Compare commits

...

10 Commits

Author SHA1 Message Date
kizadl af383d9094 final 2026-06-21 20:58:03 +07:00
kizadl 2e0400c900 Update Home 2026-05-30 22:32:17 +07:00
kizadl 7c969b72de panduan pengambilan foto 2026-05-20 11:12:33 +07:00
kizadl 86c4fb3189 upload model h5 2026-05-15 21:35:51 +07:00
kizadl a00caaa24b flask service 2026-05-14 14:01:02 +07:00
kizadl d444dd0672 Add admin classifications page and UI tweaks 2026-04-28 01:11:54 +07:00
kizadl 44e69d13c5 Add classification status, metrics, and UI updates 2026-04-28 00:49:19 +07:00
kizadl e639b0ac57 Add user classification UI and predict flow 2026-04-28 00:22:25 +07:00
kizadl fa3902fcea Add user role support and UI 2026-04-27 22:24:16 +07:00
kizadl e38215b1b4 update ROLE 2026-04-25 22:45:49 +07:00
42 changed files with 1471 additions and 182 deletions

View File

@ -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.');
}
}

View File

@ -5,14 +5,53 @@
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use App\Models\Classification; // Import model Classification
use Illuminate\Support\Facades\DB;
use App\Models\User;
class DashboardController extends Controller
{
public function index()
{
$user = Auth::user();
// Mulai Query Dasar
$query = Classification::query();
// LOGIKA ROLE: Jika yang login adalah 'user', kunci hanya data miliknya
if ($user->role === 'user') {
$query->where('user_id', $user->id);
}
// Jika admin, biarkan query mengambil semua data (tanpa where)
// Ambil data Chart (sudah terfilter role)
$chartData = (clone $query)->select('result', DB::raw('count(*) as total'))
->groupBy('result')
->get();
// Ambil History Terbaru (sudah terfilter role)
$recentHistory = (clone $query)->latest()->limit(10)->get();
// Hitung Total Scan (sudah terfilter role)
$totalScan = (clone $query)->count();
// Rata-rata akurasi dari yang berhasil
$avgAccuracy = (clone $query)->where('status', 'Berhasil')->avg('confidence') ?? 0;
// Tingkat keberhasilan (Total Berhasil / Total Scan)
$totalBerhasil = (clone $query)->where('status', 'Berhasil')->count();
$successRate = $totalScan > 0 ? ($totalBerhasil / $totalScan) * 100 : 0;
$totalUser = User::where('role', 'user')->count();
return inertia('admin/Dashboard', [
'user' => $user,
'chartData' => $chartData,
'recentHistory' => $recentHistory,
'totalScan' => $totalScan,
'avgAccuracy' => round($avgAccuracy, 2),
'successRate' => round($successRate, 2),
'totalUser' => $totalUser,
]);
}
}
}

View File

@ -41,6 +41,7 @@ public function store(Request $request)
$validatedData = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'role' => 'required|in:user,admin',
'password' => 'required|string|min:8|confirmed',
]);
@ -49,6 +50,7 @@ public function store(Request $request)
$user = User::create([
'name' => $validatedData['name'],
'email' => $validatedData['email'],
'role' => $validatedData['role'],
'password' => Hash::make($validatedData['password']),
]);
@ -90,6 +92,7 @@ public function update(Request $request, string $id)
$validatedData = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users,email,' . $id,
'role' => 'required|in:user,admin',
'password' => 'nullable|string|min:8|confirmed',
]);
$password = $validatedData['password'];
@ -102,6 +105,7 @@ public function update(Request $request, string $id)
$update = [
'name' => $validatedData['name'],
'email' => $validatedData['email'],
'role' => $validatedData['role'],
];
if ($password) {
$update['password'] = $password;

View File

@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\Classification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class ClassificationController extends Controller
{
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',
]);
$image = $request->file('image');
try {
// 1. Kirim ke AI Server (Flask)
$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. SIMPAN KE DB (BAIK BERHASIL MAUPUN DITOLAK)
$path = $image->store('classifications', 'public');
$confidence = (float) filter_var($data['confidence'] ?? 0, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION);
// Tentukan status untuk Database
$dbStatus = ($data['status'] === 'BERHASIL') ? 'Berhasil' : 'Ditolak';
Classification::create([
'user_id' => auth()->id() ?? null,
'image_path' => $path,
'result' => $data['label'] ?? 'Unknown',
'confidence' => $confidence,
'status' => $dbStatus,
]);
// 3. KEMBALIKAN KE VUE (Bawa serta 'status' asli dari Flask)
return response()->json([
'label' => $data['label'] ?? 'Unknown',
'confidence' => $data['confidence'] ?? '0',
'message' => $data['pesan'] ?? 'Hasil klasifikasi selesai.',
'status' => $data['status'] // INI PENTING UNTUK SINKRONISASI
]);
} catch (\Exception $e) {
return response()->json(['error' => 'Terjadi kesalahan internal: ' . $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

@ -39,12 +39,18 @@ public function share(Request $request): array
...parent::share($request),
'name' => config('app.name'),
'auth' => [
'user' => $request->user(),
'user' => $request->user() ? [
'id' => $request->user()->id,
'name' => $request->user()->name,
'email' => $request->user()->email,
'role' => $request->user()->role, // Memastikan role terkirim dengan jelas
// Jangan kirim password_hash atau data sensitif lainnya di sini
] : null,
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'sidebarOpen' => !$request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'flash' => [
'success' => fn () => $request->session()->get('success'),
'error' => fn () => $request->session()->get('error'),
'success' => fn() => $request->session()->get('success'),
'error' => fn() => $request->session()->get('error'),
],
];
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RoleMiddleware
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
return $next($request);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Classification extends Model
{
//
protected $fillable = [
'user_id',
'image_path',
'result',
'confidence',
'status'
];
// Relasi ke User (Satu klasifikasi dimiliki satu User)
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -11,7 +11,7 @@
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
#[Fillable(['name', 'email', 'password'])]
#[Fillable(['name', 'email', 'password', 'role'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable
{

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,72 +1,131 @@
import os
import io
import numpy as np
import tensorflow as tf
import joblib
from flask import Flask, request, jsonify
from flask_cors import CORS
from tensorflow.keras.models import load_model, Sequential
from tensorflow.keras.preprocessing.image import img_to_array
from tensorflow.keras.applications.mobilenet_v2 import preprocess_input
from PIL import Image
from rembg import remove
from flask_cors import CORS
app = Flask(__name__)
CORS(app)
# 1. SETUP PATH MODEL
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_PATH = os.path.join(BASE_DIR, 'model_efficientNet.keras')
# ==============================================================================
# 1. LOAD SEMUA MODEL (KLASIFIKATOR + SATPAM 1 & 2)
# ==============================================================================
MODEL_CNN_PATH = 'Arsitektur_MobileNetV2.keras'
MODEL_CAE_PATH = 'satpam_kopi_cae.keras'
SATPAM_IF_PATH = 'Satpam_IsolationForest.pkl'
# 2. DUMMY PREPROCESS (Agar tidak error saat load Lambda layer)
def preprocess_input(x):
return x
print("⏳ Memuat seluruh infrastruktur model...")
full_model = load_model(MODEL_CNN_PATH, compile=False)
satpam_cae = load_model(MODEL_CAE_PATH, compile=False)
iso_forest = joblib.load(SATPAM_IF_PATH)
print("⏳ Sedang memuat 'Otak AI'...")
# Buat Feature Extractor untuk Satpam IF
feature_extractor = Sequential([
full_model.layers[0],
full_model.layers[1]
])
feature_extractor.build((None, 224, 224, 3))
try:
# Menggunakan parameter Keras 3 untuk memuat model lama
model = tf.keras.models.load_model(
MODEL_PATH,
custom_objects={'preprocess_input': preprocess_input},
compile=False,
safe_mode=False # Kunci agar Keras 3 mau menerima config Keras lama
)
print("BERHASIL: Model AI readyy!")
except Exception as e:
print(f"GAGAL: {str(e)}")
classes = ['honey', 'natural', 'wash']
THRESHOLD_GOSONG = 51
THRESHOLD_MSE = 0.0021
# Label klasifikasi kopi kamu
labels = ['Honey', 'Natural', 'Washed']
print("✅ Sistem Keamanan Berlapis Berhasil Diaktifkan.")
# ==============================================================================
# 2. PIPELINE PENYARINGAN BERLAPIS
# ==============================================================================
def proses_gambar_strict(input_img):
# A. Rembg & Crop
output_rgba = remove(input_img)
bbox = output_rgba.getbbox()
if bbox: output_rgba = output_rgba.crop(bbox)
# --------------------------------------------------------------------------
# LAPIS 1: Filter Kecerahan
# --------------------------------------------------------------------------
img_rgba_np = np.array(output_rgba)
mask_biji = img_rgba_np[:, :, 3] > 0
kecerahan = np.median(img_rgba_np[mask_biji][:, :3]) if np.any(mask_biji) else 0
if kecerahan < THRESHOLD_GOSONG:
return None, "DITOLAK_GELAP", f"Kecerahan terlalu rendah ({kecerahan:.1f})"
# Siapkan Kanvas Hitam Dasar
max_dim = max(output_rgba.size)
black_bg = Image.new("RGB", (max_dim, max_dim), (0, 0, 0))
black_bg.paste(output_rgba, ((max_dim - output_rgba.size[0]) // 2, (max_dim - output_rgba.size[1]) // 2), mask=output_rgba.split()[3])
# --------------------------------------------------------------------------
# LAPIS 2: Satpam Bentuk (CAE 64x64) - Menyaring Geometri Kasar
# --------------------------------------------------------------------------
img_cae = black_bg.resize((64, 64))
img_arr_cae = img_to_array(img_cae) / 255.0
img_arr_cae = np.expand_dims(img_arr_cae, axis=0)
rekonstruksi = satpam_cae.predict(img_arr_cae, verbose=0)
mse_score = np.mean(np.square(img_arr_cae - rekonstruksi))
if mse_score > THRESHOLD_MSE:
return None, "DITOLAK_CAE", f"Struktur bentuk tidak sesuai standar (MSE: {mse_score:.5f})"
# --------------------------------------------------------------------------
# LAPIS 3: Satpam Semantik (Isolation Forest 224x224) - Menyaring Detail Fitur
# --------------------------------------------------------------------------
final_img = black_bg.resize((224, 224))
img_array = img_to_array(final_img)
img_array = np.expand_dims(img_array, axis=0)
img_array = preprocess_input(img_array)
fitur = feature_extractor.predict(img_array, verbose=0)
keputusan_if = iso_forest.predict(fitur)[0]
if keputusan_if == -1:
return None, "DITOLAK_IF", "Karakteristik objek bukan green bean kopi"
return img_array, "LOLOS", "Semua pos pemeriksaan aman"
# ==============================================================================
# 3. ENDPOINT API
# ==============================================================================
@app.route('/predict', methods=['POST'])
def predict():
if 'image' not in request.files:
return jsonify({"status": "ERROR", "message": "File tidak ditemukan"}), 400
try:
file = request.files['image']
img = Image.open(request.files['image'].stream).convert("RGBA")
processed_img, status_satpam, keterangan = proses_gambar_strict(img)
# 1. Load Gambar & Resize ke 224x224
img = Image.open(file.stream).convert('RGB')
img = img.resize((224, 224))
# Jika salah satu satpam menolak, langsung return kembalian status DITOLAK
if status_satpam != "LOLOS":
return jsonify({
"status": "DITOLAK",
"label": "Tidak terdeteksi",
"pesan": f"Objek ditolak pada tahap {status_satpam.split('_')[1]}. Keterangan: {keterangan}."
}), 200
# Jika lolos semua satpam, panggil pakar klasifikasi utama
preds = full_model.predict(processed_img, verbose=0)[0]
confidence = float(np.max(preds))
predicted_class = classes[np.argmax(preds)]
entropy = -np.sum(preds * np.log(preds + 1e-9))
# 2. Konversi ke Array
img_array = np.array(img).astype('float32')
# 3. JURUS SAKTI: Gunakan preprocessing asli EfficientNet
# Ini akan menangani scaling warna agar sama persis dengan saat training
img_array = tf.keras.applications.efficientnet.preprocess_input(img_array)
img_array = np.expand_dims(img_array, axis=0)
# 4. Prediksi
preds = model.predict(img_array, verbose=0)
class_idx = np.argmax(preds[0])
confidence = float(np.max(preds[0]))
print(f"📥 Prediksi: {labels[class_idx]} ({confidence*100:.2f}%)")
if entropy > 0.85 or confidence < 0.70:
return jsonify({"status": "DITOLAK", "label": "Tidak terdeteksi", "pesan": "Sistem ragu dengan objek ini."}), 200
return jsonify({
'label': labels[class_idx],
'confidence': f"{confidence * 100:.2f}%",
'status': 'success'
})
"status": "BERHASIL",
"label": predicted_class,
"confidence": str(round(confidence * 100, 2)),
"pesan": f"Biji kopi proses {predicted_class.upper()} terdeteksi.",
"details": {cls: round(float(p) * 100, 2) for cls, p in zip(classes, preds)}
}), 200
except Exception as e:
print(f"❌ ERROR PREDIKSI: {str(e)}")
return jsonify({'error': str(e)}), 500
return jsonify({"status": "ERROR", "message": str(e)}), 500
if __name__ == '__main__':
# Jalankan di port 5001 agar tidak diblokir Windows AirPlay
app.run(host='127.0.0.1', port=5001, debug=True)
app.run(host='0.0.0.0', port=5001, debug=False)

Binary file not shown.

View File

@ -2,6 +2,7 @@
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\RoleMiddleware;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@ -9,13 +10,17 @@
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
web: __DIR__ . '/../routes/web.php',
commands: __DIR__ . '/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->alias([
'role' => RoleMiddleware::class,
]);
$middleware->web(append: [
HandleAppearance::class,
HandleInertiaRequests::class,

View File

@ -17,6 +17,9 @@ public function up(): void
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('role')->default('user');
$table->rememberToken();
$table->timestamps();
});

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('classifications', function (Blueprint $table) {
$table->id();
// user_id boleh kosong (untuk guest)
$table->foreignId('user_id')->nullable()->constrained()->onDelete('cascade');
$table->string('image_path');
$table->string('result');
$table->float('confidence');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('classifications');
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('classifications', function (Blueprint $table) {
$table->string('status')->after('confidence')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('classifications', function (Blueprint $table) {
$table->dropColumn('status');
});
}
};

View File

@ -5,6 +5,7 @@
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder
{
@ -16,8 +17,10 @@ public function run(): void
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
'name' => 'Admin',
'email' => 'admin@example.com',
'password' => Hash::make('password'),
'role' => 'admin',
]);
}
}

31
package-lock.json generated
View File

@ -9,6 +9,7 @@
"@inertiajs/vue3": "^3.0.0",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^12.8.2",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"laravel-vite-plugin": "^3.0.0",
@ -18,6 +19,7 @@
"tailwindcss": "^4.1.1",
"tw-animate-css": "^1.2.5",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.3",
"vue-input-otp": "^0.3.2",
"vue-sonner": "^2.0.0",
"ziggy-js": "^2.6.2"
@ -575,6 +577,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@ -2505,6 +2513,19 @@
"node": ">=8"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@ -6625,6 +6646,16 @@
}
}
},
"node_modules/vue-chartjs": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-eslint-parser": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz",

View File

@ -37,6 +37,7 @@
"@inertiajs/vue3": "^3.0.0",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^12.8.2",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"laravel-vite-plugin": "^3.0.0",
@ -46,6 +47,7 @@
"tailwindcss": "^4.1.1",
"tw-animate-css": "^1.2.5",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.3",
"vue-input-otp": "^0.3.2",
"vue-sonner": "^2.0.0",
"ziggy-js": "^2.6.2"

BIN
public/pict_example.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

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

@ -4,13 +4,13 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
<template>
<div
class="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground"
class="flex aspect-square size-8 items-center justify-center rounded-md bg-green-600"
>
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
<AppLogoIcon class="size-5 text-white" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate leading-tight font-semibold"
>Laravel Starter Kit</span
>GREENS</span
>
</div>
</template>

View File

@ -1,5 +1,7 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { Bean } from 'lucide-vue-next';
defineOptions({
inheritAttrs: false,
@ -13,17 +15,8 @@ defineProps<Props>();
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 40 42"
:class="className"
v-bind="$attrs"
>
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
/>
</svg>
<Bean
class="w-5 h-5 text-white"
:stroke-width="2.8"
/>
</template>

View File

@ -1,11 +1,13 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
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';
import NavUser from '@/components/NavUser.vue';
import {
Sidebar,
SidebarContent,
@ -17,31 +19,61 @@ import {
} from '@/components/ui/sidebar';
import type { NavItem } from '@/types';
const mainNavItems: NavItem[] = [
const page = usePage();
// Ambil data user dari auth yang dikirim via HandleInertiaRequests
const user = computed(() => page.props.auth.user);
const adminNavItems: NavItem[] = [
{
title: 'Dashboard',
href: route('admin.dashboard'),
icon: LayoutGrid,
},
{
title: 'Users',
title: 'Users Management',
href: route('admin.users.index'),
icon: Users,
},
{
title: 'History Klasifikasi',
href: route('admin.classifications.index'),
icon: ScanSearch,
},
];
const footerNavItems: NavItem[] = [
const userNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit',
icon: FolderGit2,
title: 'Dashboard',
href: route('dashboard'),
icon: LayoutGrid,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen,
title: 'Riwayat 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;
// });
// });
</script>
<template>
@ -59,13 +91,12 @@ const footerNavItems: NavItem[] = [
</SidebarHeader>
<SidebarContent>
<NavMain :items="mainNavItems" />
<NavMain :items="navItems" />
</SidebarContent>
<SidebarFooter>
<NavFooter :items="footerNavItems" />
<NavUser />
</SidebarFooter>
</Sidebar>
<slot />
</template>
</template>

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

@ -33,6 +33,7 @@
<!-- CTAs -->
<div class="flex flex-col sm:flex-row items-center justify-center gap-4 mb-16">
<button
@click="scrollToScanner"
class="shimmer-btn bg-white text-zinc-950 hover:bg-zinc-200 rounded-full px-8 py-3 text-base font-medium shadow-lg shadow-white/10 flex items-center gap-2"
>
Mulai Klasifikasi
@ -47,17 +48,18 @@
<!-- Social Proof -->
<div class="flex flex-col items-center gap-4">
<div class="flex items-center -space-x-3">
<!-- <div class="flex items-center -space-x-3">
<img
v-for="(avatar, index) in avatars"
:key="index"
:src="avatar"
:alt="`Avatar ${index + 1}`"
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">
Telah digunakan oleh <span class="text-zinc-300 font-medium">2,000+</span> orang
Telah digunakan <span class="text-zinc-300 font-medium">{{ totalClassifications ? totalClassifications.toLocaleString('id-ID') : '0' }}</span> kali
</p>
</div>
</div>
@ -67,6 +69,14 @@
<script setup lang="ts">
import { ArrowRight } from 'lucide-vue-next'
defineProps<{
totalClassifications?: number
}>()
const scrollToScanner = () => {
document.getElementById('klasifikasi')?.scrollIntoView({ behavior: 'smooth' })
}
const avatars = [
'/professional-headshot-1.png',
'/professional-headshot-2.png',

View File

@ -1,5 +1,5 @@
<template>
<section id="features" class="py-24 px-4">
<section id="keunggulan" class="py-24 px-4">
<div class="max-w-6xl mx-auto">
<div class="text-center mb-16">
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4 font-display">Keunggulan Sistem Kami</h2>

View File

@ -31,18 +31,37 @@
<!-- CTA Buttons -->
<div class="hidden md:flex items-center gap-3">
<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 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"
>
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>
<!-- Mobile Menu 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

@ -21,13 +21,23 @@
Unggah foto biji kopi Arabika Anda. Pastikan gambar jelas dan mendapatkan pencahayaan yang cukup untuk hasil terbaik.
</p>
<div class="flex items-center gap-3">
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
<div :class="['w-2 h-2 rounded-full bg-emerald-500', isLoading ? 'animate-ping' : 'animate-pulse']"></div>
<span class="text-[10px] font-bold text-emerald-500 uppercase tracking-wider">
{{ isLoading ? 'Processing...' : 'AI Model Ready' }}
</span>
</div>
<button
@click="showGuideModal = true"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-zinc-800/80 border border-zinc-700 hover:border-emerald-500/50 hover:bg-zinc-800 transition-all group"
>
<Info class="w-3.5 h-3.5 text-zinc-400 group-hover:text-emerald-400 transition-colors" />
<span class="text-[10px] font-bold text-zinc-400 group-hover:text-emerald-400 uppercase tracking-wider transition-colors">
Panduan Foto
</span>
</button>
</div>
</div>
@ -53,7 +63,7 @@
</template>
<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]">
<span class="bg-black/60 text-white px-4 py-2 rounded-full text-sm font-medium">Klik untuk mengganti</span>
</div>
@ -79,12 +89,47 @@
</div>
<transition enter-active-class="transition duration-500 ease-out" enter-from-class="transform scale-95 opacity-0" enter-to-class="transform scale-100 opacity-100">
<div v-if="predictionResult" class="mt-8 p-8 rounded-3xl bg-emerald-600 shadow-[0_0_50px_-12px_rgba(16,185,129,0.5)] text-center relative overflow-hidden">
<div
v-if="predictionResult"
class="mt-8 p-8 rounded-3xl text-center relative overflow-hidden transition-colors duration-500 shadow-lg"
:class="statusInfo?.cardClass"
>
<div class="relative z-10">
<p class="text-xs font-black text-emerald-100 uppercase tracking-[0.3em] mb-2">Hasil Klasifikasi AI</p>
<h4 class="text-5xl font-black text-white italic mb-2 tracking-tighter">{{ predictionResult.label }}</h4>
<div class="inline-block px-4 py-1.5 bg-black/20 rounded-full backdrop-blur-md">
<span class="text-emerald-500 font-bold text-sm uppercase tracking-widest">Confidence: {{ predictionResult.confidence }}</span>
<div :class="predictionResult.status === 'DITOLAK' ? 'flex flex-col md:flex-row items-center justify-center gap-8' : ''">
<!-- KIRI: CONTOH FOTO JIKA DITOLAK -->
<div v-if="predictionResult.status === 'DITOLAK'" class="flex-1 w-full max-w-sm mx-auto bg-black/20 p-6 rounded-2xl border border-white/10 text-left">
<p class="text-sm text-white font-bold mb-3 text-center">Contoh Foto yang Benar:</p>
<img src="/pict_example.jpg" alt="Contoh Foto Biji Kopi" class="w-full rounded-xl shadow-sm border border-white/20" loading="lazy" />
</div>
<!-- KANAN / TENGAH: HASIL PREDIKSI -->
<div class="flex-1 w-full max-w-sm mx-auto flex flex-col justify-center">
<p class="text-xs font-black text-white/70 uppercase tracking-[0.3em] mb-2">Hasil Klasifikasi AI</p>
<h4 class="text-5xl font-black text-white mb-4 tracking-tighter">{{ predictionResult.label }}</h4>
<div class="flex flex-wrap justify-center gap-3">
<div class="px-4 py-1.5 bg-black/20 rounded-full backdrop-blur-md border border-white/10">
<span class="text-white font-bold text-xs uppercase tracking-widest">Confidence: {{ predictionResult.confidence }}</span>
</div>
<div
class="px-4 py-1.5 rounded-full backdrop-blur-md border flex items-center gap-2"
:class="statusInfo?.badgeClass"
>
<div :class="['w-1.5 h-1.5 rounded-full', statusInfo?.dotClass]"></div>
<span class="font-bold text-xs uppercase tracking-widest">
{{ statusInfo?.text }}
</span>
</div>
</div>
<!-- Menampilkan Probabilitas -->
<div v-if="predictionResult.details" class="mt-6 text-sm text-white/80 whitespace-pre-line text-left bg-black/20 p-4 rounded-xl border border-white/10 w-full">
{{ predictionResult.details }}
</div>
</div>
</div>
</div>
<Bean class="absolute -right-10 -bottom-10 w-40 h-40 text-white/10 rotate-12" />
@ -93,17 +138,115 @@
</div>
</div>
<!-- Modal Panduan -->
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="showGuideModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm" @click="showGuideModal = false">
<div class="bg-zinc-900 border border-zinc-800 rounded-3xl max-w-3xl w-full p-6 sm:p-8 relative overflow-hidden shadow-2xl" @click.stop>
<button @click="showGuideModal = false" class="absolute top-4 right-4 text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 p-2 rounded-full transition-colors z-10">
<X class="w-5 h-5" />
</button>
<div class="flex items-center gap-3 mb-8 pr-8">
<div class="p-2.5 rounded-xl bg-emerald-500/10">
<Info class="w-6 h-6 text-emerald-500" />
</div>
<h3 class="text-2xl font-bold text-white font-display">Panduan Pengambilan Foto</h3>
</div>
<div class="grid md:grid-cols-2 gap-8 items-center">
<div class="space-y-4">
<div class="bg-black/50 rounded-2xl overflow-hidden border border-zinc-800 relative group">
<img src="/pict_example.jpg" alt="Contoh Foto Benar" class="w-full h-auto aspect-video object-cover" />
<div class="absolute inset-0 border-2 border-emerald-500/0 group-hover:border-emerald-500/50 rounded-2xl transition-colors pointer-events-none"></div>
</div>
<div class="flex items-center gap-2 text-emerald-400 text-sm font-medium">
<div class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></div>
Contoh Foto yang Benar
</div>
</div>
<div class="flex flex-col justify-center space-y-6">
<div>
<h4 class="text-lg font-bold text-white mb-3 flex items-center gap-2">
<span class="text-emerald-500">Tips</span> agar hasil akurat:
</h4>
<ul class="space-y-3">
<li class="flex items-start gap-3">
<div class="min-w-[24px] h-6 rounded-full bg-emerald-500/10 text-emerald-500 flex items-center justify-center text-xs font-bold mt-0.5 border border-emerald-500/20">1</div>
<p class="text-zinc-300 text-sm leading-relaxed">Pastikan objek biji kopi <strong>fokus</strong> dan tidak buram (blur).</p>
</li>
<li class="flex items-start gap-3">
<div class="min-w-[24px] h-6 rounded-full bg-emerald-500/10 text-emerald-500 flex items-center justify-center text-xs font-bold mt-0.5 border border-emerald-500/20">2</div>
<p class="text-zinc-300 text-sm leading-relaxed">Gunakan <strong>pencahayaan yang cukup</strong> (cahaya matahari/lampu terang) agar tekstur dan warna jelas.</p>
</li>
<li class="flex items-start gap-3">
<div class="min-w-[24px] h-6 rounded-full bg-emerald-500/10 text-emerald-500 flex items-center justify-center text-xs font-bold mt-0.5 border border-emerald-500/20">3</div>
<p class="text-zinc-300 text-sm leading-relaxed">Hindari <strong>bayangan yang terlalu gelap</strong> atau pantulan cahaya yang menyilaukan.</p>
</li>
<li class="flex items-start gap-3">
<div class="min-w-[24px] h-6 rounded-full bg-emerald-500/10 text-emerald-500 flex items-center justify-center text-xs font-bold mt-0.5 border border-emerald-500/20">4</div>
<p class="text-zinc-300 text-sm leading-relaxed">Ambil foto dari <strong>jarak yang pas</strong>. Biji kopi harus terlihat jelas secara keseluruhan.</p>
</li>
</ul>
</div>
</div>
</div>
<div class="mt-8 pt-6 border-t border-zinc-800 flex justify-end">
<button @click="showGuideModal = false" class="px-6 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-xl transition-colors">
Mengerti
</button>
</div>
</div>
</div>
</transition>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Upload, Plus, X, Loader2, Bean } from 'lucide-vue-next'
import { ref, computed } from 'vue'
import { router } from '@inertiajs/vue3'
import { route } from 'ziggy-js'
import { Upload, Plus, X, Loader2, Bean, Info } from 'lucide-vue-next'
const imagePreview = ref<string | null>(null)
const selectedFile = ref<File | null>(null)
const predictionResult = ref<{ label: string, confidence: string } | null>(null)
const predictionResult = ref<{ label: string, confidence: string, details?: string, status?: string } | null>(null)
const isLoading = ref(false)
const showGuideModal = ref(false)
const statusInfo = computed(() => {
if (!predictionResult.value) return null
// Membaca status langsung dari Backend!
const statusAI = predictionResult.value.status
if (statusAI === 'BERHASIL') {
return {
text: 'Berhasil',
cardClass: 'bg-emerald-600 shadow-[0_0_50px_-12px_rgba(16,185,129,0.5)]',
badgeClass: 'bg-emerald-500/20 border-emerald-500/50 text-emerald-200',
dotClass: 'bg-emerald-400'
}
} else {
// Berlaku untuk 'DITOLAK'
return {
text: 'Ditolak',
cardClass: 'bg-red-600 shadow-[0_0_50px_-12px_rgba(220,38,38,0.5)]',
badgeClass: 'bg-red-500/20 border-red-500/50 text-red-200',
dotClass: 'bg-red-400'
}
}
})
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
@ -128,27 +271,45 @@ 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
confidence: data.confidence + '%', // Tambah % biar rapi di UI
details: data.message,
status: data.status // Simpan status dari Laravel/Flask
}
} 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)
predictionResult.value = {
label: 'Gagal',
confidence: '0%',
details: error.message || 'Terjadi kesalahan saat klasifikasi',
status: 'DITOLAK' // Beri status DITOLAK agar UI langsung memerah
}
} finally {
isLoading.value = false
}
}
const clearImage = () => {
imagePreview.value = null
selectedFile.value = null

View File

@ -10,6 +10,7 @@ const props = defineProps<AvatarImageProps>()
data-slot="avatar-image"
v-bind="props"
class="aspect-square size-full"
loading="lazy"
>
<slot />
</AvatarImage>

View File

@ -2,10 +2,16 @@
import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
import Notification from './Notification.vue';
import type { BreadcrumbItem } from '@/types';
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
const { breadcrumbs = [] } = defineProps<{
breadcrumbs?: BreadcrumbItem[];
}>();
// Mengambil data user untuk keperluan pengecekan role jika dibutuhkan di level ini
const page = usePage();
const user = computed(() => page.props.auth.user);
</script>
<template>
@ -13,4 +19,4 @@ const { breadcrumbs = [] } = defineProps<{
<slot />
<Notification />
</AppLayout>
</template>
</template>

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(() => {
isOpen.value = false
setTimeout(() => {
message.value = null
type.value = null
}, 300)
}, 2500)
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)
}
}
// 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>

View File

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

View File

@ -1,10 +1,15 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
import { Card } from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { User } from '@/types';
import { computed } from 'vue';
// Import untuk Chart
import { Pie } from 'vue-chartjs';
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js';
ChartJS.register(ArcElement, Tooltip, Legend);
defineOptions({
layout: {
@ -17,29 +22,160 @@ defineOptions({
},
});
const { user } = defineProps<{ user: User }>();
// Terima Props dari Controller
const props = defineProps<{
user: User,
chartData: Array<{ result: string, total: number }>,
recentHistory: Array<any>,
totalScan: number,
avgAccuracy: number,
successRate: number,
totalUser: number,
}>();
// Konfigurasi Warna & Data untuk Pie Chart
const chartConfig = computed(() => ({
labels: props.chartData.map(item => item.result),
datasets: [{
backgroundColor: props.chartData.map(item => {
switch (item.result?.toLowerCase()) {
case 'honey': return '#f59e0b'; // Amber-500
case 'wash': return '#10b981'; // Blue-500
case 'natural': return '#713600'; // Emerald-500
default: return '#94a3b8'; // Slate-400
}
}),
data: props.chartData.map(item => item.total),
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 = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom' as const,
}
}
};
</script>
<template>
<Head title="Dashboard" />
<div
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
>
<!-- card greetings -->
<Card class="flex-1 border-border bg-card">
<div class="flex flex-col">
<div class="flex items-center justify-between">
<div>
<h3 class="text-base font-medium text-foreground">
Selamat Datang {{ user.name }}
</h3>
<p class="text-sm text-muted-foreground">
ini adalah dashboard admin
</p>
</div>
</div>
</div>
<div class="flex h-full flex-1 flex-col gap-4 p-4 overflow-y-auto">
<Card class="border-border bg-card">
<CardHeader class="pb-3">
<CardTitle class="text-lg font-semibold text-foreground">
Selamat Datang, {{ user.name }}!
</CardTitle>
<CardDescription>
Anda masuk sebagai <span class="font-bold uppercase">{{ user.role }}</span>. Berikut ringkasan data klasifikasi kopi.
</CardDescription>
</CardHeader>
</Card>
<div class="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">Total Pengguna</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ totalUser }}</div>
<p class="text-xs text-muted-foreground">Akumulasi seluruh pengguna</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">Total Scan</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ totalScan }}</div>
<p class="text-xs text-muted-foreground">Akumulasi seluruh pemindaian</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">Rata-rata Akurasi</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold text-emerald-600">{{ avgAccuracy }}%</div>
<p class="text-xs text-muted-foreground">Dari klasifikasi berstatus berhasil</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">Tingkat Keberhasilan</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold text-blue-600">{{ successRate }}%</div>
<p class="text-xs text-muted-foreground">Persentase status berhasil dari total scan</p>
</CardContent>
</Card>
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card class="col-span-1 lg:col-span-3">
<CardHeader>
<CardTitle>Distribusi Pasca Panen</CardTitle>
<CardDescription>Persentase hasil klasifikasi</CardDescription>
</CardHeader>
<CardContent class="h-[300px]">
<Pie v-if="chartData.length > 0" :data="chartConfig" :options="chartOptions" />
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
Belum ada data untuk ditampilkan
</div>
</CardContent>
</Card>
<Card class="col-span-1 lg:col-span-4">
<CardHeader>
<CardTitle>Riwayat Terakhir</CardTitle>
<CardDescription>10 pemindaian terbaru</CardDescription>
</CardHeader>
<CardContent>
<div class="relative w-full overflow-auto">
<table class="w-full caption-bottom text-sm">
<thead class="[&_tr]:border-b">
<tr class="border-b transition-colors hover:bg-muted/50">
<th class="h-12 px-4 text-left align-middle font-medium">Tanggal</th>
<th class="h-12 px-4 text-left align-middle font-medium">Hasil</th>
<th class="h-12 px-4 text-left align-middle font-medium">Akurasi</th>
</tr>
</thead>
<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">
<td class="p-4 align-middle text-muted-foreground">
{{ new Date(item.created_at).toLocaleDateString('id-ID', { day: '2-digit', month: 'short' }) }}
</td>
<td class="p-4 align-middle font-black italic tracking-tight" :class="getResultColorClass(item.result)">
{{ item.result }}
</td>
<td class="p-4 align-middle font-medium">{{ item.confidence }}%</td>
</tr>
<tr v-if="recentHistory.length === 0">
<td colspan="3" class="p-4 text-center text-muted-foreground">Tidak ada data.</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</div>
</template>
</template>

View File

@ -30,6 +30,22 @@
</p>
</div>
<div class="flex flex-col gap-2">
<Label for="role">Role</Label>
<Select v-model="form.role">
<SelectTrigger class="w-full">
<SelectValue placeholder="Pilih Role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<p v-if="form.errors.role" class="mt-1 text-sm text-red-500">
{{ form.errors.role }}
</p>
</div>
<div class="flex flex-col gap-2">
<Label for="password">Password</Label>
<div class="relative">
@ -89,8 +105,7 @@ import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { MultipleSelect } from '@/components/ui/multiple-select';
import { SelectSearch } from '@/components/ui/select-search';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useForm } from '@inertiajs/vue3';
import { Eye, EyeOff, UserPlus } from 'lucide-vue-next';
import { computed, ref } from 'vue';
@ -102,6 +117,7 @@ const showPasswordConfirmation = ref(false);
const form = useForm({
name: '',
email: '',
role: '',
password: '',
password_confirmation: '',
});

View File

@ -30,6 +30,22 @@
</p>
</div>
<div class="flex flex-col gap-2">
<Label for="role">Role</Label>
<Select v-model="form.role">
<SelectTrigger class="w-full">
<SelectValue placeholder="Pilih Role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<p v-if="form.errors.role" class="mt-1 text-sm text-red-500">
{{ form.errors.role }}
</p>
</div>
<div class="flex flex-col gap-2">
<Label for="password">Password</Label>
<div class="relative">
@ -89,8 +105,7 @@ import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { MultipleSelect } from '@/components/ui/multiple-select';
import { SelectSearch } from '@/components/ui/select-search';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useForm } from '@inertiajs/vue3';
import { Edit, Eye, EyeOff, UserPlus } from 'lucide-vue-next';
import { computed, ref,watch } from 'vue';
@ -105,12 +120,14 @@ const props = defineProps<{
id: number;
name: string;
email: string;
role: string;
};
}>();
const form = useForm({
name: props.user.name || '',
email: props.user.email || '',
role: props.user.role || '',
password: '',
password_confirmation: '',
});
@ -119,6 +136,7 @@ watch(open, (value) => {
if (value) {
form.name = props.user.name || '';
form.email = props.user.email || '';
form.role = props.user.role || '';
form.password = '';
form.password_confirmation = '';
form.clearErrors();

View File

@ -112,6 +112,12 @@ const changePage = (page: number) => {
{{ sortOrder === 'asc' ? '↑' : '↓' }}
</span>
</TableHead>
<TableHead class="cursor-pointer" @click="sortBy('role')">
Role
<span v-if="sortField === 'role'" class="ml-1">
{{ sortOrder === 'asc' ? '↑' : '↓' }}
</span>
</TableHead>
<TableHead class="text-center">Aksi</TableHead>
</TableRow>
</TableHeader>
@ -125,6 +131,11 @@ const changePage = (page: number) => {
<div class="font-medium">{{ user.name }}</div>
</TableCell>
<TableCell>{{ user.email }}</TableCell>
<TableCell>
<div class="font-medium">
{{ user.role === 'admin' ? 'Admin' : 'User' }}
</div>
</TableCell>
<TableCell>
<div class="flex items-center justify-center gap-2">
<UserEdit :user="user" />

View File

@ -1,8 +1,8 @@
<template>
<div class="bg-background text-foreground min-h-screen">
<Navbar />
<Hero />
<Logo />
<Hero :total-classifications="totalClassifications" />
<!-- <Logo /> -->
<Scanner />
<Keunggulan />
<Teknologi />
@ -15,10 +15,14 @@
<script setup lang="ts">
import Navbar from '@/components/public/Navbar.vue'
import Hero from '@/components/public/Hero.vue'
import Logo from '@/components/public/Logo.vue'
// import Logo from '@/components/public/Logo.vue'
import Keunggulan from '@/components/public/Keunggulan.vue'
import Scanner from '@/components/public/Scanner.vue'
import Teknologi from '@/components/public/Teknologi.vue'
import FAQ from '@/components/public/FAQ.vue'
import FinalCta from '@/components/public/FinalCta.vue'
defineProps<{
totalClassifications?: number
}>()
</script>

View File

@ -0,0 +1,241 @@
<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'] });
}
};
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>
<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"
loading="lazy"
/>
</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 italic truncate leading-none" :class="getResultColorText(item.result)">{{ item.result }}</h3>
<div
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))
? 'bg-emerald-50 text-emerald-600 border-emerald-200 dark:bg-emerald-950/30 dark:text-emerald-400 dark:border-emerald-800'
: 'bg-red-50 text-red-600 border-red-200 dark:bg-red-950/30 dark:text-red-400 dark:border-red-800'"
>
<div :class="['w-1.5 h-1.5 rounded-full mr-1.5', (item.status === 'Berhasil' || (!item.status && Number(item.confidence) >= 90)) ? 'bg-emerald-500' : 'bg-red-500']"></div>
{{ item.status || (Number(item.confidence) >= 90 ? 'Berhasil' : 'Gagal') }}
</div>
</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 rounded-full transition-all duration-1000"
:class="getResultColorBar(item.result)"
: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

@ -4,18 +4,37 @@
use Laravel\Fortify\Features;
use App\Http\Controllers\Admin\UserController;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Public\ClassificationController;
use App\Http\Controllers\Admin\ClassificationController as AdminClassificationController;
use App\Models\Classification;
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', [
'canRegister' => Features::enabled(Features::registration()),
'totalClassifications' => Classification::count(),
]);
})->name('home');
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');
Route::resource('users', UserController::class)->names('users');
Route::get('classifications', [AdminClassificationController::class, 'index'])->name('classifications.index');
Route::delete('classifications/{classification}', [AdminClassificationController::class, 'destroy'])->name('classifications.destroy');
});