Add user classification UI and predict flow

This commit is contained in:
kizadl 2026-04-28 00:22:25 +07:00
parent fa3902fcea
commit e639b0ac57
10 changed files with 446 additions and 76 deletions

View File

@ -6,27 +6,73 @@
use App\Models\Classification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
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([
'image' => 'required|image|max:2048',
'result' => 'required|string',
'confidence' => 'required',
]);
// Simpan gambar ke storage public
$path = $request->file('image')->store('classifications', 'public');
$image = $request->file('image');
try {
// 1. Kirim ke AI Server untuk prediksi
$response = Http::attach(
'image',
file_get_contents($image),
$image->getClientOriginalName()
)->post('http://127.0.0.1:5001/predict');
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');
// Simpan ke Database
Classification::create([
'user_id' => auth()->id(), // null jika guest, id jika login
'user_id' => auth()->id() ?? null,
'image_path' => $path,
'result' => $request->result,
'confidence' => (float) filter_var($request->confidence, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION),
'result' => $data['label'],
'confidence' => (float) filter_var($data['confidence'], FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION),
]);
return response()->json(['message' => 'Data tersimpan ke history']);
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');
}
}

View File

@ -20,6 +20,8 @@ createInertiaApp({
return [AppLayout, SettingsLayout];
case name.startsWith('admin/'):
return [AppLayout];
case name.startsWith('user/'):
return [AppLayout];
default:
return PublicLayout;
}

View File

@ -2,7 +2,7 @@
import { Link, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
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 NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue';
@ -23,8 +23,7 @@ const page = usePage();
// Ambil data user dari auth yang dikirim via HandleInertiaRequests
const user = computed(() => page.props.auth.user);
// 1. Definisikan semua menu, tambahkan properti 'role' untuk menu yang dibatasi
const allNavItems: NavItem[] = [
const adminNavItems: NavItem[] = [
{
title: 'Dashboard',
href: route('admin.dashboard'),
@ -34,20 +33,41 @@ const allNavItems: NavItem[] = [
title: 'Users Management',
href: route('admin.users.index'),
icon: Users,
role: 'admin', // Hanya untuk admin
},
];
// 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 userNavItems: NavItem[] = [
{
title: 'Dashboard',
href: route('dashboard'),
icon: LayoutGrid,
},
{
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[] = [
{
title: 'Repository',
@ -77,7 +97,7 @@ const footerNavItems: NavItem[] = [
</SidebarHeader>
<SidebarContent>
<NavMain :items="filteredNavItems" />
<NavMain :items="navItems" />
</SidebarContent>
<SidebarFooter>

View File

@ -4,11 +4,14 @@
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 tracking-tight font-display">
Pantau Riwayat Klasifikasi
</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.
</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
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"
@ -24,6 +27,15 @@
</Link>
</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>
</div>
</section>
@ -31,5 +43,9 @@
<script setup lang="ts">
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>

View File

@ -31,6 +31,24 @@
<!-- CTA Buttons -->
<div class="hidden md:flex items-center gap-3">
<div v-if="user">
<Link
href="/dashboard"
class="px-4 py-2 text-sm text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
>
Dashboard
</Link>
<Link
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')"
method="post"
@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"
@ -44,6 +62,7 @@
Daftar
</Link>
</div>
</div>
<!-- Mobile Menu Button -->
<button
@ -88,9 +107,13 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Menu, X } from 'lucide-vue-next'
import { Link } from '@inertiajs/vue3'
import { computed, ref } from 'vue'
import { Link, usePage, router } 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 = [
{ label: 'Klasifikasi', href: '#klasifikasi' },
@ -99,6 +122,10 @@ const navItems = [
{ label: 'F.A.Q', href: '#faq' },
]
const handleLogout = () => {
router.flushAll();
};
const hoveredIndex = ref<number | null>(null)
const mobileMenuOpen = ref(false)
</script>

View File

@ -98,6 +98,8 @@
<script setup lang="ts">
import { ref } from 'vue'
import { router } from '@inertiajs/vue3'
import { route } from 'ziggy-js'
import { Upload, Plus, X, Loader2, Bean } from 'lucide-vue-next'
const imagePreview = ref<string | null>(null)
@ -128,27 +130,37 @@ const startClassification = async () => {
formData.append('image', selectedFile.value)
try {
const response = await fetch('http://127.0.0.1:5001/predict', {
const response = await fetch('/predict', {
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()
// Pastikan data yang dikirim Flask sesuai (label & confidence)
predictionResult.value = {
label: data.label,
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)
} finally {
isLoading.value = false
}
}
const clearImage = () => {
imagePreview.value = null
selectedFile.value = null

View File

@ -23,7 +23,7 @@
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { ref, computed } from 'vue'
import { usePage } from '@inertiajs/vue3'
import {
Dialog,
@ -42,30 +42,61 @@ const flash = computed(() => page.props.flash || {})
const message = ref(null)
const type = ref(null)
const isOpen = ref(false)
let activeTimeout = null
watch(
flash,
() => {
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
}
const trigger = (msg, t) => {
if (activeTimeout) clearTimeout(activeTimeout)
if (message.value) {
setTimeout(() => {
message.value = msg
type.value = t
isOpen.value = true
activeTimeout = setTimeout(() => {
isOpen.value = false
setTimeout(() => {
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)
}
},
{ immediate: true }
)
}
// 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')
}
})
})
onUnmounted(() => {
if (unregisterFinishEvent) unregisterFinishEvent()
})
</script>

View File

@ -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>

View File

@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<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 --}}
<script>

View File

@ -6,7 +6,12 @@
use App\Http\Controllers\Admin\DashboardController;
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 () {
return inertia('public/Home', [
@ -14,7 +19,13 @@
]);
})->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::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');