Compare commits

...

10 Commits

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

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Classification;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ClassificationController extends Controller
{
public function index()
{
$classifications = Classification::with('user')
->latest()
->paginate(10);
return Inertia::render('admin/Classification', [
'classifications' => $classifications
]);
}
public function destroy(Classification $classification)
{
$classification->delete();
return redirect()->back()->with('success', 'History classification deleted successfully.');
}
}

View File

@ -5,14 +5,53 @@
use App\Http\Controllers\Controller; use 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,
]); ]);
} }
} }

View File

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

View File

@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\Classification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class ClassificationController extends Controller
{
public function index()
{
$classifications = Classification::where('user_id', auth()->id())->get();
return inertia('user/classification/ClassificationIndex', [
'classifications' => $classifications,
]);
}
public function predict(Request $request)
{
$request->validate([
'image' => 'required|image|max:2048',
]);
$image = $request->file('image');
try {
// 1. Kirim ke AI Server (Flask)
$response = Http::attach(
'image',
file_get_contents($image),
$image->getClientOriginalName()
)->post('http://127.0.0.1:5001/predict');
if ($response->failed()) {
return response()->json(['error' => 'Gagal terhubung ke AI server'], 500);
}
$data = $response->json();
// 2. SIMPAN KE DB (BAIK BERHASIL MAUPUN DITOLAK)
$path = $image->store('classifications', 'public');
$confidence = (float) filter_var($data['confidence'] ?? 0, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION);
// Tentukan status untuk Database
$dbStatus = ($data['status'] === 'BERHASIL') ? 'Berhasil' : 'Ditolak';
Classification::create([
'user_id' => auth()->id() ?? null,
'image_path' => $path,
'result' => $data['label'] ?? 'Unknown',
'confidence' => $confidence,
'status' => $dbStatus,
]);
// 3. KEMBALIKAN KE VUE (Bawa serta 'status' asli dari Flask)
return response()->json([
'label' => $data['label'] ?? 'Unknown',
'confidence' => $data['confidence'] ?? '0',
'message' => $data['pesan'] ?? 'Hasil klasifikasi selesai.',
'status' => $data['status'] // INI PENTING UNTUK SINKRONISASI
]);
} catch (\Exception $e) {
return response()->json(['error' => 'Terjadi kesalahan internal: ' . $e->getMessage()], 500);
}
}
public function destroy(Classification $classification)
{
if ($classification->user_id !== auth()->id()) {
abort(403);
}
// Hapus file dari storage jika ada
if ($classification->image_path && \Storage::disk('public')->exists($classification->image_path)) {
\Storage::disk('public')->delete($classification->image_path);
}
$classification->delete();
return back()->with('success', 'Klasifikasi berhasil dihapus');
}
}

View File

@ -39,12 +39,18 @@ public function share(Request $request): array
...parent::share($request), ...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'),
], ],
]; ];
} }

View File

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

View File

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

View File

@ -11,7 +11,7 @@
use Illuminate\Notifications\Notifiable; use 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@
use App\Models\User; use 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',
]); ]);
} }
} }

31
package-lock.json generated
View File

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

View File

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

BIN
public/pict_example.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

View File

@ -20,6 +20,8 @@ createInertiaApp({
return [AppLayout, SettingsLayout]; 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;
} }

View File

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

View File

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

View File

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

View File

@ -4,11 +4,14 @@
<h2 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 tracking-tight font-display"> <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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,196 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
Pagination,
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination';
import { Button } from '@/components/ui/button';
import { router } from '@inertiajs/vue3';
import { Trash2, Eye } from 'lucide-vue-next';
import { ref } from 'vue';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
defineOptions({
layout: {
breadcrumbs: [
{
title: 'History Klasifikasi',
href: route('admin.classifications.index'),
},
],
},
});
const props = defineProps({
classifications: Object,
});
const isViewDialogOpen = ref(false);
const selectedImage = ref('');
const viewImage = (path: string) => {
selectedImage.value = '/storage/' + path;
isViewDialogOpen.value = true;
};
const changePage = (page: number) => {
router.visit(route('admin.classifications.index', { page }), {
preserveScroll: true,
});
};
const deleteClassification = (id: number) => {
if (confirm('Apakah Anda yakin ingin menghapus history ini?')) {
router.delete(route('admin.classifications.destroy', id), {
preserveScroll: true,
});
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('id-ID', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getResultColor = (result: string) => {
switch (result?.toLowerCase()) {
case 'honey':
return 'bg-amber-100 text-amber-800 border-amber-200';
case 'washed':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'natural':
return 'bg-emerald-100 text-emerald-800 border-emerald-200';
default:
return 'bg-primary/10 text-primary border-primary/20';
}
};
</script>
<template>
<Head title="History Klasifikasi" />
<div class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight">History Klasifikasi</h1>
<p class="text-muted-foreground">Daftar semua hasil klasifikasi yang dilakukan oleh pengguna maupun tamu.</p>
</div>
</div>
<div class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Waktu</TableHead>
<TableHead>User Pembuat</TableHead>
<TableHead>Hasil</TableHead>
<TableHead>Confidence</TableHead>
<TableHead>Status</TableHead>
<TableHead class="text-center">Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="!props.classifications?.data?.length">
<TableCell colspan="6" class="h-24 text-center">Tidak ada data klasifikasi.</TableCell>
</TableRow>
<TableRow v-for="item in props.classifications?.data" :key="item.id" class="hover:bg-muted/50">
<TableCell>{{ formatDate(item.created_at) }}</TableCell>
<TableCell>
<div class="font-medium">
{{ item.user ? item.user.name : 'Guess' }}
</div>
<div v-if="item.user" class="text-xs text-muted-foreground">
{{ item.user.email }}
</div>
</TableCell>
<TableCell>
<span :class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium border',
getResultColor(item.result)
]">
{{ item.result }}
</span>
</TableCell>
<TableCell>{{ item.confidence }}%</TableCell>
<TableCell>
<span :class="[
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
item.status === 'Berhasil' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
]">
{{ item.status }}
</span>
</TableCell>
<TableCell>
<div class="flex items-center justify-center gap-2">
<Button variant="ghost" size="icon" @click="viewImage(item.image_path)" v-if="item.image_path" title="Lihat Foto">
<Eye class="h-4 w-4 text-blue-500" />
</Button>
<Button variant="ghost" size="icon" @click="deleteClassification(item.id)" title="Hapus">
<Trash2 class="h-4 w-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Pagination -->
<div v-if="classifications && classifications.last_page > 1" class="flex justify-center mt-4">
<Pagination v-slot="{ page }" :items-per-page="classifications.per_page" :total="classifications.total" :default-page="classifications.current_page">
<PaginationContent v-slot="{ items }" class="flex items-center gap-1">
<PaginationFirst @click="changePage(1)" />
<PaginationPrevious @click="changePage(classifications.current_page - 1)" />
<template v-for="(item, index) in items">
<PaginationItem v-if="item.type === 'page'" :key="index" :value="item.value" as-child>
<Button
class="h-9 w-9 p-0"
:variant="item.value === classifications.current_page ? 'default' : 'outline'"
@click="changePage(item.value)"
>
{{ item.value }}
</Button>
</PaginationItem>
<PaginationEllipsis v-else :key="item.type" :index="index" />
</template>
<PaginationNext @click="changePage(classifications.current_page + 1)" />
<PaginationLast @click="changePage(classifications.last_page)" />
</PaginationContent>
</Pagination>
</div>
<!-- View Photo Dialog -->
<Dialog :open="isViewDialogOpen" @update:open="isViewDialogOpen = $event">
<DialogContent class="max-w-3xl rounded-3xl overflow-hidden p-0">
<DialogHeader class="p-6 pb-0">
<DialogTitle>Detail Foto Klasifikasi</DialogTitle>
</DialogHeader>
<div class="p-6">
<div class="aspect-video rounded-2xl overflow-hidden bg-muted flex items-center justify-center border">
<img :src="selectedImage" class="w-full h-full object-contain" alt="Classification result" loading="lazy" />
</div>
</div>
</DialogContent>
</Dialog>
</div>
</template>

View File

@ -1,10 +1,15 @@
<script setup lang="ts"> <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>

View File

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

View File

@ -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();

View File

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

View File

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

View File

@ -0,0 +1,241 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { Button } from '@/components/ui/button';
import { Trash2, Plus, History, ScanSearch, Calendar, AlertCircle } from 'lucide-vue-next';
import Scanner from '@/components/public/Scanner.vue';
import { ref } from 'vue';
import { route } from 'ziggy-js';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
defineOptions({
layout: {
breadcrumbs: [
{
title: 'Klasifikasi',
href: route('classifications.index'),
},
],
},
});
const props = defineProps<{
classifications: any[];
}>();
const showScanner = ref(false);
const isDeleteDialogOpen = ref(false);
const itemToDelete = ref<number | null>(null);
const isDeleting = ref(false);
const confirmDelete = (id: number) => {
itemToDelete.value = id;
isDeleteDialogOpen.value = true;
};
const deleteClassification = () => {
if (!itemToDelete.value) return;
isDeleting.value = true;
router.delete(route('classifications.destroy', itemToDelete.value), {
preserveScroll: true,
onSuccess: () => {
isDeleteDialogOpen.value = false;
itemToDelete.value = null;
},
onFinish: () => {
isDeleting.value = false;
}
});
};
const toggleScanner = () => {
showScanner.value = !showScanner.value;
if (!showScanner.value) {
// Refresh data saat menutup scanner jika ada data baru
router.reload({ only: ['classifications'] });
}
};
const getResultColorText = (result: string) => {
switch (result?.toLowerCase()) {
case 'honey':
return 'text-amber-600 dark:text-amber-500';
case 'washed':
return 'text-blue-600 dark:text-blue-500';
case 'natural':
return 'text-emerald-600 dark:text-emerald-500';
default:
return 'text-emerald-600 dark:text-emerald-500';
}
};
const getResultColorBar = (result: string) => {
switch (result?.toLowerCase()) {
case 'honey':
return 'bg-amber-500';
case 'washed':
return 'bg-blue-500';
case 'natural':
return 'bg-emerald-500';
default:
return 'bg-emerald-500';
}
};
</script>
<template>
<Head title="Klasifikasi Kopi" />
<div class="flex h-full flex-1 flex-col gap-8 rounded-xl p-4 md:p-8 overflow-x-hidden">
<!-- Header Section -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 class="text-3xl font-bold tracking-tight flex items-center gap-3">
<History class="w-8 h-8 text-emerald-500" />
Riwayat Klasifikasi
</h1>
<p class="text-muted-foreground mt-1">Kelola dan lihat hasil analisis biji kopi Anda.</p>
</div>
<Button
@click="toggleScanner"
:variant="showScanner ? 'outline' : 'default'"
class="w-full sm:w-auto h-12 px-6 rounded-xl font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-95"
>
<template v-if="!showScanner">
<Plus class="mr-2 h-5 w-5" />
Klasifikasi Baru
</template>
<template v-else>
Tutup Scanner
</template>
</Button>
</div>
<!-- Scanner Section -->
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform -translate-y-4 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="transform translate-y-0 opacity-100"
leave-to-class="transform -translate-y-4 opacity-0"
>
<div v-if="showScanner" class="mb-12 rounded-3xl overflow-hidden shadow-2xl border border-emerald-500/20">
<Scanner />
</div>
</transition>
<!-- History Grid -->
<div v-if="classifications.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pb-12">
<div
v-for="item in classifications"
:key="item.id"
class="group relative bg-white dark:bg-zinc-900 rounded-[2rem] border border-zinc-200 dark:border-zinc-800 p-4 flex gap-5 items-center shadow-sm hover:shadow-xl hover:border-emerald-500/30 transition-all duration-300"
>
<!-- Small Photo Thumbnail -->
<div class="w-24 h-24 sm:w-32 sm:h-32 rounded-2xl overflow-hidden bg-zinc-100 dark:bg-zinc-950 flex-shrink-0 relative">
<img
:src="'/storage/' + item.image_path"
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
loading="lazy"
/>
</div>
<!-- Info Section -->
<div class="flex-1 min-w-0 flex flex-col justify-between py-1 pr-6">
<div class="flex justify-between items-start mb-2">
<div class="space-y-0.5">
<div class="flex items-center gap-2 text-[9px] font-bold text-zinc-400 uppercase tracking-widest mb-1">
<Calendar class="w-3 h-3" />
{{ new Date(item.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'short' }) }}
</div>
<h3 class="text-xl font-black italic truncate leading-none" :class="getResultColorText(item.result)">{{ item.result }}</h3>
<div
class="mt-2 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider border"
:class="(item.status === 'Berhasil' || (!item.status && Number(item.confidence) >= 90))
? 'bg-emerald-50 text-emerald-600 border-emerald-200 dark:bg-emerald-950/30 dark:text-emerald-400 dark:border-emerald-800'
: 'bg-red-50 text-red-600 border-red-200 dark:bg-red-950/30 dark:text-red-400 dark:border-red-800'"
>
<div :class="['w-1.5 h-1.5 rounded-full mr-1.5', (item.status === 'Berhasil' || (!item.status && Number(item.confidence) >= 90)) ? 'bg-emerald-500' : 'bg-red-500']"></div>
{{ item.status || (Number(item.confidence) >= 90 ? 'Berhasil' : 'Gagal') }}
</div>
</div>
<div class="text-right">
<p class="text-[9px] font-bold text-zinc-400 uppercase tracking-widest">Confidence</p>
<p class="text-lg font-bold text-zinc-900 dark:text-zinc-100 leading-none mt-1">{{ Number(item.confidence).toFixed(2) }}%</p>
</div>
</div>
<div class="space-y-2">
<div class="h-1.5 w-full bg-zinc-100 dark:bg-zinc-800 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-1000"
:class="getResultColorBar(item.result)"
:style="{ width: item.confidence + '%' }"
></div>
</div>
</div>
</div>
<!-- Tombol Hapus Pojok Kanan Bawah -->
<Button
variant="ghost"
size="icon"
@click="confirmDelete(item.id)"
class="absolute bottom-3 right-3 h-8 w-8 text-zinc-400 hover:text-red-500 hover:bg-red-50/50 dark:hover:bg-red-950/20 transition-colors"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</div>
<!-- Empty State -->
<div v-else-if="!showScanner" class="text-center py-32 bg-white dark:bg-zinc-900 rounded-[2.5rem] border-2 border-dashed border-zinc-200 dark:border-zinc-800 shadow-sm mb-12">
<div class="inline-flex p-6 rounded-3xl bg-zinc-50 dark:bg-zinc-950 mb-6">
<ScanSearch class="w-12 h-12 text-zinc-400" />
</div>
<h3 class="text-xl font-bold text-zinc-900 dark:text-white mb-2">Belum ada riwayat</h3>
<p class="text-zinc-500 dark:text-zinc-400 max-w-xs mx-auto mb-8">Anda belum pernah melakukan klasifikasi biji kopi. Mulai sekarang untuk melihat hasilnya di sini.</p>
<Button @click="showScanner = true" class="h-12 px-8 rounded-xl font-bold">
Mulai Klasifikasi Pertama
</Button>
</div>
<!-- Delete Confirmation Dialog -->
<Dialog :open="isDeleteDialogOpen" @update:open="isDeleteDialogOpen = $event">
<DialogContent class="sm:max-w-[425px] rounded-3xl">
<DialogHeader>
<div class="mx-auto w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-4">
<AlertCircle class="w-6 h-6 text-red-600 dark:text-red-400" />
</div>
<DialogTitle class="text-center text-xl font-bold">Hapus Riwayat?</DialogTitle>
<DialogDescription class="text-center">
Tindakan ini tidak dapat dibatalkan. Riwayat klasifikasi dan foto akan dihapus secara permanen dari server kami.
</DialogDescription>
</DialogHeader>
<DialogFooter class="flex flex-col sm:flex-row gap-2 mt-4">
<Button variant="outline" @click="isDeleteDialogOpen = false" class="rounded-xl flex-1 h-12 font-bold">
Batal
</Button>
<Button
variant="destructive"
@click="deleteClassification"
:disabled="isDeleting"
class="rounded-xl flex-1 h-12 font-bold"
>
{{ isDeleting ? 'Menghapus...' : 'Ya, Hapus' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>

View File

@ -3,6 +3,7 @@
<head> <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>

View File

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