Add admin classifications page and UI tweaks
This commit is contained in:
parent
44e69d13c5
commit
d444dd0672
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Classification;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class ClassificationController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$classifications = Classification::with('user')
|
||||||
|
->latest()
|
||||||
|
->paginate(10);
|
||||||
|
|
||||||
|
return Inertia::render('admin/Classification', [
|
||||||
|
'classifications' => $classifications
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Classification $classification)
|
||||||
|
{
|
||||||
|
$classification->delete();
|
||||||
|
return redirect()->back()->with('success', 'History classification deleted successfully.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,11 @@ const user = computed(() => page.props.auth.user);
|
||||||
href: route('admin.users.index'),
|
href: route('admin.users.index'),
|
||||||
icon: Users,
|
icon: Users,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'History Klasifikasi',
|
||||||
|
href: route('admin.classifications.index'),
|
||||||
|
icon: ScanSearch,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const userNavItems: NavItem[] = [
|
const userNavItems: NavItem[] = [
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@
|
||||||
:src="avatar"
|
:src="avatar"
|
||||||
:alt="`Avatar ${index + 1}`"
|
:alt="`Avatar ${index + 1}`"
|
||||||
class="w-10 h-10 rounded-full border-2 border-zinc-950 object-cover"
|
class="w-10 h-10 rounded-full border-2 border-zinc-950 object-cover"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-zinc-500">
|
<p class="text-sm text-zinc-500">
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<img :src="imagePreview" class="absolute inset-0 w-full h-full object-cover" />
|
<img :src="imagePreview" class="absolute inset-0 w-full h-full object-cover" loading="lazy" />
|
||||||
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover/upload:opacity-100 transition-opacity duration-300 flex items-center justify-center backdrop-blur-[2px]">
|
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover/upload:opacity-100 transition-opacity duration-300 flex items-center justify-center backdrop-blur-[2px]">
|
||||||
<span class="bg-black/60 text-white px-4 py-2 rounded-full text-sm font-medium">Klik untuk mengganti</span>
|
<span class="bg-black/60 text-white px-4 py-2 rounded-full text-sm font-medium">Klik untuk mengganti</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const props = defineProps<AvatarImageProps>()
|
||||||
data-slot="avatar-image"
|
data-slot="avatar-image"
|
||||||
v-bind="props"
|
v-bind="props"
|
||||||
class="aspect-square size-full"
|
class="aspect-square size-full"
|
||||||
|
loading="lazy"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</AvatarImage>
|
</AvatarImage>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Head } from '@inertiajs/vue3';
|
||||||
|
import { route } from 'ziggy-js';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationFirst,
|
||||||
|
PaginationLast,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from '@/components/ui/pagination';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { router } from '@inertiajs/vue3';
|
||||||
|
import { Trash2, Eye } from 'lucide-vue-next';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
layout: {
|
||||||
|
breadcrumbs: [
|
||||||
|
{
|
||||||
|
title: 'History Klasifikasi',
|
||||||
|
href: route('admin.classifications.index'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
classifications: Object,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isViewDialogOpen = ref(false);
|
||||||
|
const selectedImage = ref('');
|
||||||
|
|
||||||
|
const viewImage = (path: string) => {
|
||||||
|
selectedImage.value = '/storage/' + path;
|
||||||
|
isViewDialogOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePage = (page: number) => {
|
||||||
|
router.visit(route('admin.classifications.index', { page }), {
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteClassification = (id: number) => {
|
||||||
|
if (confirm('Apakah Anda yakin ingin menghapus history ini?')) {
|
||||||
|
router.delete(route('admin.classifications.destroy', id), {
|
||||||
|
preserveScroll: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleString('id-ID', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResultColor = (result: string) => {
|
||||||
|
switch (result?.toLowerCase()) {
|
||||||
|
case 'honey':
|
||||||
|
return 'bg-amber-100 text-amber-800 border-amber-200';
|
||||||
|
case 'washed':
|
||||||
|
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||||
|
case 'natural':
|
||||||
|
return 'bg-emerald-100 text-emerald-800 border-emerald-200';
|
||||||
|
default:
|
||||||
|
return 'bg-primary/10 text-primary border-primary/20';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="History Klasifikasi" />
|
||||||
|
|
||||||
|
<div class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight">History Klasifikasi</h1>
|
||||||
|
<p class="text-muted-foreground">Daftar semua hasil klasifikasi yang dilakukan oleh pengguna maupun tamu.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Waktu</TableHead>
|
||||||
|
<TableHead>User Pembuat</TableHead>
|
||||||
|
<TableHead>Hasil</TableHead>
|
||||||
|
<TableHead>Confidence</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead class="text-center">Aksi</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-if="!props.classifications?.data?.length">
|
||||||
|
<TableCell colspan="6" class="h-24 text-center">Tidak ada data klasifikasi.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow v-for="item in props.classifications?.data" :key="item.id" class="hover:bg-muted/50">
|
||||||
|
<TableCell>{{ formatDate(item.created_at) }}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ item.user ? item.user.name : 'Guess' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="item.user" class="text-xs text-muted-foreground">
|
||||||
|
{{ item.user.email }}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span :class="[
|
||||||
|
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium border',
|
||||||
|
getResultColor(item.result)
|
||||||
|
]">
|
||||||
|
{{ item.result }}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{{ item.confidence }}%</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span :class="[
|
||||||
|
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||||
|
item.status === 'Berhasil' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||||
|
]">
|
||||||
|
{{ item.status }}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon" @click="viewImage(item.image_path)" v-if="item.image_path" title="Lihat Foto">
|
||||||
|
<Eye class="h-4 w-4 text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" @click="deleteClassification(item.id)" title="Hapus">
|
||||||
|
<Trash2 class="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div v-if="classifications && classifications.last_page > 1" class="flex justify-center mt-4">
|
||||||
|
<Pagination v-slot="{ page }" :items-per-page="classifications.per_page" :total="classifications.total" :default-page="classifications.current_page">
|
||||||
|
<PaginationContent v-slot="{ items }" class="flex items-center gap-1">
|
||||||
|
<PaginationFirst @click="changePage(1)" />
|
||||||
|
<PaginationPrevious @click="changePage(classifications.current_page - 1)" />
|
||||||
|
|
||||||
|
<template v-for="(item, index) in items">
|
||||||
|
<PaginationItem v-if="item.type === 'page'" :key="index" :value="item.value" as-child>
|
||||||
|
<Button
|
||||||
|
class="h-9 w-9 p-0"
|
||||||
|
:variant="item.value === classifications.current_page ? 'default' : 'outline'"
|
||||||
|
@click="changePage(item.value)"
|
||||||
|
>
|
||||||
|
{{ item.value }}
|
||||||
|
</Button>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationEllipsis v-else :key="item.type" :index="index" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<PaginationNext @click="changePage(classifications.current_page + 1)" />
|
||||||
|
<PaginationLast @click="changePage(classifications.last_page)" />
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View Photo Dialog -->
|
||||||
|
<Dialog :open="isViewDialogOpen" @update:open="isViewDialogOpen = $event">
|
||||||
|
<DialogContent class="max-w-3xl rounded-3xl overflow-hidden p-0">
|
||||||
|
<DialogHeader class="p-6 pb-0">
|
||||||
|
<DialogTitle>Detail Foto Klasifikasi</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="aspect-video rounded-2xl overflow-hidden bg-muted flex items-center justify-center border">
|
||||||
|
<img :src="selectedImage" class="w-full h-full object-contain" alt="Classification result" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -36,12 +36,28 @@ const props = defineProps<{
|
||||||
const chartConfig = computed(() => ({
|
const chartConfig = computed(() => ({
|
||||||
labels: props.chartData.map(item => item.result),
|
labels: props.chartData.map(item => item.result),
|
||||||
datasets: [{
|
datasets: [{
|
||||||
backgroundColor: ['#eab308', '#22c55e', '#3b82f6'], // Honey (Yellow), Natural (Green), Washed (Blue)
|
backgroundColor: props.chartData.map(item => {
|
||||||
|
switch (item.result?.toLowerCase()) {
|
||||||
|
case 'honey': return '#f59e0b'; // Amber-500
|
||||||
|
case 'washed': return '#3b82f6'; // Blue-500
|
||||||
|
case 'natural': return '#10b981'; // Emerald-500
|
||||||
|
default: return '#94a3b8'; // Slate-400
|
||||||
|
}
|
||||||
|
}),
|
||||||
data: props.chartData.map(item => item.total),
|
data: props.chartData.map(item => item.total),
|
||||||
borderWidth: 1
|
borderWidth: 1
|
||||||
}]
|
}]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const getResultColorClass = (result: string) => {
|
||||||
|
switch (result?.toLowerCase()) {
|
||||||
|
case 'honey': return 'text-amber-600 dark:text-amber-500';
|
||||||
|
case 'washed': return 'text-blue-600 dark:text-blue-500';
|
||||||
|
case 'natural': return 'text-emerald-600 dark:text-emerald-500';
|
||||||
|
default: return 'text-muted-foreground';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const chartOptions = {
|
const chartOptions = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
|
@ -133,11 +149,13 @@ const chartOptions = {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="[&_tr:last-child]:border-0">
|
<tbody class="[&_tr:last-child]:border-0">
|
||||||
<tr v-for="item in recentHistory" :key="item.id" class="border-b transition-colors hover:bg-muted/50">
|
<tr v-for="item in recentHistory" :key="item.id" class="border-b transition-colors hover:bg-muted/50">
|
||||||
<td class="p-4 align-middle">
|
<td class="p-4 align-middle text-muted-foreground">
|
||||||
{{ new Date(item.created_at).toLocaleDateString('id-ID') }}
|
{{ new Date(item.created_at).toLocaleDateString('id-ID', { day: '2-digit', month: 'short' }) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-4 align-middle font-medium uppercase">{{ item.result }}</td>
|
<td class="p-4 align-middle font-black italic tracking-tight" :class="getResultColorClass(item.result)">
|
||||||
<td class="p-4 align-middle">{{ item.confidence }}%</td>
|
{{ item.result }}
|
||||||
|
</td>
|
||||||
|
<td class="p-4 align-middle font-medium">{{ item.confidence }}%</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="recentHistory.length === 0">
|
<tr v-if="recentHistory.length === 0">
|
||||||
<td colspan="3" class="p-4 text-center text-muted-foreground">Tidak ada data.</td>
|
<td colspan="3" class="p-4 text-center text-muted-foreground">Tidak ada data.</td>
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,32 @@ const toggleScanner = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getResultColorText = (result: string) => {
|
||||||
|
switch (result?.toLowerCase()) {
|
||||||
|
case 'honey':
|
||||||
|
return 'text-amber-600 dark:text-amber-500';
|
||||||
|
case 'washed':
|
||||||
|
return 'text-blue-600 dark:text-blue-500';
|
||||||
|
case 'natural':
|
||||||
|
return 'text-emerald-600 dark:text-emerald-500';
|
||||||
|
default:
|
||||||
|
return 'text-emerald-600 dark:text-emerald-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResultColorBar = (result: string) => {
|
||||||
|
switch (result?.toLowerCase()) {
|
||||||
|
case 'honey':
|
||||||
|
return 'bg-amber-500';
|
||||||
|
case 'washed':
|
||||||
|
return 'bg-blue-500';
|
||||||
|
case 'natural':
|
||||||
|
return 'bg-emerald-500';
|
||||||
|
default:
|
||||||
|
return 'bg-emerald-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -120,6 +146,7 @@ const toggleScanner = () => {
|
||||||
<img
|
<img
|
||||||
:src="'/storage/' + item.image_path"
|
:src="'/storage/' + item.image_path"
|
||||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -131,7 +158,7 @@ const toggleScanner = () => {
|
||||||
<Calendar class="w-3 h-3" />
|
<Calendar class="w-3 h-3" />
|
||||||
{{ new Date(item.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'short' }) }}
|
{{ new Date(item.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'short' }) }}
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-xl font-black text-emerald-600 dark:text-emerald-500 italic truncate leading-none">{{ item.result }}</h3>
|
<h3 class="text-xl font-black italic truncate leading-none" :class="getResultColorText(item.result)">{{ item.result }}</h3>
|
||||||
<div
|
<div
|
||||||
class="mt-2 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider border"
|
class="mt-2 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider border"
|
||||||
:class="(item.status === 'Berhasil' || (!item.status && Number(item.confidence) >= 90))
|
:class="(item.status === 'Berhasil' || (!item.status && Number(item.confidence) >= 90))
|
||||||
|
|
@ -152,7 +179,7 @@ const toggleScanner = () => {
|
||||||
<div class="h-1.5 w-full bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
<div class="h-1.5 w-full bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full transition-all duration-1000"
|
class="h-full rounded-full transition-all duration-1000"
|
||||||
:class="(item.status === 'Berhasil' || (!item.status && Number(item.confidence) >= 90)) ? 'bg-emerald-500' : 'bg-red-500'"
|
:class="getResultColorBar(item.result)"
|
||||||
:style="{ width: item.confidence + '%' }"
|
:style="{ width: item.confidence + '%' }"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
use App\Http\Controllers\Admin\UserController;
|
use App\Http\Controllers\Admin\UserController;
|
||||||
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;
|
||||||
|
use App\Http\Controllers\Admin\ClassificationController as AdminClassificationController;
|
||||||
|
|
||||||
Route::get('dashboard', function () {
|
Route::get('dashboard', function () {
|
||||||
if (auth()->user()->role === 'admin') {
|
if (auth()->user()->role === 'admin') {
|
||||||
|
|
@ -30,9 +31,8 @@
|
||||||
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');
|
||||||
Route::resource('users', UserController::class)->names('users');
|
Route::resource('users', UserController::class)->names('users');
|
||||||
|
Route::get('classifications', [AdminClassificationController::class, 'index'])->name('classifications.index');
|
||||||
// Route tambahan untuk Admin jika ingin melihat history klasifikasi secara global
|
Route::delete('classifications/{classification}', [AdminClassificationController::class, 'destroy'])->name('classifications.destroy');
|
||||||
// Route::get('history', [DashboardController::class, 'history'])->name('history');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue