Add user classification UI and predict flow
This commit is contained in:
parent
fa3902fcea
commit
e639b0ac57
|
|
@ -6,27 +6,73 @@
|
||||||
use App\Models\Classification;
|
use App\Models\Classification;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
class ClassificationController extends Controller
|
class ClassificationController extends Controller
|
||||||
{
|
{
|
||||||
public function store(Request $request)
|
public function index()
|
||||||
|
{
|
||||||
|
$classifications = Classification::where('user_id', auth()->id())->get();
|
||||||
|
return inertia('user/classification/ClassificationIndex', [
|
||||||
|
'classifications' => $classifications,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function predict(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'image' => 'required|image|max:2048',
|
'image' => 'required|image|max:2048',
|
||||||
'result' => 'required|string',
|
|
||||||
'confidence' => 'required',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Simpan gambar ke storage public
|
$image = $request->file('image');
|
||||||
$path = $request->file('image')->store('classifications', 'public');
|
|
||||||
|
|
||||||
// Simpan ke Database
|
try {
|
||||||
Classification::create([
|
// 1. Kirim ke AI Server untuk prediksi
|
||||||
'user_id' => auth()->id(), // null jika guest, id jika login
|
$response = Http::attach(
|
||||||
'image_path' => $path,
|
'image',
|
||||||
'result' => $request->result,
|
file_get_contents($image),
|
||||||
'confidence' => (float) filter_var($request->confidence, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION),
|
$image->getClientOriginalName()
|
||||||
]);
|
)->post('http://127.0.0.1:5001/predict');
|
||||||
|
|
||||||
return response()->json(['message' => 'Data tersimpan ke history']);
|
if ($response->failed()) {
|
||||||
|
return response()->json(['error' => 'Gagal terhubung ke AI server'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
// 2. Jika sukses, simpan gambar dan hasil ke database
|
||||||
|
$path = $image->store('classifications', 'public');
|
||||||
|
|
||||||
|
Classification::create([
|
||||||
|
'user_id' => auth()->id() ?? null,
|
||||||
|
'image_path' => $path,
|
||||||
|
'result' => $data['label'],
|
||||||
|
'confidence' => (float) filter_var($data['confidence'], FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'label' => $data['label'],
|
||||||
|
'confidence' => $data['confidence'],
|
||||||
|
'message' => 'Hasil klasifikasi berhasil disimpan'
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json(['error' => 'Terjadi kesalahan: ' . $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public function destroy(Classification $classification)
|
||||||
|
{
|
||||||
|
if ($classification->user_id !== auth()->id()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hapus file dari storage jika ada
|
||||||
|
if ($classification->image_path && \Storage::disk('public')->exists($classification->image_path)) {
|
||||||
|
\Storage::disk('public')->delete($classification->image_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$classification->delete();
|
||||||
|
|
||||||
|
return back()->with('success', 'Klasifikasi berhasil dihapus');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -20,6 +20,8 @@ createInertiaApp({
|
||||||
return [AppLayout, SettingsLayout];
|
return [AppLayout, SettingsLayout];
|
||||||
case name.startsWith('admin/'):
|
case name.startsWith('admin/'):
|
||||||
return [AppLayout];
|
return [AppLayout];
|
||||||
|
case name.startsWith('user/'):
|
||||||
|
return [AppLayout];
|
||||||
default:
|
default:
|
||||||
return PublicLayout;
|
return PublicLayout;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
import { Link, usePage } from '@inertiajs/vue3';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { route } from 'ziggy-js';
|
import { route } from 'ziggy-js';
|
||||||
import { BookOpen, FolderGit2, LayoutGrid, Users } from 'lucide-vue-next';
|
import { BookOpen, FolderGit2, LayoutGrid, Users, ScanSearch } from 'lucide-vue-next';
|
||||||
import AppLogo from '@/components/AppLogo.vue';
|
import AppLogo from '@/components/AppLogo.vue';
|
||||||
import NavFooter from '@/components/NavFooter.vue';
|
import NavFooter from '@/components/NavFooter.vue';
|
||||||
import NavMain from '@/components/NavMain.vue';
|
import NavMain from '@/components/NavMain.vue';
|
||||||
|
|
@ -23,8 +23,7 @@ const page = usePage();
|
||||||
// Ambil data user dari auth yang dikirim via HandleInertiaRequests
|
// Ambil data user dari auth yang dikirim via HandleInertiaRequests
|
||||||
const user = computed(() => page.props.auth.user);
|
const user = computed(() => page.props.auth.user);
|
||||||
|
|
||||||
// 1. Definisikan semua menu, tambahkan properti 'role' untuk menu yang dibatasi
|
const adminNavItems: NavItem[] = [
|
||||||
const allNavItems: NavItem[] = [
|
|
||||||
{
|
{
|
||||||
title: 'Dashboard',
|
title: 'Dashboard',
|
||||||
href: route('admin.dashboard'),
|
href: route('admin.dashboard'),
|
||||||
|
|
@ -34,20 +33,41 @@ const allNavItems: NavItem[] = [
|
||||||
title: 'Users Management',
|
title: 'Users Management',
|
||||||
href: route('admin.users.index'),
|
href: route('admin.users.index'),
|
||||||
icon: Users,
|
icon: Users,
|
||||||
role: 'admin', // Hanya untuk admin
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 2. Filter menu berdasarkan role user yang sedang login
|
const userNavItems: NavItem[] = [
|
||||||
const filteredNavItems = computed(() => {
|
{
|
||||||
return allNavItems.filter(item => {
|
title: 'Dashboard',
|
||||||
// Jika menu tidak punya batasan role, tampilkan untuk semua
|
href: route('dashboard'),
|
||||||
if (!item.role) return true;
|
icon: LayoutGrid,
|
||||||
// Jika ada batasan role, cek apakah role user cocok (admin === admin)
|
},
|
||||||
return item.role === user.value?.role;
|
{
|
||||||
});
|
title: 'Klasifikasi',
|
||||||
|
href: route('classifications.index'),
|
||||||
|
icon: ScanSearch,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
const navItems = computed(() => {
|
||||||
|
if (user.value?.role === 'admin') {
|
||||||
|
return adminNavItems;
|
||||||
|
} else {
|
||||||
|
return userNavItems;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 2. Filter menu berdasarkan role user yang sedang login
|
||||||
|
// const filteredNavItems = computed(() => {
|
||||||
|
// return allNavItems.filter(item => {
|
||||||
|
// // Jika menu tidak punya batasan role, tampilkan untuk semua
|
||||||
|
// if (!item.role) return true;
|
||||||
|
// // Jika ada batasan role, cek apakah role user cocok (admin === admin)
|
||||||
|
// return item.role === user.value?.role;
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
|
||||||
const footerNavItems: NavItem[] = [
|
const footerNavItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Repository',
|
title: 'Repository',
|
||||||
|
|
@ -77,7 +97,7 @@ const footerNavItems: NavItem[] = [
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain :items="filteredNavItems" />
|
<NavMain :items="navItems" />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,14 @@
|
||||||
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 tracking-tight font-display">
|
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 tracking-tight font-display">
|
||||||
Pantau Riwayat Klasifikasi
|
Pantau Riwayat Klasifikasi
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-lg sm:text-xl text-zinc-400 mb-10 max-w-2xl mx-auto">
|
<p v-if="!user" class="text-lg sm:text-xl text-zinc-400 mb-10 max-w-2xl mx-auto">
|
||||||
Masuk ke akun Anda sekarang untuk menyimpan hasil dan melihat kembali seluruh riwayat klasifikasi biji kopi yang pernah Anda lakukan.
|
Masuk ke akun Anda sekarang untuk menyimpan hasil dan melihat kembali seluruh riwayat klasifikasi biji kopi yang pernah Anda lakukan.
|
||||||
</p>
|
</p>
|
||||||
|
<p v-else class="text-lg sm:text-xl text-zinc-400 mb-10 max-w-2xl mx-auto">
|
||||||
|
Pantau riwayat klasifikasi biji kopi Anda sekarang.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
<div v-if="!user" class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-8 py-3 text-base font-bold shadow-lg shadow-emerald-500/20 flex items-center gap-2 transition-all active:scale-95"
|
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-8 py-3 text-base font-bold shadow-lg shadow-emerald-500/20 flex items-center gap-2 transition-all active:scale-95"
|
||||||
|
|
@ -24,6 +27,15 @@
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-8 py-3 text-base font-bold shadow-lg shadow-emerald-500/20 flex items-center gap-2 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="mt-8 text-sm text-zinc-500">Gratis sepenuhnya. Jangan biarkan riwayat klasifikasi Anda hilang.</p>
|
<p class="mt-8 text-sm text-zinc-500">Gratis sepenuhnya. Jangan biarkan riwayat klasifikasi Anda hilang.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -31,5 +43,9 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ArrowRight } from 'lucide-vue-next'
|
import { ArrowRight } from 'lucide-vue-next'
|
||||||
import { Link } from '@inertiajs/vue3'
|
import { Link, usePage } from '@inertiajs/vue3'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const page = usePage()
|
||||||
|
const user = computed(() => page.props.auth?.user)
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -31,18 +31,37 @@
|
||||||
|
|
||||||
<!-- CTA Buttons -->
|
<!-- CTA Buttons -->
|
||||||
<div class="hidden md:flex items-center gap-3">
|
<div class="hidden md:flex items-center gap-3">
|
||||||
<Link
|
<div v-if="user">
|
||||||
href="/login"
|
<Link
|
||||||
class="px-4 py-2 text-sm text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
href="/dashboard"
|
||||||
>
|
class="px-4 py-2 text-sm text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||||
Masuk
|
>
|
||||||
</Link>
|
Dashboard
|
||||||
<Link
|
</Link>
|
||||||
href="/register"
|
<Link
|
||||||
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-4 py-2 text-sm font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
|
class="shimmer-btn bg-red-500 text-zinc-950 hover:bg-red-400 rounded-full px-4 py-2 text-sm font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-95 inline-flex items-center"
|
||||||
>
|
:href="route('logout')"
|
||||||
Daftar
|
method="post"
|
||||||
</Link>
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
<LogOut class="mr-2 h-4 w-4" />
|
||||||
|
Log out
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
class="px-4 py-2 text-sm text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Masuk
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-4 py-2 text-sm font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Daftar
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Menu Button -->
|
<!-- Mobile Menu Button -->
|
||||||
|
|
@ -88,9 +107,13 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { Menu, X } from 'lucide-vue-next'
|
import { Link, usePage, router } from '@inertiajs/vue3'
|
||||||
import { Link } from '@inertiajs/vue3'
|
import { LogOut, Menu, X } from 'lucide-vue-next'
|
||||||
|
import { route } from 'ziggy-js';
|
||||||
|
|
||||||
|
const page = usePage()
|
||||||
|
const user = computed(() => page.props.auth?.user)
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: 'Klasifikasi', href: '#klasifikasi' },
|
{ label: 'Klasifikasi', href: '#klasifikasi' },
|
||||||
|
|
@ -99,6 +122,10 @@ const navItems = [
|
||||||
{ label: 'F.A.Q', href: '#faq' },
|
{ label: 'F.A.Q', href: '#faq' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
router.flushAll();
|
||||||
|
};
|
||||||
|
|
||||||
const hoveredIndex = ref<number | null>(null)
|
const hoveredIndex = ref<number | null>(null)
|
||||||
const mobileMenuOpen = ref(false)
|
const mobileMenuOpen = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,8 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { router } from '@inertiajs/vue3'
|
||||||
|
import { route } from 'ziggy-js'
|
||||||
import { Upload, Plus, X, Loader2, Bean } from 'lucide-vue-next'
|
import { Upload, Plus, X, Loader2, Bean } from 'lucide-vue-next'
|
||||||
|
|
||||||
const imagePreview = ref<string | null>(null)
|
const imagePreview = ref<string | null>(null)
|
||||||
|
|
@ -128,27 +130,37 @@ const startClassification = async () => {
|
||||||
formData.append('image', selectedFile.value)
|
formData.append('image', selectedFile.value)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://127.0.0.1:5001/predict', {
|
const response = await fetch('/predict', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Gagal terhubung ke AI server")
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.error || "Gagal melakukan klasifikasi")
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
// Pastikan data yang dikirim Flask sesuai (label & confidence)
|
|
||||||
predictionResult.value = {
|
predictionResult.value = {
|
||||||
label: data.label,
|
label: data.label,
|
||||||
confidence: data.confidence
|
confidence: data.confidence
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
alert("Error: Pastikan backend-ai sudah dijalankan (python app.py)")
|
// Refresh data jika di Inertia context (dashboard)
|
||||||
|
if (typeof route === 'function' && route().current('classifications.index')) {
|
||||||
|
router.reload({ only: ['classifications'] })
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const clearImage = () => {
|
const clearImage = () => {
|
||||||
imagePreview.value = null
|
imagePreview.value = null
|
||||||
selectedFile.value = null
|
selectedFile.value = null
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { usePage } from '@inertiajs/vue3'
|
import { usePage } from '@inertiajs/vue3'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -42,30 +42,61 @@ const flash = computed(() => page.props.flash || {})
|
||||||
const message = ref(null)
|
const message = ref(null)
|
||||||
const type = ref(null)
|
const type = ref(null)
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
|
let activeTimeout = null
|
||||||
|
|
||||||
watch(
|
const trigger = (msg, t) => {
|
||||||
flash,
|
if (activeTimeout) clearTimeout(activeTimeout)
|
||||||
() => {
|
|
||||||
if (flash.value.success) {
|
|
||||||
message.value = flash.value.success
|
|
||||||
type.value = 'success'
|
|
||||||
isOpen.value = true
|
|
||||||
} else if (flash.value.error) {
|
|
||||||
message.value = flash.value.error
|
|
||||||
type.value = 'error'
|
|
||||||
isOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.value) {
|
message.value = msg
|
||||||
setTimeout(() => {
|
type.value = t
|
||||||
isOpen.value = false
|
isOpen.value = true
|
||||||
setTimeout(() => {
|
|
||||||
message.value = null
|
activeTimeout = setTimeout(() => {
|
||||||
type.value = null
|
isOpen.value = false
|
||||||
}, 300)
|
setTimeout(() => {
|
||||||
}, 2500)
|
message.value = null
|
||||||
|
type.value = null
|
||||||
|
activeTimeout = null
|
||||||
|
}, 300)
|
||||||
|
}, 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showNotification = (msg, t) => {
|
||||||
|
if (!msg) return
|
||||||
|
|
||||||
|
// Jika sudah terbuka, kita reset agar animasinya terpicu ulang
|
||||||
|
if (isOpen.value) {
|
||||||
|
isOpen.value = false
|
||||||
|
setTimeout(() => trigger(msg, t), 100)
|
||||||
|
} else {
|
||||||
|
trigger(msg, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gunakan router event agar selalu terdeteksi setiap kali request selesai
|
||||||
|
import { router } from '@inertiajs/vue3'
|
||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
let unregisterFinishEvent = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Cek saat mount awal
|
||||||
|
if (flash.value.success || flash.value.error) {
|
||||||
|
showNotification(flash.value.success || flash.value.error, flash.value.success ? 'success' : 'error')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dengarkan setiap kali aksi Inertia selesai
|
||||||
|
unregisterFinishEvent = router.on('finish', () => {
|
||||||
|
const currentFlash = page.props.flash || {}
|
||||||
|
if (currentFlash.success || currentFlash.error) {
|
||||||
|
showNotification(currentFlash.success || currentFlash.error, currentFlash.success ? 'success' : 'error')
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
{ immediate: true }
|
})
|
||||||
)
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unregisterFinishEvent) unregisterFinishEvent()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Head, router } from '@inertiajs/vue3';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Trash2, Plus, History, ScanSearch, Calendar, AlertCircle } from 'lucide-vue-next';
|
||||||
|
import Scanner from '@/components/public/Scanner.vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { route } from 'ziggy-js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
layout: {
|
||||||
|
breadcrumbs: [
|
||||||
|
{
|
||||||
|
title: 'Klasifikasi',
|
||||||
|
href: route('classifications.index'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
classifications: any[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const showScanner = ref(false);
|
||||||
|
const isDeleteDialogOpen = ref(false);
|
||||||
|
const itemToDelete = ref<number | null>(null);
|
||||||
|
const isDeleting = ref(false);
|
||||||
|
|
||||||
|
const confirmDelete = (id: number) => {
|
||||||
|
itemToDelete.value = id;
|
||||||
|
isDeleteDialogOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteClassification = () => {
|
||||||
|
if (!itemToDelete.value) return;
|
||||||
|
|
||||||
|
isDeleting.value = true;
|
||||||
|
router.delete(route('classifications.destroy', itemToDelete.value), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
isDeleteDialogOpen.value = false;
|
||||||
|
itemToDelete.value = null;
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
isDeleting.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const toggleScanner = () => {
|
||||||
|
showScanner.value = !showScanner.value;
|
||||||
|
if (!showScanner.value) {
|
||||||
|
// Refresh data saat menutup scanner jika ada data baru
|
||||||
|
router.reload({ only: ['classifications'] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Klasifikasi Kopi" />
|
||||||
|
|
||||||
|
<div class="flex h-full flex-1 flex-col gap-8 rounded-xl p-4 md:p-8 overflow-x-hidden">
|
||||||
|
<!-- Header Section -->
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight flex items-center gap-3">
|
||||||
|
<History class="w-8 h-8 text-emerald-500" />
|
||||||
|
Riwayat Klasifikasi
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground mt-1">Kelola dan lihat hasil analisis biji kopi Anda.</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
@click="toggleScanner"
|
||||||
|
:variant="showScanner ? 'outline' : 'default'"
|
||||||
|
class="w-full sm:w-auto h-12 px-6 rounded-xl font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<template v-if="!showScanner">
|
||||||
|
<Plus class="mr-2 h-5 w-5" />
|
||||||
|
Klasifikasi Baru
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Tutup Scanner
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scanner Section -->
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition duration-300 ease-out"
|
||||||
|
enter-from-class="transform -translate-y-4 opacity-0"
|
||||||
|
enter-to-class="transform translate-y-0 opacity-100"
|
||||||
|
leave-active-class="transition duration-200 ease-in"
|
||||||
|
leave-from-class="transform translate-y-0 opacity-100"
|
||||||
|
leave-to-class="transform -translate-y-4 opacity-0"
|
||||||
|
>
|
||||||
|
<div v-if="showScanner" class="mb-12 rounded-3xl overflow-hidden shadow-2xl border border-emerald-500/20">
|
||||||
|
<Scanner />
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- History Grid -->
|
||||||
|
<div v-if="classifications.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pb-12">
|
||||||
|
<div
|
||||||
|
v-for="item in classifications"
|
||||||
|
:key="item.id"
|
||||||
|
class="group relative bg-white dark:bg-zinc-900 rounded-[2rem] border border-zinc-200 dark:border-zinc-800 p-4 flex gap-5 items-center shadow-sm hover:shadow-xl hover:border-emerald-500/30 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<!-- Small Photo Thumbnail -->
|
||||||
|
<div class="w-24 h-24 sm:w-32 sm:h-32 rounded-2xl overflow-hidden bg-zinc-100 dark:bg-zinc-950 flex-shrink-0 relative">
|
||||||
|
<img
|
||||||
|
:src="'/storage/' + item.image_path"
|
||||||
|
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Section -->
|
||||||
|
<div class="flex-1 min-w-0 flex flex-col justify-between py-1 pr-6">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<div class="flex items-center gap-2 text-[9px] font-bold text-zinc-400 uppercase tracking-widest mb-1">
|
||||||
|
<Calendar class="w-3 h-3" />
|
||||||
|
{{ new Date(item.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'short' }) }}
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-black text-emerald-600 dark:text-emerald-500 italic truncate leading-none">{{ item.result }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-[9px] font-bold text-zinc-400 uppercase tracking-widest">Confidence</p>
|
||||||
|
<p class="text-lg font-bold text-zinc-900 dark:text-zinc-100 leading-none mt-1">{{ Number(item.confidence).toFixed(2) }}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="h-1.5 w-full bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full bg-emerald-500 rounded-full transition-all duration-1000"
|
||||||
|
:style="{ width: item.confidence + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tombol Hapus Pojok Kanan Bawah -->
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
@click="confirmDelete(item.id)"
|
||||||
|
class="absolute bottom-3 right-3 h-8 w-8 text-zinc-400 hover:text-red-500 hover:bg-red-50/50 dark:hover:bg-red-950/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="!showScanner" class="text-center py-32 bg-white dark:bg-zinc-900 rounded-[2.5rem] border-2 border-dashed border-zinc-200 dark:border-zinc-800 shadow-sm mb-12">
|
||||||
|
<div class="inline-flex p-6 rounded-3xl bg-zinc-50 dark:bg-zinc-950 mb-6">
|
||||||
|
<ScanSearch class="w-12 h-12 text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-bold text-zinc-900 dark:text-white mb-2">Belum ada riwayat</h3>
|
||||||
|
<p class="text-zinc-500 dark:text-zinc-400 max-w-xs mx-auto mb-8">Anda belum pernah melakukan klasifikasi biji kopi. Mulai sekarang untuk melihat hasilnya di sini.</p>
|
||||||
|
<Button @click="showScanner = true" class="h-12 px-8 rounded-xl font-bold">
|
||||||
|
Mulai Klasifikasi Pertama
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Dialog -->
|
||||||
|
<Dialog :open="isDeleteDialogOpen" @update:open="isDeleteDialogOpen = $event">
|
||||||
|
<DialogContent class="sm:max-w-[425px] rounded-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<div class="mx-auto w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-4">
|
||||||
|
<AlertCircle class="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<DialogTitle class="text-center text-xl font-bold">Hapus Riwayat?</DialogTitle>
|
||||||
|
<DialogDescription class="text-center">
|
||||||
|
Tindakan ini tidak dapat dibatalkan. Riwayat klasifikasi dan foto akan dihapus secara permanen dari server kami.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter class="flex flex-col sm:flex-row gap-2 mt-4">
|
||||||
|
<Button variant="outline" @click="isDeleteDialogOpen = false" class="rounded-xl flex-1 h-12 font-bold">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
@click="deleteClassification"
|
||||||
|
:disabled="isDeleting"
|
||||||
|
class="rounded-xl flex-1 h-12 font-bold"
|
||||||
|
>
|
||||||
|
{{ isDeleting ? 'Menghapus...' : 'Ya, Hapus' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
|
||||||
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
||||||
<script>
|
<script>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,12 @@
|
||||||
use App\Http\Controllers\Admin\DashboardController;
|
use App\Http\Controllers\Admin\DashboardController;
|
||||||
use App\Http\Controllers\Public\ClassificationController; // Import Controller
|
use App\Http\Controllers\Public\ClassificationController; // Import Controller
|
||||||
|
|
||||||
Route::redirect('dashboard', 'admin/dashboard')->name('dashboard');
|
Route::get('dashboard', function () {
|
||||||
|
if (auth()->user()->role === 'admin') {
|
||||||
|
return redirect()->route('admin.dashboard');
|
||||||
|
}
|
||||||
|
return app(DashboardController::class)->index();
|
||||||
|
})->middleware(['auth', 'verified'])->name('dashboard');
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return inertia('public/Home', [
|
return inertia('public/Home', [
|
||||||
|
|
@ -14,7 +19,13 @@
|
||||||
]);
|
]);
|
||||||
})->name('home');
|
})->name('home');
|
||||||
|
|
||||||
Route::post('/classifications', [ClassificationController::class, 'store'])->name('public.classifications.store');
|
Route::post('/predict', [ClassificationController::class, 'predict'])->name('public.predict');
|
||||||
|
|
||||||
|
Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
|
Route::get('/classifications', [ClassificationController::class, 'index'])->name('classifications.index');
|
||||||
|
Route::delete('/classifications/{classification}', [ClassificationController::class, 'destroy'])->name('classifications.destroy');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
Route::prefix('admin')->name('admin.')->middleware(['auth', 'verified'])->group(function () {
|
Route::prefix('admin')->name('admin.')->middleware(['auth', 'verified'])->group(function () {
|
||||||
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue