Compare commits
10 Commits
45d641519c
...
af383d9094
| Author | SHA1 | Date |
|---|---|---|
|
|
af383d9094 | |
|
|
2e0400c900 | |
|
|
7c969b72de | |
|
|
86c4fb3189 | |
|
|
a00caaa24b | |
|
|
d444dd0672 | |
|
|
44e69d13c5 | |
|
|
e639b0ac57 | |
|
|
fa3902fcea | |
|
|
e38215b1b4 |
|
|
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,14 +5,53 @@
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
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
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$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', [
|
return inertia('admin/Dashboard', [
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
|
'chartData' => $chartData,
|
||||||
|
'recentHistory' => $recentHistory,
|
||||||
|
'totalScan' => $totalScan,
|
||||||
|
'avgAccuracy' => round($avgAccuracy, 2),
|
||||||
|
'successRate' => round($successRate, 2),
|
||||||
|
'totalUser' => $totalUser,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -41,6 +41,7 @@ public function store(Request $request)
|
||||||
$validatedData = $request->validate([
|
$validatedData = $request->validate([
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'email' => 'required|string|email|max:255|unique:users',
|
'email' => 'required|string|email|max:255|unique:users',
|
||||||
|
'role' => 'required|in:user,admin',
|
||||||
'password' => 'required|string|min:8|confirmed',
|
'password' => 'required|string|min:8|confirmed',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -49,6 +50,7 @@ public function store(Request $request)
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'name' => $validatedData['name'],
|
'name' => $validatedData['name'],
|
||||||
'email' => $validatedData['email'],
|
'email' => $validatedData['email'],
|
||||||
|
'role' => $validatedData['role'],
|
||||||
'password' => Hash::make($validatedData['password']),
|
'password' => Hash::make($validatedData['password']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -90,6 +92,7 @@ public function update(Request $request, string $id)
|
||||||
$validatedData = $request->validate([
|
$validatedData = $request->validate([
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'email' => 'required|string|email|max:255|unique:users,email,' . $id,
|
'email' => 'required|string|email|max:255|unique:users,email,' . $id,
|
||||||
|
'role' => 'required|in:user,admin',
|
||||||
'password' => 'nullable|string|min:8|confirmed',
|
'password' => 'nullable|string|min:8|confirmed',
|
||||||
]);
|
]);
|
||||||
$password = $validatedData['password'];
|
$password = $validatedData['password'];
|
||||||
|
|
@ -102,6 +105,7 @@ public function update(Request $request, string $id)
|
||||||
$update = [
|
$update = [
|
||||||
'name' => $validatedData['name'],
|
'name' => $validatedData['name'],
|
||||||
'email' => $validatedData['email'],
|
'email' => $validatedData['email'],
|
||||||
|
'role' => $validatedData['role'],
|
||||||
];
|
];
|
||||||
if ($password) {
|
if ($password) {
|
||||||
$update['password'] = $password;
|
$update['password'] = $password;
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,12 +39,18 @@ public function share(Request $request): array
|
||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
'name' => config('app.name'),
|
'name' => config('app.name'),
|
||||||
'auth' => [
|
'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' => [
|
'flash' => [
|
||||||
'success' => fn () => $request->session()->get('success'),
|
'success' => fn() => $request->session()->get('success'),
|
||||||
'error' => fn () => $request->session()->get('error'),
|
'error' => fn() => $request->session()->get('error'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'password'])]
|
#[Fillable(['name', 'email', 'password', 'role'])]
|
||||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,72 +1,131 @@
|
||||||
import os
|
import os
|
||||||
import io
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import tensorflow as tf
|
import joblib
|
||||||
from flask import Flask, request, jsonify
|
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 PIL import Image
|
||||||
|
from rembg import remove
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
||||||
# 1. SETUP PATH MODEL
|
# ==============================================================================
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
# 1. LOAD SEMUA MODEL (KLASIFIKATOR + SATPAM 1 & 2)
|
||||||
MODEL_PATH = os.path.join(BASE_DIR, 'model_efficientNet.keras')
|
# ==============================================================================
|
||||||
|
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)
|
print("⏳ Memuat seluruh infrastruktur model...")
|
||||||
def preprocess_input(x):
|
full_model = load_model(MODEL_CNN_PATH, compile=False)
|
||||||
return x
|
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:
|
classes = ['honey', 'natural', 'wash']
|
||||||
# Menggunakan parameter Keras 3 untuk memuat model lama
|
THRESHOLD_GOSONG = 51
|
||||||
model = tf.keras.models.load_model(
|
THRESHOLD_MSE = 0.0021
|
||||||
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)}")
|
|
||||||
|
|
||||||
# Label klasifikasi kopi kamu
|
print("✅ Sistem Keamanan Berlapis Berhasil Diaktifkan.")
|
||||||
labels = ['Honey', 'Natural', 'Washed']
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 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'])
|
@app.route('/predict', methods=['POST'])
|
||||||
def predict():
|
def predict():
|
||||||
|
if 'image' not in request.files:
|
||||||
|
return jsonify({"status": "ERROR", "message": "File tidak ditemukan"}), 400
|
||||||
|
|
||||||
try:
|
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
|
# Jika salah satu satpam menolak, langsung return kembalian status DITOLAK
|
||||||
img = Image.open(file.stream).convert('RGB')
|
if status_satpam != "LOLOS":
|
||||||
img = img.resize((224, 224))
|
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
|
if entropy > 0.85 or confidence < 0.70:
|
||||||
img_array = np.array(img).astype('float32')
|
return jsonify({"status": "DITOLAK", "label": "Tidak terdeteksi", "pesan": "Sistem ragu dengan objek ini."}), 200
|
||||||
|
|
||||||
# 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}%)")
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'label': labels[class_idx],
|
"status": "BERHASIL",
|
||||||
'confidence': f"{confidence * 100:.2f}%",
|
"label": predicted_class,
|
||||||
'status': 'success'
|
"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:
|
except Exception as e:
|
||||||
print(f"❌ ERROR PREDIKSI: {str(e)}")
|
return jsonify({"status": "ERROR", "message": str(e)}), 500
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
# Jalankan di port 5001 agar tidak diblokir Windows AirPlay
|
app.run(host='0.0.0.0', port=5001, debug=False)
|
||||||
app.run(host='127.0.0.1', port=5001, debug=True)
|
|
||||||
Binary file not shown.
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use App\Http\Middleware\HandleAppearance;
|
use App\Http\Middleware\HandleAppearance;
|
||||||
use App\Http\Middleware\HandleInertiaRequests;
|
use App\Http\Middleware\HandleInertiaRequests;
|
||||||
|
use App\Http\Middleware\RoleMiddleware;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
|
@ -9,13 +10,17 @@
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__ . '/../routes/web.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__ . '/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||||
|
|
||||||
|
$middleware->alias([
|
||||||
|
'role' => RoleMiddleware::class,
|
||||||
|
]);
|
||||||
|
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
HandleAppearance::class,
|
HandleAppearance::class,
|
||||||
HandleInertiaRequests::class,
|
HandleInertiaRequests::class,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ public function up(): void
|
||||||
$table->string('email')->unique();
|
$table->string('email')->unique();
|
||||||
$table->timestamp('email_verified_at')->nullable();
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
$table->string('password');
|
$table->string('password');
|
||||||
|
|
||||||
|
$table->string('role')->default('user');
|
||||||
|
|
||||||
$table->rememberToken();
|
$table->rememberToken();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
class DatabaseSeeder extends Seeder
|
class DatabaseSeeder extends Seeder
|
||||||
{
|
{
|
||||||
|
|
@ -16,8 +17,10 @@ public function run(): void
|
||||||
// User::factory(10)->create();
|
// User::factory(10)->create();
|
||||||
|
|
||||||
User::factory()->create([
|
User::factory()->create([
|
||||||
'name' => 'Test User',
|
'name' => 'Admin',
|
||||||
'email' => 'test@example.com',
|
'email' => 'admin@example.com',
|
||||||
|
'password' => Hash::make('password'),
|
||||||
|
'role' => 'admin',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"@inertiajs/vue3": "^3.0.0",
|
"@inertiajs/vue3": "^3.0.0",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@vueuse/core": "^12.8.2",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"laravel-vite-plugin": "^3.0.0",
|
"laravel-vite-plugin": "^3.0.0",
|
||||||
|
|
@ -18,6 +19,7 @@
|
||||||
"tailwindcss": "^4.1.1",
|
"tailwindcss": "^4.1.1",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-input-otp": "^0.3.2",
|
"vue-input-otp": "^0.3.2",
|
||||||
"vue-sonner": "^2.0.0",
|
"vue-sonner": "^2.0.0",
|
||||||
"ziggy-js": "^2.6.2"
|
"ziggy-js": "^2.6.2"
|
||||||
|
|
@ -575,6 +577,12 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
|
|
@ -2505,6 +2513,19 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/class-variance-authority": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
"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": {
|
"node_modules/vue-eslint-parser": {
|
||||||
"version": "10.4.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
"@inertiajs/vue3": "^3.0.0",
|
"@inertiajs/vue3": "^3.0.0",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@vueuse/core": "^12.8.2",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"laravel-vite-plugin": "^3.0.0",
|
"laravel-vite-plugin": "^3.0.0",
|
||||||
|
|
@ -46,6 +47,7 @@
|
||||||
"tailwindcss": "^4.1.1",
|
"tailwindcss": "^4.1.1",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-input-otp": "^0.3.2",
|
"vue-input-otp": "^0.3.2",
|
||||||
"vue-sonner": "^2.0.0",
|
"vue-sonner": "^2.0.0",
|
||||||
"ziggy-js": "^2.6.2"
|
"ziggy-js": "^2.6.2"
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 573 KiB |
|
|
@ -20,6 +20,8 @@ createInertiaApp({
|
||||||
return [AppLayout, SettingsLayout];
|
return [AppLayout, SettingsLayout];
|
||||||
case name.startsWith('admin/'):
|
case name.startsWith('admin/'):
|
||||||
return [AppLayout];
|
return [AppLayout];
|
||||||
|
case name.startsWith('user/'):
|
||||||
|
return [AppLayout];
|
||||||
default:
|
default:
|
||||||
return PublicLayout;
|
return PublicLayout;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<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>
|
||||||
<div class="ml-1 grid flex-1 text-left text-sm">
|
<div class="ml-1 grid flex-1 text-left text-sm">
|
||||||
<span class="mb-0.5 truncate leading-tight font-semibold"
|
<span class="mb-0.5 truncate leading-tight font-semibold"
|
||||||
>Laravel Starter Kit</span
|
>GREENS</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from 'vue';
|
import type { HTMLAttributes } from 'vue';
|
||||||
|
import { Bean } from 'lucide-vue-next';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
|
|
@ -13,17 +15,8 @@ defineProps<Props>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<svg
|
<Bean
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
class="w-5 h-5 text-white"
|
||||||
viewBox="0 0 40 42"
|
:stroke-width="2.8"
|
||||||
: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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
<script setup lang="ts">
|
<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 { route } from 'ziggy-js';
|
||||||
import { BookOpen, FolderGit2, LayoutGrid, Users } from 'lucide-vue-next';
|
import { BookOpen, FolderGit2, LayoutGrid, Users, ScanSearch } from 'lucide-vue-next';
|
||||||
import AppLogo from '@/components/AppLogo.vue';
|
import AppLogo from '@/components/AppLogo.vue';
|
||||||
import NavFooter from '@/components/NavFooter.vue';
|
import NavFooter from '@/components/NavFooter.vue';
|
||||||
import NavMain from '@/components/NavMain.vue';
|
import NavMain from '@/components/NavMain.vue';
|
||||||
import NavUser from '@/components/NavUser.vue';
|
import NavUser from '@/components/NavUser.vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
|
|
@ -17,31 +19,61 @@ import {
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import type { NavItem } from '@/types';
|
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',
|
title: 'Dashboard',
|
||||||
href: route('admin.dashboard'),
|
href: route('admin.dashboard'),
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Users',
|
title: 'Users Management',
|
||||||
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 footerNavItems: NavItem[] = [
|
const userNavItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Repository',
|
title: 'Dashboard',
|
||||||
href: 'https://github.com/laravel/vue-starter-kit',
|
href: route('dashboard'),
|
||||||
icon: FolderGit2,
|
icon: LayoutGrid,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Documentation',
|
title: 'Riwayat Klasifikasi',
|
||||||
href: 'https://laravel.com/docs/starter-kits#vue',
|
href: route('classifications.index'),
|
||||||
icon: BookOpen,
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -59,13 +91,12 @@ const footerNavItems: NavItem[] = [
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain :items="mainNavItems" />
|
<NavMain :items="navItems" />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavFooter :items="footerNavItems" />
|
|
||||||
<NavUser />
|
<NavUser />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<slot />
|
<slot />
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -4,11 +4,14 @@
|
||||||
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 tracking-tight font-display">
|
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 tracking-tight font-display">
|
||||||
Pantau Riwayat Klasifikasi
|
Pantau Riwayat Klasifikasi
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-lg sm:text-xl text-zinc-400 mb-10 max-w-2xl mx-auto">
|
<p v-if="!user" class="text-lg sm:text-xl text-zinc-400 mb-10 max-w-2xl mx-auto">
|
||||||
Masuk ke akun Anda sekarang untuk menyimpan hasil dan melihat kembali seluruh riwayat klasifikasi biji kopi yang pernah Anda lakukan.
|
Masuk ke akun Anda sekarang untuk menyimpan hasil dan melihat kembali seluruh riwayat klasifikasi biji kopi yang pernah Anda lakukan.
|
||||||
</p>
|
</p>
|
||||||
|
<p v-else class="text-lg sm:text-xl text-zinc-400 mb-10 max-w-2xl mx-auto">
|
||||||
|
Pantau riwayat klasifikasi biji kopi Anda sekarang.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
<div v-if="!user" class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-8 py-3 text-base font-bold shadow-lg shadow-emerald-500/20 flex items-center gap-2 transition-all active:scale-95"
|
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-8 py-3 text-base font-bold shadow-lg shadow-emerald-500/20 flex items-center gap-2 transition-all active:scale-95"
|
||||||
|
|
@ -24,6 +27,15 @@
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-8 py-3 text-base font-bold shadow-lg shadow-emerald-500/20 flex items-center gap-2 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="mt-8 text-sm text-zinc-500">Gratis sepenuhnya. Jangan biarkan riwayat klasifikasi Anda hilang.</p>
|
<p class="mt-8 text-sm text-zinc-500">Gratis sepenuhnya. Jangan biarkan riwayat klasifikasi Anda hilang.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -31,5 +43,9 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ArrowRight } from 'lucide-vue-next'
|
import { ArrowRight } from 'lucide-vue-next'
|
||||||
import { Link } from '@inertiajs/vue3'
|
import { Link, usePage } from '@inertiajs/vue3'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const page = usePage()
|
||||||
|
const user = computed(() => page.props.auth?.user)
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
<!-- CTAs -->
|
<!-- CTAs -->
|
||||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4 mb-16">
|
<div class="flex flex-col sm:flex-row items-center justify-center gap-4 mb-16">
|
||||||
<button
|
<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"
|
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
|
Mulai Klasifikasi
|
||||||
|
|
@ -47,17 +48,18 @@
|
||||||
|
|
||||||
<!-- Social Proof -->
|
<!-- Social Proof -->
|
||||||
<div class="flex flex-col items-center gap-4">
|
<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
|
<img
|
||||||
v-for="(avatar, index) in avatars"
|
v-for="(avatar, index) in avatars"
|
||||||
:key="index"
|
:key="index"
|
||||||
: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">
|
||||||
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -67,6 +69,14 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ArrowRight } from 'lucide-vue-next'
|
import { ArrowRight } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
totalClassifications?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const scrollToScanner = () => {
|
||||||
|
document.getElementById('klasifikasi')?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
const avatars = [
|
const avatars = [
|
||||||
'/professional-headshot-1.png',
|
'/professional-headshot-1.png',
|
||||||
'/professional-headshot-2.png',
|
'/professional-headshot-2.png',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<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="max-w-6xl mx-auto">
|
||||||
<div class="text-center mb-16">
|
<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>
|
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4 font-display">Keunggulan Sistem Kami</h2>
|
||||||
|
|
|
||||||
|
|
@ -31,18 +31,37 @@
|
||||||
|
|
||||||
<!-- CTA Buttons -->
|
<!-- CTA Buttons -->
|
||||||
<div class="hidden md:flex items-center gap-3">
|
<div class="hidden md:flex items-center gap-3">
|
||||||
<Link
|
<div v-if="user">
|
||||||
href="/login"
|
<Link
|
||||||
class="px-4 py-2 text-sm text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
href="/dashboard"
|
||||||
>
|
class="px-4 py-2 text-sm text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||||
Masuk
|
>
|
||||||
</Link>
|
Dashboard
|
||||||
<Link
|
</Link>
|
||||||
href="/register"
|
<Link
|
||||||
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-4 py-2 text-sm font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
|
class="shimmer-btn bg-red-500 text-zinc-950 hover:bg-red-400 rounded-full px-4 py-2 text-sm font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-95 inline-flex items-center"
|
||||||
>
|
:href="route('logout')"
|
||||||
Daftar
|
method="post"
|
||||||
</Link>
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
<LogOut class="mr-2 h-4 w-4" />
|
||||||
|
Log out
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
class="px-4 py-2 text-sm text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Masuk
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-4 py-2 text-sm font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
Daftar
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Menu Button -->
|
<!-- Mobile Menu Button -->
|
||||||
|
|
@ -88,9 +107,13 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { Menu, X } from 'lucide-vue-next'
|
import { Link, usePage, router } from '@inertiajs/vue3'
|
||||||
import { Link } from '@inertiajs/vue3'
|
import { LogOut, Menu, X } from 'lucide-vue-next'
|
||||||
|
import { route } from 'ziggy-js';
|
||||||
|
|
||||||
|
const page = usePage()
|
||||||
|
const user = computed(() => page.props.auth?.user)
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: 'Klasifikasi', href: '#klasifikasi' },
|
{ label: 'Klasifikasi', href: '#klasifikasi' },
|
||||||
|
|
@ -99,6 +122,10 @@ const navItems = [
|
||||||
{ label: 'F.A.Q', href: '#faq' },
|
{ label: 'F.A.Q', href: '#faq' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
router.flushAll();
|
||||||
|
};
|
||||||
|
|
||||||
const hoveredIndex = ref<number | null>(null)
|
const hoveredIndex = ref<number | null>(null)
|
||||||
const mobileMenuOpen = ref(false)
|
const mobileMenuOpen = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,23 @@
|
||||||
Unggah foto biji kopi Arabika Anda. Pastikan gambar jelas dan mendapatkan pencahayaan yang cukup untuk hasil terbaik.
|
Unggah foto biji kopi Arabika Anda. Pastikan gambar jelas dan mendapatkan pencahayaan yang cukup untuk hasil terbaik.
|
||||||
</p>
|
</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="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>
|
<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">
|
<span class="text-[10px] font-bold text-emerald-500 uppercase tracking-wider">
|
||||||
{{ isLoading ? 'Processing...' : 'AI Model Ready' }}
|
{{ isLoading ? 'Processing...' : 'AI Model Ready' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -53,7 +63,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>
|
||||||
|
|
@ -79,12 +89,47 @@
|
||||||
</div>
|
</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">
|
<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">
|
<div class="relative z-10">
|
||||||
<p class="text-xs font-black text-emerald-100 uppercase tracking-[0.3em] mb-2">Hasil Klasifikasi AI</p>
|
<div :class="predictionResult.status === 'DITOLAK' ? 'flex flex-col md:flex-row items-center justify-center gap-8' : ''">
|
||||||
<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">
|
<!-- KIRI: CONTOH FOTO JIKA DITOLAK -->
|
||||||
<span class="text-emerald-500 font-bold text-sm uppercase tracking-widest">Confidence: {{ predictionResult.confidence }}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Bean class="absolute -right-10 -bottom-10 w-40 h-40 text-white/10 rotate-12" />
|
<Bean class="absolute -right-10 -bottom-10 w-40 h-40 text-white/10 rotate-12" />
|
||||||
|
|
@ -93,17 +138,115 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { Upload, Plus, X, Loader2, Bean } from 'lucide-vue-next'
|
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 imagePreview = ref<string | null>(null)
|
||||||
const selectedFile = ref<File | 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 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 handleFileChange = (event: Event) => {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement
|
||||||
|
|
@ -128,27 +271,45 @@ const startClassification = async () => {
|
||||||
formData.append('image', selectedFile.value)
|
formData.append('image', selectedFile.value)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://127.0.0.1:5001/predict', {
|
const response = await fetch('/predict', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Gagal terhubung ke AI server")
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.error || "Gagal melakukan klasifikasi")
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
// Pastikan data yang dikirim Flask sesuai (label & confidence)
|
|
||||||
predictionResult.value = {
|
predictionResult.value = {
|
||||||
label: data.label,
|
label: data.label,
|
||||||
confidence: data.confidence
|
confidence: data.confidence + '%', // 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)
|
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 {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const clearImage = () => {
|
const clearImage = () => {
|
||||||
imagePreview.value = null
|
imagePreview.value = null
|
||||||
selectedFile.value = null
|
selectedFile.value = null
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,16 @@
|
||||||
import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
|
import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
|
||||||
import Notification from './Notification.vue';
|
import Notification from './Notification.vue';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { BreadcrumbItem } from '@/types';
|
||||||
|
import { usePage } from '@inertiajs/vue3';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const { breadcrumbs = [] } = defineProps<{
|
const { breadcrumbs = [] } = defineProps<{
|
||||||
breadcrumbs?: BreadcrumbItem[];
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -13,4 +19,4 @@ const { breadcrumbs = [] } = defineProps<{
|
||||||
<slot />
|
<slot />
|
||||||
<Notification />
|
<Notification />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { usePage } from '@inertiajs/vue3'
|
import { usePage } from '@inertiajs/vue3'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -42,30 +42,61 @@ const flash = computed(() => page.props.flash || {})
|
||||||
const message = ref(null)
|
const message = ref(null)
|
||||||
const type = ref(null)
|
const type = ref(null)
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
|
let activeTimeout = null
|
||||||
|
|
||||||
watch(
|
const trigger = (msg, t) => {
|
||||||
flash,
|
if (activeTimeout) clearTimeout(activeTimeout)
|
||||||
() => {
|
|
||||||
if (flash.value.success) {
|
|
||||||
message.value = flash.value.success
|
|
||||||
type.value = 'success'
|
|
||||||
isOpen.value = true
|
|
||||||
} else if (flash.value.error) {
|
|
||||||
message.value = flash.value.error
|
|
||||||
type.value = 'error'
|
|
||||||
isOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.value) {
|
message.value = msg
|
||||||
setTimeout(() => {
|
type.value = t
|
||||||
isOpen.value = false
|
isOpen.value = true
|
||||||
setTimeout(() => {
|
|
||||||
message.value = null
|
activeTimeout = setTimeout(() => {
|
||||||
type.value = null
|
isOpen.value = false
|
||||||
}, 300)
|
setTimeout(() => {
|
||||||
}, 2500)
|
message.value = null
|
||||||
|
type.value = null
|
||||||
|
activeTimeout = null
|
||||||
|
}, 300)
|
||||||
|
}, 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showNotification = (msg, t) => {
|
||||||
|
if (!msg) return
|
||||||
|
|
||||||
|
// Jika sudah terbuka, kita reset agar animasinya terpicu ulang
|
||||||
|
if (isOpen.value) {
|
||||||
|
isOpen.value = false
|
||||||
|
setTimeout(() => trigger(msg, t), 100)
|
||||||
|
} else {
|
||||||
|
trigger(msg, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gunakan router event agar selalu terdeteksi setiap kali request selesai
|
||||||
|
import { router } from '@inertiajs/vue3'
|
||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
let unregisterFinishEvent = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Cek saat mount awal
|
||||||
|
if (flash.value.success || flash.value.error) {
|
||||||
|
showNotification(flash.value.success || flash.value.error, flash.value.success ? 'success' : 'error')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dengarkan setiap kali aksi Inertia selesai
|
||||||
|
unregisterFinishEvent = router.on('finish', () => {
|
||||||
|
const currentFlash = page.props.flash || {}
|
||||||
|
if (currentFlash.success || currentFlash.error) {
|
||||||
|
showNotification(currentFlash.success || currentFlash.error, currentFlash.success ? 'success' : 'error')
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
{ immediate: true }
|
})
|
||||||
)
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unregisterFinishEvent) unregisterFinishEvent()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -0,0 +1,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>
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import { route } from 'ziggy-js';
|
import { route } from 'ziggy-js';
|
||||||
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Card } from '@/components/ui/card';
|
|
||||||
import { User } from '@/types';
|
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({
|
defineOptions({
|
||||||
layout: {
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Head title="Dashboard" />
|
<Head title="Dashboard" />
|
||||||
|
|
||||||
<div
|
<div class="flex h-full flex-1 flex-col gap-4 p-4 overflow-y-auto">
|
||||||
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
|
|
||||||
>
|
<Card class="border-border bg-card">
|
||||||
<!-- card greetings -->
|
<CardHeader class="pb-3">
|
||||||
<Card class="flex-1 border-border bg-card">
|
<CardTitle class="text-lg font-semibold text-foreground">
|
||||||
<div class="flex flex-col">
|
Selamat Datang, {{ user.name }}! ☕
|
||||||
<div class="flex items-center justify-between">
|
</CardTitle>
|
||||||
<div>
|
<CardDescription>
|
||||||
<h3 class="text-base font-medium text-foreground">
|
Anda masuk sebagai <span class="font-bold uppercase">{{ user.role }}</span>. Berikut ringkasan data klasifikasi kopi.
|
||||||
Selamat Datang {{ user.name }}
|
</CardDescription>
|
||||||
</h3>
|
</CardHeader>
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
ini adalah dashboard admin
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -30,6 +30,22 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div class="flex flex-col gap-2">
|
||||||
<Label for="password">Password</Label>
|
<Label for="password">Password</Label>
|
||||||
<div class="relative">
|
<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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { MultipleSelect } from '@/components/ui/multiple-select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { SelectSearch } from '@/components/ui/select-search';
|
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { Eye, EyeOff, UserPlus } from 'lucide-vue-next';
|
import { Eye, EyeOff, UserPlus } from 'lucide-vue-next';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
@ -102,6 +117,7 @@ const showPasswordConfirmation = ref(false);
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
|
role: '',
|
||||||
password: '',
|
password: '',
|
||||||
password_confirmation: '',
|
password_confirmation: '',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,22 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div class="flex flex-col gap-2">
|
||||||
<Label for="password">Password</Label>
|
<Label for="password">Password</Label>
|
||||||
<div class="relative">
|
<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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { MultipleSelect } from '@/components/ui/multiple-select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { SelectSearch } from '@/components/ui/select-search';
|
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { Edit, Eye, EyeOff, UserPlus } from 'lucide-vue-next';
|
import { Edit, Eye, EyeOff, UserPlus } from 'lucide-vue-next';
|
||||||
import { computed, ref,watch } from 'vue';
|
import { computed, ref,watch } from 'vue';
|
||||||
|
|
@ -105,12 +120,14 @@ const props = defineProps<{
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
role: string;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
name: props.user.name || '',
|
name: props.user.name || '',
|
||||||
email: props.user.email || '',
|
email: props.user.email || '',
|
||||||
|
role: props.user.role || '',
|
||||||
password: '',
|
password: '',
|
||||||
password_confirmation: '',
|
password_confirmation: '',
|
||||||
});
|
});
|
||||||
|
|
@ -119,6 +136,7 @@ watch(open, (value) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
form.name = props.user.name || '';
|
form.name = props.user.name || '';
|
||||||
form.email = props.user.email || '';
|
form.email = props.user.email || '';
|
||||||
|
form.role = props.user.role || '';
|
||||||
form.password = '';
|
form.password = '';
|
||||||
form.password_confirmation = '';
|
form.password_confirmation = '';
|
||||||
form.clearErrors();
|
form.clearErrors();
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,12 @@ const changePage = (page: number) => {
|
||||||
{{ sortOrder === 'asc' ? '↑' : '↓' }}
|
{{ sortOrder === 'asc' ? '↑' : '↓' }}
|
||||||
</span>
|
</span>
|
||||||
</TableHead>
|
</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>
|
<TableHead class="text-center">Aksi</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -125,6 +131,11 @@ const changePage = (page: number) => {
|
||||||
<div class="font-medium">{{ user.name }}</div>
|
<div class="font-medium">{{ user.name }}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{{ user.email }}</TableCell>
|
<TableCell>{{ user.email }}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ user.role === 'admin' ? 'Admin' : 'User' }}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex items-center justify-center gap-2">
|
<div class="flex items-center justify-center gap-2">
|
||||||
<UserEdit :user="user" />
|
<UserEdit :user="user" />
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="bg-background text-foreground min-h-screen">
|
<div class="bg-background text-foreground min-h-screen">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<Hero />
|
<Hero :total-classifications="totalClassifications" />
|
||||||
<Logo />
|
<!-- <Logo /> -->
|
||||||
<Scanner />
|
<Scanner />
|
||||||
<Keunggulan />
|
<Keunggulan />
|
||||||
<Teknologi />
|
<Teknologi />
|
||||||
|
|
@ -15,10 +15,14 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Navbar from '@/components/public/Navbar.vue'
|
import Navbar from '@/components/public/Navbar.vue'
|
||||||
import Hero from '@/components/public/Hero.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 Keunggulan from '@/components/public/Keunggulan.vue'
|
||||||
import Scanner from '@/components/public/Scanner.vue'
|
import Scanner from '@/components/public/Scanner.vue'
|
||||||
import Teknologi from '@/components/public/Teknologi.vue'
|
import Teknologi from '@/components/public/Teknologi.vue'
|
||||||
import FAQ from '@/components/public/FAQ.vue'
|
import FAQ from '@/components/public/FAQ.vue'
|
||||||
import FinalCta from '@/components/public/FinalCta.vue'
|
import FinalCta from '@/components/public/FinalCta.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
totalClassifications?: number
|
||||||
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
|
||||||
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
||||||
<script>
|
<script>
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,37 @@
|
||||||
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;
|
||||||
|
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 () {
|
Route::get('/', function () {
|
||||||
return inertia('public/Home', [
|
return inertia('public/Home', [
|
||||||
'canRegister' => Features::enabled(Features::registration()),
|
'canRegister' => Features::enabled(Features::registration()),
|
||||||
|
'totalClassifications' => Classification::count(),
|
||||||
]);
|
]);
|
||||||
})->name('home');
|
})->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::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::delete('classifications/{classification}', [AdminClassificationController::class, 'destroy'])->name('classifications.destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue