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 Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use App\Models\Classification; // Import model Classification
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Models\User;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Mulai Query Dasar
|
||||
$query = Classification::query();
|
||||
|
||||
// LOGIKA ROLE: Jika yang login adalah 'user', kunci hanya data miliknya
|
||||
if ($user->role === 'user') {
|
||||
$query->where('user_id', $user->id);
|
||||
}
|
||||
// Jika admin, biarkan query mengambil semua data (tanpa where)
|
||||
|
||||
// Ambil data Chart (sudah terfilter role)
|
||||
$chartData = (clone $query)->select('result', DB::raw('count(*) as total'))
|
||||
->groupBy('result')
|
||||
->get();
|
||||
|
||||
// Ambil History Terbaru (sudah terfilter role)
|
||||
$recentHistory = (clone $query)->latest()->limit(10)->get();
|
||||
|
||||
// Hitung Total Scan (sudah terfilter role)
|
||||
$totalScan = (clone $query)->count();
|
||||
|
||||
// Rata-rata akurasi dari yang berhasil
|
||||
$avgAccuracy = (clone $query)->where('status', 'Berhasil')->avg('confidence') ?? 0;
|
||||
|
||||
// Tingkat keberhasilan (Total Berhasil / Total Scan)
|
||||
$totalBerhasil = (clone $query)->where('status', 'Berhasil')->count();
|
||||
$successRate = $totalScan > 0 ? ($totalBerhasil / $totalScan) * 100 : 0;
|
||||
|
||||
$totalUser = User::where('role', 'user')->count();
|
||||
|
||||
return inertia('admin/Dashboard', [
|
||||
'user' => $user,
|
||||
'chartData' => $chartData,
|
||||
'recentHistory' => $recentHistory,
|
||||
'totalScan' => $totalScan,
|
||||
'avgAccuracy' => round($avgAccuracy, 2),
|
||||
'successRate' => round($successRate, 2),
|
||||
'totalUser' => $totalUser,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ public function store(Request $request)
|
|||
$validatedData = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|email|max:255|unique:users',
|
||||
'role' => 'required|in:user,admin',
|
||||
'password' => 'required|string|min:8|confirmed',
|
||||
]);
|
||||
|
||||
|
|
@ -49,6 +50,7 @@ public function store(Request $request)
|
|||
$user = User::create([
|
||||
'name' => $validatedData['name'],
|
||||
'email' => $validatedData['email'],
|
||||
'role' => $validatedData['role'],
|
||||
'password' => Hash::make($validatedData['password']),
|
||||
]);
|
||||
|
||||
|
|
@ -90,6 +92,7 @@ public function update(Request $request, string $id)
|
|||
$validatedData = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'required|string|email|max:255|unique:users,email,' . $id,
|
||||
'role' => 'required|in:user,admin',
|
||||
'password' => 'nullable|string|min:8|confirmed',
|
||||
]);
|
||||
$password = $validatedData['password'];
|
||||
|
|
@ -102,6 +105,7 @@ public function update(Request $request, string $id)
|
|||
$update = [
|
||||
'name' => $validatedData['name'],
|
||||
'email' => $validatedData['email'],
|
||||
'role' => $validatedData['role'],
|
||||
];
|
||||
if ($password) {
|
||||
$update['password'] = $password;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
'name' => config('app.name'),
|
||||
'auth' => [
|
||||
'user' => $request->user(),
|
||||
'user' => $request->user() ? [
|
||||
'id' => $request->user()->id,
|
||||
'name' => $request->user()->name,
|
||||
'email' => $request->user()->email,
|
||||
'role' => $request->user()->role, // Memastikan role terkirim dengan jelas
|
||||
// Jangan kirim password_hash atau data sensitif lainnya di sini
|
||||
] : null,
|
||||
],
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'sidebarOpen' => !$request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'flash' => [
|
||||
'success' => fn () => $request->session()->get('success'),
|
||||
'error' => fn () => $request->session()->get('error'),
|
||||
'success' => fn() => $request->session()->get('success'),
|
||||
'error' => fn() => $request->session()->get('error'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Fillable(['name', 'email', 'password', 'role'])]
|
||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,72 +1,131 @@
|
|||
import os
|
||||
import io
|
||||
import numpy as np
|
||||
import tensorflow as tf
|
||||
import joblib
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
from tensorflow.keras.models import load_model, Sequential
|
||||
from tensorflow.keras.preprocessing.image import img_to_array
|
||||
from tensorflow.keras.applications.mobilenet_v2 import preprocess_input
|
||||
from PIL import Image
|
||||
from rembg import remove
|
||||
from flask_cors import CORS
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
# 1. SETUP PATH MODEL
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
MODEL_PATH = os.path.join(BASE_DIR, 'model_efficientNet.keras')
|
||||
# ==============================================================================
|
||||
# 1. LOAD SEMUA MODEL (KLASIFIKATOR + SATPAM 1 & 2)
|
||||
# ==============================================================================
|
||||
MODEL_CNN_PATH = 'Arsitektur_MobileNetV2.keras'
|
||||
MODEL_CAE_PATH = 'satpam_kopi_cae.keras'
|
||||
SATPAM_IF_PATH = 'Satpam_IsolationForest.pkl'
|
||||
|
||||
# 2. DUMMY PREPROCESS (Agar tidak error saat load Lambda layer)
|
||||
def preprocess_input(x):
|
||||
return x
|
||||
print("⏳ Memuat seluruh infrastruktur model...")
|
||||
full_model = load_model(MODEL_CNN_PATH, compile=False)
|
||||
satpam_cae = load_model(MODEL_CAE_PATH, compile=False)
|
||||
iso_forest = joblib.load(SATPAM_IF_PATH)
|
||||
|
||||
print("⏳ Sedang memuat 'Otak AI'...")
|
||||
# Buat Feature Extractor untuk Satpam IF
|
||||
feature_extractor = Sequential([
|
||||
full_model.layers[0],
|
||||
full_model.layers[1]
|
||||
])
|
||||
feature_extractor.build((None, 224, 224, 3))
|
||||
|
||||
try:
|
||||
# Menggunakan parameter Keras 3 untuk memuat model lama
|
||||
model = tf.keras.models.load_model(
|
||||
MODEL_PATH,
|
||||
custom_objects={'preprocess_input': preprocess_input},
|
||||
compile=False,
|
||||
safe_mode=False # Kunci agar Keras 3 mau menerima config Keras lama
|
||||
)
|
||||
print("BERHASIL: Model AI readyy!")
|
||||
except Exception as e:
|
||||
print(f"GAGAL: {str(e)}")
|
||||
classes = ['honey', 'natural', 'wash']
|
||||
THRESHOLD_GOSONG = 51
|
||||
THRESHOLD_MSE = 0.0021
|
||||
|
||||
# Label klasifikasi kopi kamu
|
||||
labels = ['Honey', 'Natural', 'Washed']
|
||||
print("✅ Sistem Keamanan Berlapis Berhasil Diaktifkan.")
|
||||
|
||||
# ==============================================================================
|
||||
# 2. PIPELINE PENYARINGAN BERLAPIS
|
||||
# ==============================================================================
|
||||
def proses_gambar_strict(input_img):
|
||||
# A. Rembg & Crop
|
||||
output_rgba = remove(input_img)
|
||||
bbox = output_rgba.getbbox()
|
||||
if bbox: output_rgba = output_rgba.crop(bbox)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# LAPIS 1: Filter Kecerahan
|
||||
# --------------------------------------------------------------------------
|
||||
img_rgba_np = np.array(output_rgba)
|
||||
mask_biji = img_rgba_np[:, :, 3] > 0
|
||||
kecerahan = np.median(img_rgba_np[mask_biji][:, :3]) if np.any(mask_biji) else 0
|
||||
if kecerahan < THRESHOLD_GOSONG:
|
||||
return None, "DITOLAK_GELAP", f"Kecerahan terlalu rendah ({kecerahan:.1f})"
|
||||
|
||||
# Siapkan Kanvas Hitam Dasar
|
||||
max_dim = max(output_rgba.size)
|
||||
black_bg = Image.new("RGB", (max_dim, max_dim), (0, 0, 0))
|
||||
black_bg.paste(output_rgba, ((max_dim - output_rgba.size[0]) // 2, (max_dim - output_rgba.size[1]) // 2), mask=output_rgba.split()[3])
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# LAPIS 2: Satpam Bentuk (CAE 64x64) - Menyaring Geometri Kasar
|
||||
# --------------------------------------------------------------------------
|
||||
img_cae = black_bg.resize((64, 64))
|
||||
img_arr_cae = img_to_array(img_cae) / 255.0
|
||||
img_arr_cae = np.expand_dims(img_arr_cae, axis=0)
|
||||
|
||||
rekonstruksi = satpam_cae.predict(img_arr_cae, verbose=0)
|
||||
mse_score = np.mean(np.square(img_arr_cae - rekonstruksi))
|
||||
if mse_score > THRESHOLD_MSE:
|
||||
return None, "DITOLAK_CAE", f"Struktur bentuk tidak sesuai standar (MSE: {mse_score:.5f})"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# LAPIS 3: Satpam Semantik (Isolation Forest 224x224) - Menyaring Detail Fitur
|
||||
# --------------------------------------------------------------------------
|
||||
final_img = black_bg.resize((224, 224))
|
||||
img_array = img_to_array(final_img)
|
||||
img_array = np.expand_dims(img_array, axis=0)
|
||||
img_array = preprocess_input(img_array)
|
||||
|
||||
fitur = feature_extractor.predict(img_array, verbose=0)
|
||||
keputusan_if = iso_forest.predict(fitur)[0]
|
||||
if keputusan_if == -1:
|
||||
return None, "DITOLAK_IF", "Karakteristik objek bukan green bean kopi"
|
||||
|
||||
return img_array, "LOLOS", "Semua pos pemeriksaan aman"
|
||||
|
||||
# ==============================================================================
|
||||
# 3. ENDPOINT API
|
||||
# ==============================================================================
|
||||
@app.route('/predict', methods=['POST'])
|
||||
def predict():
|
||||
if 'image' not in request.files:
|
||||
return jsonify({"status": "ERROR", "message": "File tidak ditemukan"}), 400
|
||||
|
||||
try:
|
||||
file = request.files['image']
|
||||
img = Image.open(request.files['image'].stream).convert("RGBA")
|
||||
processed_img, status_satpam, keterangan = proses_gambar_strict(img)
|
||||
|
||||
# 1. Load Gambar & Resize ke 224x224
|
||||
img = Image.open(file.stream).convert('RGB')
|
||||
img = img.resize((224, 224))
|
||||
# Jika salah satu satpam menolak, langsung return kembalian status DITOLAK
|
||||
if status_satpam != "LOLOS":
|
||||
return jsonify({
|
||||
"status": "DITOLAK",
|
||||
"label": "Tidak terdeteksi",
|
||||
"pesan": f"Objek ditolak pada tahap {status_satpam.split('_')[1]}. Keterangan: {keterangan}."
|
||||
}), 200
|
||||
|
||||
# Jika lolos semua satpam, panggil pakar klasifikasi utama
|
||||
preds = full_model.predict(processed_img, verbose=0)[0]
|
||||
confidence = float(np.max(preds))
|
||||
predicted_class = classes[np.argmax(preds)]
|
||||
entropy = -np.sum(preds * np.log(preds + 1e-9))
|
||||
|
||||
# 2. Konversi ke Array
|
||||
img_array = np.array(img).astype('float32')
|
||||
|
||||
# 3. JURUS SAKTI: Gunakan preprocessing asli EfficientNet
|
||||
# Ini akan menangani scaling warna agar sama persis dengan saat training
|
||||
img_array = tf.keras.applications.efficientnet.preprocess_input(img_array)
|
||||
img_array = np.expand_dims(img_array, axis=0)
|
||||
|
||||
# 4. Prediksi
|
||||
preds = model.predict(img_array, verbose=0)
|
||||
class_idx = np.argmax(preds[0])
|
||||
confidence = float(np.max(preds[0]))
|
||||
|
||||
print(f"📥 Prediksi: {labels[class_idx]} ({confidence*100:.2f}%)")
|
||||
if entropy > 0.85 or confidence < 0.70:
|
||||
return jsonify({"status": "DITOLAK", "label": "Tidak terdeteksi", "pesan": "Sistem ragu dengan objek ini."}), 200
|
||||
|
||||
return jsonify({
|
||||
'label': labels[class_idx],
|
||||
'confidence': f"{confidence * 100:.2f}%",
|
||||
'status': 'success'
|
||||
})
|
||||
"status": "BERHASIL",
|
||||
"label": predicted_class,
|
||||
"confidence": str(round(confidence * 100, 2)),
|
||||
"pesan": f"Biji kopi proses {predicted_class.upper()} terdeteksi.",
|
||||
"details": {cls: round(float(p) * 100, 2) for cls, p in zip(classes, preds)}
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ ERROR PREDIKSI: {str(e)}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return jsonify({"status": "ERROR", "message": str(e)}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Jalankan di port 5001 agar tidak diblokir Windows AirPlay
|
||||
app.run(host='127.0.0.1', port=5001, debug=True)
|
||||
app.run(host='0.0.0.0', port=5001, debug=False)
|
||||
Binary file not shown.
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use App\Http\Middleware\HandleAppearance;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Http\Middleware\RoleMiddleware;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
|
@ -9,13 +10,17 @@
|
|||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
web: __DIR__ . '/../routes/web.php',
|
||||
commands: __DIR__ . '/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||
|
||||
$middleware->alias([
|
||||
'role' => RoleMiddleware::class,
|
||||
]);
|
||||
|
||||
$middleware->web(append: [
|
||||
HandleAppearance::class,
|
||||
HandleInertiaRequests::class,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ public function up(): void
|
|||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
|
||||
$table->string('role')->default('user');
|
||||
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
|
|
@ -16,8 +17,10 @@ public function run(): void
|
|||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'name' => 'Admin',
|
||||
'email' => 'admin@example.com',
|
||||
'password' => Hash::make('password'),
|
||||
'role' => 'admin',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"@inertiajs/vue3": "^3.0.0",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"laravel-vite-plugin": "^3.0.0",
|
||||
|
|
@ -18,6 +19,7 @@
|
|||
"tailwindcss": "^4.1.1",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"vue": "^3.5.13",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-input-otp": "^0.3.2",
|
||||
"vue-sonner": "^2.0.0",
|
||||
"ziggy-js": "^2.6.2"
|
||||
|
|
@ -575,6 +577,12 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
|
|
@ -2505,6 +2513,19 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/class-variance-authority": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
|
|
@ -6625,6 +6646,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-chartjs": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
|
||||
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": "^4.1.1",
|
||||
"vue": "^3.0.0-0 || ^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-eslint-parser": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz",
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"@inertiajs/vue3": "^3.0.0",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"laravel-vite-plugin": "^3.0.0",
|
||||
|
|
@ -46,6 +47,7 @@
|
|||
"tailwindcss": "^4.1.1",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"vue": "^3.5.13",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-input-otp": "^0.3.2",
|
||||
"vue-sonner": "^2.0.0",
|
||||
"ziggy-js": "^2.6.2"
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 573 KiB |
|
|
@ -20,6 +20,8 @@ createInertiaApp({
|
|||
return [AppLayout, SettingsLayout];
|
||||
case name.startsWith('admin/'):
|
||||
return [AppLayout];
|
||||
case name.startsWith('user/'):
|
||||
return [AppLayout];
|
||||
default:
|
||||
return PublicLayout;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
|||
|
||||
<template>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-md bg-green-600"
|
||||
>
|
||||
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
|
||||
<AppLogoIcon class="size-5 text-white" />
|
||||
</div>
|
||||
<div class="ml-1 grid flex-1 text-left text-sm">
|
||||
<span class="mb-0.5 truncate leading-tight font-semibold"
|
||||
>Laravel Starter Kit</span
|
||||
>GREENS</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
import { Bean } from 'lucide-vue-next';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
|
|
@ -13,17 +15,8 @@ defineProps<Props>();
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 40 42"
|
||||
:class="className"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
|
||||
/>
|
||||
</svg>
|
||||
<Bean
|
||||
class="w-5 h-5 text-white"
|
||||
:stroke-width="2.8"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import { route } from 'ziggy-js';
|
||||
import { BookOpen, FolderGit2, LayoutGrid, Users } from 'lucide-vue-next';
|
||||
import { BookOpen, FolderGit2, LayoutGrid, Users, ScanSearch } from 'lucide-vue-next';
|
||||
import AppLogo from '@/components/AppLogo.vue';
|
||||
import NavFooter from '@/components/NavFooter.vue';
|
||||
import NavMain from '@/components/NavMain.vue';
|
||||
import NavUser from '@/components/NavUser.vue';
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
|
|
@ -17,31 +19,61 @@ import {
|
|||
} from '@/components/ui/sidebar';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
const page = usePage();
|
||||
// Ambil data user dari auth yang dikirim via HandleInertiaRequests
|
||||
const user = computed(() => page.props.auth.user);
|
||||
|
||||
const adminNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: route('admin.dashboard'),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Users',
|
||||
title: 'Users Management',
|
||||
href: route('admin.users.index'),
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: 'History Klasifikasi',
|
||||
href: route('admin.classifications.index'),
|
||||
icon: ScanSearch,
|
||||
},
|
||||
];
|
||||
|
||||
const footerNavItems: NavItem[] = [
|
||||
const userNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/laravel/vue-starter-kit',
|
||||
icon: FolderGit2,
|
||||
title: 'Dashboard',
|
||||
href: route('dashboard'),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#vue',
|
||||
icon: BookOpen,
|
||||
title: 'Riwayat Klasifikasi',
|
||||
href: route('classifications.index'),
|
||||
icon: ScanSearch,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const navItems = computed(() => {
|
||||
if (user.value?.role === 'admin') {
|
||||
return adminNavItems;
|
||||
} else {
|
||||
return userNavItems;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Filter menu berdasarkan role user yang sedang login
|
||||
// const filteredNavItems = computed(() => {
|
||||
// return allNavItems.filter(item => {
|
||||
// // Jika menu tidak punya batasan role, tampilkan untuk semua
|
||||
// if (!item.role) return true;
|
||||
// // Jika ada batasan role, cek apakah role user cocok (admin === admin)
|
||||
// return item.role === user.value?.role;
|
||||
// });
|
||||
// });
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -59,13 +91,12 @@ const footerNavItems: NavItem[] = [
|
|||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<NavMain :items="mainNavItems" />
|
||||
<NavMain :items="navItems" />
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<NavFooter :items="footerNavItems" />
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<slot />
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -4,11 +4,14 @@
|
|||
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 tracking-tight font-display">
|
||||
Pantau Riwayat Klasifikasi
|
||||
</h2>
|
||||
<p class="text-lg sm:text-xl text-zinc-400 mb-10 max-w-2xl mx-auto">
|
||||
<p v-if="!user" class="text-lg sm:text-xl text-zinc-400 mb-10 max-w-2xl mx-auto">
|
||||
Masuk ke akun Anda sekarang untuk menyimpan hasil dan melihat kembali seluruh riwayat klasifikasi biji kopi yang pernah Anda lakukan.
|
||||
</p>
|
||||
<p v-else class="text-lg sm:text-xl text-zinc-400 mb-10 max-w-2xl mx-auto">
|
||||
Pantau riwayat klasifikasi biji kopi Anda sekarang.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<div v-if="!user" class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/login"
|
||||
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-8 py-3 text-base font-bold shadow-lg shadow-emerald-500/20 flex items-center gap-2 transition-all active:scale-95"
|
||||
|
|
@ -24,6 +27,15 @@
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-8 py-3 text-base font-bold shadow-lg shadow-emerald-500/20 flex items-center gap-2 transition-all active:scale-95"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p class="mt-8 text-sm text-zinc-500">Gratis sepenuhnya. Jangan biarkan riwayat klasifikasi Anda hilang.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -31,5 +43,9 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight } from 'lucide-vue-next'
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
import { Link, usePage } from '@inertiajs/vue3'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const page = usePage()
|
||||
const user = computed(() => page.props.auth?.user)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
<!-- CTAs -->
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-4 mb-16">
|
||||
<button
|
||||
@click="scrollToScanner"
|
||||
class="shimmer-btn bg-white text-zinc-950 hover:bg-zinc-200 rounded-full px-8 py-3 text-base font-medium shadow-lg shadow-white/10 flex items-center gap-2"
|
||||
>
|
||||
Mulai Klasifikasi
|
||||
|
|
@ -47,17 +48,18 @@
|
|||
|
||||
<!-- Social Proof -->
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="flex items-center -space-x-3">
|
||||
<!-- <div class="flex items-center -space-x-3">
|
||||
<img
|
||||
v-for="(avatar, index) in avatars"
|
||||
:key="index"
|
||||
:src="avatar"
|
||||
:alt="`Avatar ${index + 1}`"
|
||||
class="w-10 h-10 rounded-full border-2 border-zinc-950 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div> -->
|
||||
<p class="text-sm text-zinc-500">
|
||||
Telah digunakan oleh <span class="text-zinc-300 font-medium">2,000+</span> orang
|
||||
Telah digunakan <span class="text-zinc-300 font-medium">{{ totalClassifications ? totalClassifications.toLocaleString('id-ID') : '0' }}</span> kali
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -67,6 +69,14 @@
|
|||
<script setup lang="ts">
|
||||
import { ArrowRight } from 'lucide-vue-next'
|
||||
|
||||
defineProps<{
|
||||
totalClassifications?: number
|
||||
}>()
|
||||
|
||||
const scrollToScanner = () => {
|
||||
document.getElementById('klasifikasi')?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const avatars = [
|
||||
'/professional-headshot-1.png',
|
||||
'/professional-headshot-2.png',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<section id="features" class="py-24 px-4">
|
||||
<section id="keunggulan" class="py-24 px-4">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4 font-display">Keunggulan Sistem Kami</h2>
|
||||
|
|
|
|||
|
|
@ -31,18 +31,37 @@
|
|||
|
||||
<!-- CTA Buttons -->
|
||||
<div class="hidden md:flex items-center gap-3">
|
||||
<Link
|
||||
href="/login"
|
||||
class="px-4 py-2 text-sm text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
>
|
||||
Masuk
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-4 py-2 text-sm font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
|
||||
>
|
||||
Daftar
|
||||
</Link>
|
||||
<div v-if="user">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
class="px-4 py-2 text-sm text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
class="shimmer-btn bg-red-500 text-zinc-950 hover:bg-red-400 rounded-full px-4 py-2 text-sm font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-95 inline-flex items-center"
|
||||
:href="route('logout')"
|
||||
method="post"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</Link>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Link
|
||||
href="/login"
|
||||
class="px-4 py-2 text-sm text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
>
|
||||
Masuk
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
class="shimmer-btn bg-emerald-500 text-zinc-950 hover:bg-emerald-400 rounded-full px-4 py-2 text-sm font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
|
||||
>
|
||||
Daftar
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
|
|
@ -88,9 +107,13 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Menu, X } from 'lucide-vue-next'
|
||||
import { Link } from '@inertiajs/vue3'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Link, usePage, router } from '@inertiajs/vue3'
|
||||
import { LogOut, Menu, X } from 'lucide-vue-next'
|
||||
import { route } from 'ziggy-js';
|
||||
|
||||
const page = usePage()
|
||||
const user = computed(() => page.props.auth?.user)
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Klasifikasi', href: '#klasifikasi' },
|
||||
|
|
@ -99,6 +122,10 @@ const navItems = [
|
|||
{ label: 'F.A.Q', href: '#faq' },
|
||||
]
|
||||
|
||||
const handleLogout = () => {
|
||||
router.flushAll();
|
||||
};
|
||||
|
||||
const hoveredIndex = ref<number | null>(null)
|
||||
const mobileMenuOpen = ref(false)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -21,13 +21,23 @@
|
|||
Unggah foto biji kopi Arabika Anda. Pastikan gambar jelas dan mendapatkan pencahayaan yang cukup untuk hasil terbaik.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
|
||||
<div :class="['w-2 h-2 rounded-full bg-emerald-500', isLoading ? 'animate-ping' : 'animate-pulse']"></div>
|
||||
<span class="text-[10px] font-bold text-emerald-500 uppercase tracking-wider">
|
||||
{{ isLoading ? 'Processing...' : 'AI Model Ready' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="showGuideModal = true"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-zinc-800/80 border border-zinc-700 hover:border-emerald-500/50 hover:bg-zinc-800 transition-all group"
|
||||
>
|
||||
<Info class="w-3.5 h-3.5 text-zinc-400 group-hover:text-emerald-400 transition-colors" />
|
||||
<span class="text-[10px] font-bold text-zinc-400 group-hover:text-emerald-400 uppercase tracking-wider transition-colors">
|
||||
Panduan Foto
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -53,7 +63,7 @@
|
|||
</template>
|
||||
|
||||
<template v-else>
|
||||
<img :src="imagePreview" class="absolute inset-0 w-full h-full object-cover" />
|
||||
<img :src="imagePreview" class="absolute inset-0 w-full h-full object-cover" loading="lazy" />
|
||||
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover/upload:opacity-100 transition-opacity duration-300 flex items-center justify-center backdrop-blur-[2px]">
|
||||
<span class="bg-black/60 text-white px-4 py-2 rounded-full text-sm font-medium">Klik untuk mengganti</span>
|
||||
</div>
|
||||
|
|
@ -79,12 +89,47 @@
|
|||
</div>
|
||||
|
||||
<transition enter-active-class="transition duration-500 ease-out" enter-from-class="transform scale-95 opacity-0" enter-to-class="transform scale-100 opacity-100">
|
||||
<div v-if="predictionResult" class="mt-8 p-8 rounded-3xl bg-emerald-600 shadow-[0_0_50px_-12px_rgba(16,185,129,0.5)] text-center relative overflow-hidden">
|
||||
<div
|
||||
v-if="predictionResult"
|
||||
class="mt-8 p-8 rounded-3xl text-center relative overflow-hidden transition-colors duration-500 shadow-lg"
|
||||
:class="statusInfo?.cardClass"
|
||||
>
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs font-black text-emerald-100 uppercase tracking-[0.3em] mb-2">Hasil Klasifikasi AI</p>
|
||||
<h4 class="text-5xl font-black text-white italic mb-2 tracking-tighter">{{ predictionResult.label }}</h4>
|
||||
<div class="inline-block px-4 py-1.5 bg-black/20 rounded-full backdrop-blur-md">
|
||||
<span class="text-emerald-500 font-bold text-sm uppercase tracking-widest">Confidence: {{ predictionResult.confidence }}</span>
|
||||
<div :class="predictionResult.status === 'DITOLAK' ? 'flex flex-col md:flex-row items-center justify-center gap-8' : ''">
|
||||
|
||||
<!-- KIRI: CONTOH FOTO JIKA DITOLAK -->
|
||||
<div v-if="predictionResult.status === 'DITOLAK'" class="flex-1 w-full max-w-sm mx-auto bg-black/20 p-6 rounded-2xl border border-white/10 text-left">
|
||||
<p class="text-sm text-white font-bold mb-3 text-center">Contoh Foto yang Benar:</p>
|
||||
<img src="/pict_example.jpg" alt="Contoh Foto Biji Kopi" class="w-full rounded-xl shadow-sm border border-white/20" loading="lazy" />
|
||||
</div>
|
||||
|
||||
<!-- KANAN / TENGAH: HASIL PREDIKSI -->
|
||||
<div class="flex-1 w-full max-w-sm mx-auto flex flex-col justify-center">
|
||||
<p class="text-xs font-black text-white/70 uppercase tracking-[0.3em] mb-2">Hasil Klasifikasi AI</p>
|
||||
<h4 class="text-5xl font-black text-white mb-4 tracking-tighter">{{ predictionResult.label }}</h4>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<div class="px-4 py-1.5 bg-black/20 rounded-full backdrop-blur-md border border-white/10">
|
||||
<span class="text-white font-bold text-xs uppercase tracking-widest">Confidence: {{ predictionResult.confidence }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="px-4 py-1.5 rounded-full backdrop-blur-md border flex items-center gap-2"
|
||||
:class="statusInfo?.badgeClass"
|
||||
>
|
||||
<div :class="['w-1.5 h-1.5 rounded-full', statusInfo?.dotClass]"></div>
|
||||
<span class="font-bold text-xs uppercase tracking-widest">
|
||||
{{ statusInfo?.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menampilkan Probabilitas -->
|
||||
<div v-if="predictionResult.details" class="mt-6 text-sm text-white/80 whitespace-pre-line text-left bg-black/20 p-4 rounded-xl border border-white/10 w-full">
|
||||
{{ predictionResult.details }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<Bean class="absolute -right-10 -bottom-10 w-40 h-40 text-white/10 rotate-12" />
|
||||
|
|
@ -93,17 +138,115 @@
|
|||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Panduan -->
|
||||
<transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="showGuideModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm" @click="showGuideModal = false">
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-3xl max-w-3xl w-full p-6 sm:p-8 relative overflow-hidden shadow-2xl" @click.stop>
|
||||
<button @click="showGuideModal = false" class="absolute top-4 right-4 text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 p-2 rounded-full transition-colors z-10">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-3 mb-8 pr-8">
|
||||
<div class="p-2.5 rounded-xl bg-emerald-500/10">
|
||||
<Info class="w-6 h-6 text-emerald-500" />
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-white font-display">Panduan Pengambilan Foto</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-8 items-center">
|
||||
<div class="space-y-4">
|
||||
<div class="bg-black/50 rounded-2xl overflow-hidden border border-zinc-800 relative group">
|
||||
<img src="/pict_example.jpg" alt="Contoh Foto Benar" class="w-full h-auto aspect-video object-cover" />
|
||||
<div class="absolute inset-0 border-2 border-emerald-500/0 group-hover:border-emerald-500/50 rounded-2xl transition-colors pointer-events-none"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-emerald-400 text-sm font-medium">
|
||||
<div class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></div>
|
||||
Contoh Foto yang Benar
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-center space-y-6">
|
||||
<div>
|
||||
<h4 class="text-lg font-bold text-white mb-3 flex items-center gap-2">
|
||||
<span class="text-emerald-500">Tips</span> agar hasil akurat:
|
||||
</h4>
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start gap-3">
|
||||
<div class="min-w-[24px] h-6 rounded-full bg-emerald-500/10 text-emerald-500 flex items-center justify-center text-xs font-bold mt-0.5 border border-emerald-500/20">1</div>
|
||||
<p class="text-zinc-300 text-sm leading-relaxed">Pastikan objek biji kopi <strong>fokus</strong> dan tidak buram (blur).</p>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<div class="min-w-[24px] h-6 rounded-full bg-emerald-500/10 text-emerald-500 flex items-center justify-center text-xs font-bold mt-0.5 border border-emerald-500/20">2</div>
|
||||
<p class="text-zinc-300 text-sm leading-relaxed">Gunakan <strong>pencahayaan yang cukup</strong> (cahaya matahari/lampu terang) agar tekstur dan warna jelas.</p>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<div class="min-w-[24px] h-6 rounded-full bg-emerald-500/10 text-emerald-500 flex items-center justify-center text-xs font-bold mt-0.5 border border-emerald-500/20">3</div>
|
||||
<p class="text-zinc-300 text-sm leading-relaxed">Hindari <strong>bayangan yang terlalu gelap</strong> atau pantulan cahaya yang menyilaukan.</p>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<div class="min-w-[24px] h-6 rounded-full bg-emerald-500/10 text-emerald-500 flex items-center justify-center text-xs font-bold mt-0.5 border border-emerald-500/20">4</div>
|
||||
<p class="text-zinc-300 text-sm leading-relaxed">Ambil foto dari <strong>jarak yang pas</strong>. Biji kopi harus terlihat jelas secara keseluruhan.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-zinc-800 flex justify-end">
|
||||
<button @click="showGuideModal = false" class="px-6 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-xl transition-colors">
|
||||
Mengerti
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Upload, Plus, X, Loader2, Bean } from 'lucide-vue-next'
|
||||
import { ref, computed } from 'vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import { route } from 'ziggy-js'
|
||||
import { Upload, Plus, X, Loader2, Bean, Info } from 'lucide-vue-next'
|
||||
|
||||
const imagePreview = ref<string | null>(null)
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const predictionResult = ref<{ label: string, confidence: string } | null>(null)
|
||||
const predictionResult = ref<{ label: string, confidence: string, details?: string, status?: string } | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const showGuideModal = ref(false)
|
||||
|
||||
const statusInfo = computed(() => {
|
||||
if (!predictionResult.value) return null
|
||||
|
||||
// Membaca status langsung dari Backend!
|
||||
const statusAI = predictionResult.value.status
|
||||
|
||||
if (statusAI === 'BERHASIL') {
|
||||
return {
|
||||
text: 'Berhasil',
|
||||
cardClass: 'bg-emerald-600 shadow-[0_0_50px_-12px_rgba(16,185,129,0.5)]',
|
||||
badgeClass: 'bg-emerald-500/20 border-emerald-500/50 text-emerald-200',
|
||||
dotClass: 'bg-emerald-400'
|
||||
}
|
||||
} else {
|
||||
// Berlaku untuk 'DITOLAK'
|
||||
return {
|
||||
text: 'Ditolak',
|
||||
cardClass: 'bg-red-600 shadow-[0_0_50px_-12px_rgba(220,38,38,0.5)]',
|
||||
badgeClass: 'bg-red-500/20 border-red-500/50 text-red-200',
|
||||
dotClass: 'bg-red-400'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
|
|
@ -128,27 +271,45 @@ const startClassification = async () => {
|
|||
formData.append('image', selectedFile.value)
|
||||
|
||||
try {
|
||||
const response = await fetch('http://127.0.0.1:5001/predict', {
|
||||
const response = await fetch('/predict', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || ''
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error("Gagal terhubung ke AI server")
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || "Gagal melakukan klasifikasi")
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
// Pastikan data yang dikirim Flask sesuai (label & confidence)
|
||||
predictionResult.value = {
|
||||
label: data.label,
|
||||
confidence: data.confidence
|
||||
confidence: data.confidence + '%', // Tambah % biar rapi di UI
|
||||
details: data.message,
|
||||
status: data.status // Simpan status dari Laravel/Flask
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Error: Pastikan backend-ai sudah dijalankan (python app.py)")
|
||||
|
||||
// Refresh data jika di Inertia context (dashboard)
|
||||
if (typeof route === 'function' && route().current('classifications.index')) {
|
||||
router.reload({ only: ['classifications'] })
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error)
|
||||
predictionResult.value = {
|
||||
label: 'Gagal',
|
||||
confidence: '0%',
|
||||
details: error.message || 'Terjadi kesalahan saat klasifikasi',
|
||||
status: 'DITOLAK' // Beri status DITOLAK agar UI langsung memerah
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const clearImage = () => {
|
||||
imagePreview.value = null
|
||||
selectedFile.value = null
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const props = defineProps<AvatarImageProps>()
|
|||
data-slot="avatar-image"
|
||||
v-bind="props"
|
||||
class="aspect-square size-full"
|
||||
loading="lazy"
|
||||
>
|
||||
<slot />
|
||||
</AvatarImage>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,16 @@
|
|||
import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
|
||||
import Notification from './Notification.vue';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const { breadcrumbs = [] } = defineProps<{
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
}>();
|
||||
|
||||
// Mengambil data user untuk keperluan pengecekan role jika dibutuhkan di level ini
|
||||
const page = usePage();
|
||||
const user = computed(() => page.props.auth.user);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -13,4 +19,4 @@ const { breadcrumbs = [] } = defineProps<{
|
|||
<slot />
|
||||
<Notification />
|
||||
</AppLayout>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -42,30 +42,61 @@ const flash = computed(() => page.props.flash || {})
|
|||
const message = ref(null)
|
||||
const type = ref(null)
|
||||
const isOpen = ref(false)
|
||||
let activeTimeout = null
|
||||
|
||||
watch(
|
||||
flash,
|
||||
() => {
|
||||
if (flash.value.success) {
|
||||
message.value = flash.value.success
|
||||
type.value = 'success'
|
||||
isOpen.value = true
|
||||
} else if (flash.value.error) {
|
||||
message.value = flash.value.error
|
||||
type.value = 'error'
|
||||
isOpen.value = true
|
||||
}
|
||||
const trigger = (msg, t) => {
|
||||
if (activeTimeout) clearTimeout(activeTimeout)
|
||||
|
||||
if (message.value) {
|
||||
setTimeout(() => {
|
||||
isOpen.value = false
|
||||
setTimeout(() => {
|
||||
message.value = null
|
||||
type.value = null
|
||||
}, 300)
|
||||
}, 2500)
|
||||
message.value = msg
|
||||
type.value = t
|
||||
isOpen.value = true
|
||||
|
||||
activeTimeout = setTimeout(() => {
|
||||
isOpen.value = false
|
||||
setTimeout(() => {
|
||||
message.value = null
|
||||
type.value = null
|
||||
activeTimeout = null
|
||||
}, 300)
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
const showNotification = (msg, t) => {
|
||||
if (!msg) return
|
||||
|
||||
// Jika sudah terbuka, kita reset agar animasinya terpicu ulang
|
||||
if (isOpen.value) {
|
||||
isOpen.value = false
|
||||
setTimeout(() => trigger(msg, t), 100)
|
||||
} else {
|
||||
trigger(msg, t)
|
||||
}
|
||||
}
|
||||
|
||||
// Gunakan router event agar selalu terdeteksi setiap kali request selesai
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
let unregisterFinishEvent = null
|
||||
|
||||
onMounted(() => {
|
||||
// Cek saat mount awal
|
||||
if (flash.value.success || flash.value.error) {
|
||||
showNotification(flash.value.success || flash.value.error, flash.value.success ? 'success' : 'error')
|
||||
}
|
||||
|
||||
// Dengarkan setiap kali aksi Inertia selesai
|
||||
unregisterFinishEvent = router.on('finish', () => {
|
||||
const currentFlash = page.props.flash || {}
|
||||
if (currentFlash.success || currentFlash.error) {
|
||||
showNotification(currentFlash.success || currentFlash.error, currentFlash.success ? 'success' : 'error')
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unregisterFinishEvent) unregisterFinishEvent()
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
|
|
@ -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">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { route } from 'ziggy-js';
|
||||
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { User } from '@/types';
|
||||
import { computed } from 'vue';
|
||||
|
||||
// Import untuk Chart
|
||||
import { Pie } from 'vue-chartjs';
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js';
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend);
|
||||
|
||||
defineOptions({
|
||||
layout: {
|
||||
|
|
@ -17,29 +22,160 @@ defineOptions({
|
|||
},
|
||||
});
|
||||
|
||||
const { user } = defineProps<{ user: User }>();
|
||||
// Terima Props dari Controller
|
||||
const props = defineProps<{
|
||||
user: User,
|
||||
chartData: Array<{ result: string, total: number }>,
|
||||
recentHistory: Array<any>,
|
||||
totalScan: number,
|
||||
avgAccuracy: number,
|
||||
successRate: number,
|
||||
totalUser: number,
|
||||
}>();
|
||||
|
||||
// Konfigurasi Warna & Data untuk Pie Chart
|
||||
const chartConfig = computed(() => ({
|
||||
labels: props.chartData.map(item => item.result),
|
||||
datasets: [{
|
||||
backgroundColor: props.chartData.map(item => {
|
||||
switch (item.result?.toLowerCase()) {
|
||||
case 'honey': return '#f59e0b'; // Amber-500
|
||||
case 'wash': return '#10b981'; // Blue-500
|
||||
case 'natural': return '#713600'; // Emerald-500
|
||||
default: return '#94a3b8'; // Slate-400
|
||||
}
|
||||
}),
|
||||
data: props.chartData.map(item => item.total),
|
||||
borderWidth: 1
|
||||
}]
|
||||
}));
|
||||
|
||||
const getResultColorClass = (result: string) => {
|
||||
switch (result?.toLowerCase()) {
|
||||
case 'honey': return 'text-amber-600 dark:text-amber-500';
|
||||
case 'washed': return 'text-blue-600 dark:text-blue-500';
|
||||
case 'natural': return 'text-emerald-600 dark:text-emerald-500';
|
||||
default: return 'text-muted-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom' as const,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Dashboard" />
|
||||
|
||||
<div
|
||||
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
|
||||
>
|
||||
<!-- card greetings -->
|
||||
<Card class="flex-1 border-border bg-card">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-medium text-foreground">
|
||||
Selamat Datang {{ user.name }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
ini adalah dashboard admin
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-full flex-1 flex-col gap-4 p-4 overflow-y-auto">
|
||||
|
||||
<Card class="border-border bg-card">
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="text-lg font-semibold text-foreground">
|
||||
Selamat Datang, {{ user.name }}! ☕
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Anda masuk sebagai <span class="font-bold uppercase">{{ user.role }}</span>. Berikut ringkasan data klasifikasi kopi.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Total Pengguna</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">{{ totalUser }}</div>
|
||||
<p class="text-xs text-muted-foreground">Akumulasi seluruh pengguna</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Total Scan</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">{{ totalScan }}</div>
|
||||
<p class="text-xs text-muted-foreground">Akumulasi seluruh pemindaian</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Rata-rata Akurasi</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold text-emerald-600">{{ avgAccuracy }}%</div>
|
||||
<p class="text-xs text-muted-foreground">Dari klasifikasi berstatus berhasil</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Tingkat Keberhasilan</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold text-blue-600">{{ successRate }}%</div>
|
||||
<p class="text-xs text-muted-foreground">Persentase status berhasil dari total scan</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card class="col-span-1 lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Distribusi Pasca Panen</CardTitle>
|
||||
<CardDescription>Persentase hasil klasifikasi</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="h-[300px]">
|
||||
<Pie v-if="chartData.length > 0" :data="chartConfig" :options="chartOptions" />
|
||||
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
|
||||
Belum ada data untuk ditampilkan
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="col-span-1 lg:col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Riwayat Terakhir</CardTitle>
|
||||
<CardDescription>10 pemindaian terbaru</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="relative w-full overflow-auto">
|
||||
<table class="w-full caption-bottom text-sm">
|
||||
<thead class="[&_tr]:border-b">
|
||||
<tr class="border-b transition-colors hover:bg-muted/50">
|
||||
<th class="h-12 px-4 text-left align-middle font-medium">Tanggal</th>
|
||||
<th class="h-12 px-4 text-left align-middle font-medium">Hasil</th>
|
||||
<th class="h-12 px-4 text-left align-middle font-medium">Akurasi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="[&_tr:last-child]:border-0">
|
||||
<tr v-for="item in recentHistory" :key="item.id" class="border-b transition-colors hover:bg-muted/50">
|
||||
<td class="p-4 align-middle text-muted-foreground">
|
||||
{{ new Date(item.created_at).toLocaleDateString('id-ID', { day: '2-digit', month: 'short' }) }}
|
||||
</td>
|
||||
<td class="p-4 align-middle font-black italic tracking-tight" :class="getResultColorClass(item.result)">
|
||||
{{ item.result }}
|
||||
</td>
|
||||
<td class="p-4 align-middle font-medium">{{ item.confidence }}%</td>
|
||||
</tr>
|
||||
<tr v-if="recentHistory.length === 0">
|
||||
<td colspan="3" class="p-4 text-center text-muted-foreground">Tidak ada data.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -30,6 +30,22 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="role">Role</Label>
|
||||
<Select v-model="form.role">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Pilih Role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-if="form.errors.role" class="mt-1 text-sm text-red-500">
|
||||
{{ form.errors.role }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="password">Password</Label>
|
||||
<div class="relative">
|
||||
|
|
@ -89,8 +105,7 @@ import { Button } from '@/components/ui/button';
|
|||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { MultipleSelect } from '@/components/ui/multiple-select';
|
||||
import { SelectSearch } from '@/components/ui/select-search';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Eye, EyeOff, UserPlus } from 'lucide-vue-next';
|
||||
import { computed, ref } from 'vue';
|
||||
|
|
@ -102,6 +117,7 @@ const showPasswordConfirmation = ref(false);
|
|||
const form = useForm({
|
||||
name: '',
|
||||
email: '',
|
||||
role: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,6 +30,22 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="role">Role</Label>
|
||||
<Select v-model="form.role">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Pilih Role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-if="form.errors.role" class="mt-1 text-sm text-red-500">
|
||||
{{ form.errors.role }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="password">Password</Label>
|
||||
<div class="relative">
|
||||
|
|
@ -89,8 +105,7 @@ import { Button } from '@/components/ui/button';
|
|||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { MultipleSelect } from '@/components/ui/multiple-select';
|
||||
import { SelectSearch } from '@/components/ui/select-search';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Edit, Eye, EyeOff, UserPlus } from 'lucide-vue-next';
|
||||
import { computed, ref,watch } from 'vue';
|
||||
|
|
@ -105,12 +120,14 @@ const props = defineProps<{
|
|||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const form = useForm({
|
||||
name: props.user.name || '',
|
||||
email: props.user.email || '',
|
||||
role: props.user.role || '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
|
@ -119,6 +136,7 @@ watch(open, (value) => {
|
|||
if (value) {
|
||||
form.name = props.user.name || '';
|
||||
form.email = props.user.email || '';
|
||||
form.role = props.user.role || '';
|
||||
form.password = '';
|
||||
form.password_confirmation = '';
|
||||
form.clearErrors();
|
||||
|
|
|
|||
|
|
@ -112,6 +112,12 @@ const changePage = (page: number) => {
|
|||
{{ sortOrder === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead class="cursor-pointer" @click="sortBy('role')">
|
||||
Role
|
||||
<span v-if="sortField === 'role'" class="ml-1">
|
||||
{{ sortOrder === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead class="text-center">Aksi</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -125,6 +131,11 @@ const changePage = (page: number) => {
|
|||
<div class="font-medium">{{ user.name }}</div>
|
||||
</TableCell>
|
||||
<TableCell>{{ user.email }}</TableCell>
|
||||
<TableCell>
|
||||
<div class="font-medium">
|
||||
{{ user.role === 'admin' ? 'Admin' : 'User' }}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<UserEdit :user="user" />
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<template>
|
||||
<div class="bg-background text-foreground min-h-screen">
|
||||
<Navbar />
|
||||
<Hero />
|
||||
<Logo />
|
||||
<Hero :total-classifications="totalClassifications" />
|
||||
<!-- <Logo /> -->
|
||||
<Scanner />
|
||||
<Keunggulan />
|
||||
<Teknologi />
|
||||
|
|
@ -15,10 +15,14 @@
|
|||
<script setup lang="ts">
|
||||
import Navbar from '@/components/public/Navbar.vue'
|
||||
import Hero from '@/components/public/Hero.vue'
|
||||
import Logo from '@/components/public/Logo.vue'
|
||||
// import Logo from '@/components/public/Logo.vue'
|
||||
import Keunggulan from '@/components/public/Keunggulan.vue'
|
||||
import Scanner from '@/components/public/Scanner.vue'
|
||||
import Teknologi from '@/components/public/Teknologi.vue'
|
||||
import FAQ from '@/components/public/FAQ.vue'
|
||||
import FinalCta from '@/components/public/FinalCta.vue'
|
||||
|
||||
defineProps<{
|
||||
totalClassifications?: number
|
||||
}>()
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -4,18 +4,37 @@
|
|||
use Laravel\Fortify\Features;
|
||||
use App\Http\Controllers\Admin\UserController;
|
||||
use App\Http\Controllers\Admin\DashboardController;
|
||||
use App\Http\Controllers\Public\ClassificationController;
|
||||
use App\Http\Controllers\Admin\ClassificationController as AdminClassificationController;
|
||||
use App\Models\Classification;
|
||||
|
||||
Route::redirect('dashboard', 'admin/dashboard')->name('dashboard');
|
||||
Route::get('dashboard', function () {
|
||||
if (auth()->user()->role === 'admin') {
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
return app(DashboardController::class)->index();
|
||||
})->middleware(['auth', 'verified'])->name('dashboard');
|
||||
|
||||
Route::get('/', function () {
|
||||
return inertia('public/Home', [
|
||||
'canRegister' => Features::enabled(Features::registration()),
|
||||
'totalClassifications' => Classification::count(),
|
||||
]);
|
||||
})->name('home');
|
||||
|
||||
Route::post('/predict', [ClassificationController::class, 'predict'])->name('public.predict');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('/classifications', [ClassificationController::class, 'index'])->name('classifications.index');
|
||||
Route::delete('/classifications/{classification}', [ClassificationController::class, 'destroy'])->name('classifications.destroy');
|
||||
});
|
||||
|
||||
|
||||
Route::prefix('admin')->name('admin.')->middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||
Route::resource('users', UserController::class)->names('users');
|
||||
Route::get('classifications', [AdminClassificationController::class, 'index'])->name('classifications.index');
|
||||
Route::delete('classifications/{classification}', [AdminClassificationController::class, 'destroy'])->name('classifications.destroy');
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue