From 5697938fa44f99879c5799e96cb9bd065dc39b7d Mon Sep 17 00:00:00 2001 From: ramzdhani11 <158116439+ramzdhani11@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:56:36 +0700 Subject: [PATCH] Perbaikan dashboard admin dan fitur statistik sistem --- app/Http/Controllers/AdminController.php | 129 +++--- .../Controllers/AdminDashboardController.php | 48 ++ .../ClassificationHistoryController.php | 32 ++ app/Http/Controllers/StatistikController.php | 180 ++++++++ app/Http/Controllers/TomatController.php | 51 ++- app/Http/Controllers/UploadController.php | 260 +++++++---- app/Models/Predictions.php | 27 ++ app/Models/Upload.php | 28 ++ composer.json | 4 +- composer.lock | 86 +++- config/app.php | 2 +- .../2025_02_05_cleanup_users_admin_only.php | 2 +- ...2025_11_23_060921_create_uploads_table.php | 2 + notifikasi_setup.md | 247 ++++++++++ .../Admin/classification-history.blade.php | 269 +++++------ resources/views/Admin/index.blade.php | 300 ++++++------ resources/views/Admin/layouts/app.blade.php | 11 +- resources/views/Admin/manage-admin.blade.php | 3 +- .../views/Admin/partials/sidebar.blade.php | 9 - .../views/Admin/system-statistics.blade.php | 427 ++++++++++-------- .../tomat/classification_result.blade.php | 26 +- routes/web.php | 38 +- 22 files changed, 1443 insertions(+), 738 deletions(-) create mode 100644 app/Http/Controllers/AdminDashboardController.php create mode 100644 app/Http/Controllers/ClassificationHistoryController.php create mode 100644 app/Http/Controllers/StatistikController.php create mode 100644 app/Models/Predictions.php create mode 100644 app/Models/Upload.php create mode 100644 notifikasi_setup.md diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index a08538a..cf5fe9c 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -9,28 +9,32 @@ class AdminController extends Controller { - public function index() + private function checkAuth() { if (!session('admin_logged_in')) { - return redirect()->route('admin.login')->with('error', 'Silakan login terlebih dahulu.'); + abort(403, 'Unauthorized'); } - - // Ambil semua admin dengan role 'admin' - $admins = User::where('role', 'admin')->orderBy('created_at', 'desc')->get(); - + } + + public function index() + { + $this->checkAuth(); + + // Ambil hanya admin + $admins = User::where('role', 'admin') + ->orderBy('created_at', 'desc') + ->get(); + return view('Admin.manage-admin', compact('admins')); } public function store(Request $request) { - if (!session('admin_logged_in')) { - return response()->json(['error' => 'Unauthorized'], 401); - } + $this->checkAuth(); - // Validate input - role tidak termasuk, otomatis 'admin' $validator = Validator::make($request->all(), [ 'name' => 'required|string|max:255', - 'email' => 'required|string|email|max:255|unique:users,email', + 'email' => 'required|email|max:255|unique:users,email', 'password' => 'required|string|min:8', ]); @@ -38,110 +42,109 @@ public function store(Request $request) return response()->json(['errors' => $validator->errors()], 422); } - // Create admin dengan role selalu 'admin', tidak dari input $admin = User::create([ 'name' => $request->name, 'email' => $request->email, 'password' => Hash::make($request->password), - 'role' => 'admin', // Role SELALU 'admin' + 'role' => 'admin', 'email_verified_at' => now() ]); - return response()->json(['success' => 'Admin berhasil ditambahkan', 'admin' => $admin]); + return response()->json([ + 'success' => 'Admin berhasil ditambahkan', + 'admin' => $admin + ]); } public function edit($id) { - if (!session('admin_logged_in')) { - return response()->json(['error' => 'Unauthorized'], 401); - } + $this->checkAuth(); + + $admin = User::where('id', $id) + ->where('role', 'admin') + ->firstOrFail(); - $admin = User::findOrFail($id); return response()->json($admin); } public function update(Request $request, $id) { - if (!session('admin_logged_in')) { - return response()->json(['error' => 'Unauthorized'], 401); - } + $this->checkAuth(); - $admin = User::findOrFail($id); - - // Pastikan hanya admin yang dapat diupdate - if ($admin->role !== 'admin') { - return response()->json(['error' => 'Hanya admin yang dapat dikelola'], 422); - } + $admin = User::where('id', $id) + ->where('role', 'admin') + ->firstOrFail(); - // Validate input - role tidak termasuk, tidak boleh diubah $validator = Validator::make($request->all(), [ 'name' => 'required|string|max:255', - 'email' => 'required|string|email|max:255|unique:users,email,'.$id, + 'email' => 'required|email|max:255|unique:users,email,' . $id, ]); if ($validator->fails()) { return response()->json(['errors' => $validator->errors()], 422); } - // Update hanya name dan email, role TIDAK boleh diubah $admin->update([ 'name' => $request->name, 'email' => $request->email, - // Role selalu 'admin', tidak dapat diubah ]); if ($request->filled('password')) { - $admin->update(['password' => Hash::make($request->password)]); + $admin->update([ + 'password' => Hash::make($request->password) + ]); } - return response()->json(['success' => 'Admin berhasil diperbarui', 'admin' => $admin]); + return response()->json([ + 'success' => 'Admin berhasil diperbarui', + 'admin' => $admin + ]); } public function destroy($id) { - if (!session('admin_logged_in')) { - return response()->json(['error' => 'Unauthorized'], 401); - } + $this->checkAuth(); + + $admin = User::where('id', $id) + ->where('role', 'admin') + ->firstOrFail(); - $admin = User::findOrFail($id); - - // Pastikan hanya admin yang dihapus - if ($admin->role !== 'admin') { - return response()->json(['error' => 'Hanya admin yang dapat dihapus'], 422); - } - - // Prevent deleting the currently logged in admin if ($admin->id == session('admin_user_id')) { - return response()->json(['error' => 'Tidak dapat menghapus akun yang sedang digunakan'], 422); + return response()->json([ + 'error' => 'Tidak dapat menghapus akun yang sedang digunakan' + ], 422); } $admin->delete(); - return response()->json(['success' => 'Admin berhasil dihapus']); + return response()->json([ + 'success' => 'Admin berhasil dihapus' + ]); } public function toggleStatus($id) { - if (!session('admin_logged_in')) { - return response()->json(['error' => 'Unauthorized'], 401); - } + $this->checkAuth(); + + $admin = User::where('id', $id) + ->where('role', 'admin') + ->firstOrFail(); - $admin = User::findOrFail($id); - - // Prevent deactivating the currently logged in admin if ($admin->id == session('admin_user_id')) { - return response()->json(['error' => 'Tidak dapat menonaktifkan akun yang sedang digunakan'], 422); + return response()->json([ + 'error' => 'Tidak dapat menonaktifkan akun yang sedang digunakan' + ], 422); } - // For simplicity, we'll use email_verified_at as status indicator - if ($admin->email_verified_at) { - $admin->update(['email_verified_at' => null]); - $status = 'inactive'; - } else { - $admin->update(['email_verified_at' => now()]); - $status = 'active'; - } + $status = $admin->email_verified_at ? null : now(); - return response()->json(['success' => "Status admin berhasil diubah menjadi $status", 'status' => $status]); + $admin->update([ + 'email_verified_at' => $status + ]); + + return response()->json([ + 'success' => 'Status berhasil diubah', + 'status' => $status ? 'active' : 'inactive' + ]); } -} +} \ No newline at end of file diff --git a/app/Http/Controllers/AdminDashboardController.php b/app/Http/Controllers/AdminDashboardController.php new file mode 100644 index 0000000..64ef242 --- /dev/null +++ b/app/Http/Controllers/AdminDashboardController.php @@ -0,0 +1,48 @@ +route('admin.login')->with('error', 'Silakan login terlebih dahulu.'); + } + + $modelAccuracy = 92.62; // ✅ hardcode akurasi model + + $total = Upload::count(); + $today = Upload::whereDate('created_at', today())->count(); + + $trend = Upload::select( + DB::raw('DATE(created_at) as date'), + DB::raw('count(*) as total') + ) + ->where('created_at', '>=', now()->subDays(6)) + ->groupBy('date') + ->orderBy('date') + ->get(); + + $distribution = Upload::select('category', DB::raw('count(*) as total')) + ->groupBy('category') + ->get(); + + $totalAdmin = User::where('role', 'admin')->count(); + + return view('Admin.index', compact( + 'total', + 'today', + 'trend', + 'distribution', + 'totalAdmin', + 'modelAccuracy' + )); +} +} \ No newline at end of file diff --git a/app/Http/Controllers/ClassificationHistoryController.php b/app/Http/Controllers/ClassificationHistoryController.php new file mode 100644 index 0000000..a177673 --- /dev/null +++ b/app/Http/Controllers/ClassificationHistoryController.php @@ -0,0 +1,32 @@ +route('admin.login'); + } + + $query = Upload::latest(); + + // Filter by category + if ($request->filter && $request->filter !== 'all') { + $query->where('category', $request->filter); + } + + // Search + if ($request->search) { + $query->where('category', 'like', '%' . $request->search . '%'); + } + + $uploads = $query->paginate(10); + + return view('Admin.classification-history', compact('uploads')); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/StatistikController.php b/app/Http/Controllers/StatistikController.php new file mode 100644 index 0000000..b044d34 --- /dev/null +++ b/app/Http/Controllers/StatistikController.php @@ -0,0 +1,180 @@ +count(); + + // Jumlah admin + $penggunaAktifAdmin = User::where('role', 'admin')->count(); + + // Akurasi model + $akurasiRataRata = 92.62; + + /* + |-------------------------------------------------------------------------- + | PERSENTASE KENAIKAN + |-------------------------------------------------------------------------- + */ + + $kemarin = Upload::whereDate( + 'created_at', + Carbon::yesterday() + )->count(); + + $pctHariIni = $kemarin > 0 + ? round((($klasifikasiHariIni - $kemarin) / $kemarin) * 100, 1) + : 0; + + $bulanIni = Upload::whereMonth('created_at', now()->month) + ->whereYear('created_at', now()->year) + ->count(); + + $bulanLalu = Upload::whereMonth( + 'created_at', + now()->subMonth()->month + ) + ->whereYear( + 'created_at', + now()->subMonth()->year + ) + ->count(); + + $pctTotal = $bulanLalu > 0 + ? round((($bulanIni - $bulanLalu) / $bulanLalu) * 100, 1) + : 0; + + /* + |-------------------------------------------------------------------------- + | GRAFIK BAR 7 HARI + |-------------------------------------------------------------------------- + */ + + $hariLabels = []; + $hariData = []; + + $namaHari = [ + 'Min', 'Sen', 'Sel', + 'Rab', 'Kam', 'Jum', 'Sab' + ]; + + for ($i = 6; $i >= 0; $i--) { + + $tgl = Carbon::today()->subDays($i); + + $hariLabels[] = $namaHari[$tgl->dayOfWeek]; + + $hariData[] = Upload::whereDate( + 'created_at', + $tgl + )->count(); + } + + /* + |-------------------------------------------------------------------------- + | GRAFIK LINE 12 BULAN + |-------------------------------------------------------------------------- + */ + + $bulanLabels = []; + $bulanData = []; + + $namaBulan = [ + 'Jan', 'Feb', 'Mar', 'Apr', + 'Mei', 'Jun', 'Jul', 'Agu', + 'Sep', 'Okt', 'Nov', 'Des' + ]; + + for ($i = 11; $i >= 0; $i--) { + + $tgl = Carbon::now() + ->startOfMonth() + ->subMonths($i); + + $bulanLabels[] = $namaBulan[$tgl->month - 1]; + + $bulanData[] = Upload::whereMonth( + 'created_at', + $tgl->month + ) + ->whereYear( + 'created_at', + $tgl->year + ) + ->count(); + } + + /* + |-------------------------------------------------------------------------- + | PIE CHART DATASET TRAINING + |-------------------------------------------------------------------------- + */ + + $datasetTomat = [ + 'mentah' => 232, + 'setengah_matang' => 402, + 'matang' => 341, + ]; + + $totalDataset = array_sum($datasetTomat); + + $distribusiData = []; + + foreach ($datasetTomat as $label => $jumlah) { + + $distribusiData[] = [ + 'label' => ucfirst(str_replace('_', ' ', $label)), + 'jumlah' => $jumlah, + 'persentase' => round( + ($jumlah / $totalDataset) * 100, + 1 + ), + ]; + } + + /* + |-------------------------------------------------------------------------- + | RETURN VIEW + |-------------------------------------------------------------------------- + */ + + return view('Admin.system-statistics', compact( + 'totalKlasifikasi', + 'klasifikasiHariIni', + 'akurasiRataRata', + 'penggunaAktifAdmin', + 'pctHariIni', + 'pctTotal', + 'hariLabels', + 'hariData', + 'bulanLabels', + 'bulanData', + 'distribusiData' + )); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/TomatController.php b/app/Http/Controllers/TomatController.php index e1cb3d2..f8e8dcc 100644 --- a/app/Http/Controllers/TomatController.php +++ b/app/Http/Controllers/TomatController.php @@ -4,22 +4,20 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; +use App\Models\Upload; +use App\Models\Predictions; +use Intervention\Image\Facades\Image; // ✅ tambah ini class TomatController extends Controller { protected $apiUrl = 'http://127.0.0.1:5000/predict'; - /** - * Show upload form - */ public function index() { return view('tomat.upload'); } - /** - * Handle image upload dan kirim ke Flask API - */ public function classify(Request $request) { $request->validate([ @@ -29,8 +27,7 @@ public function classify(Request $request) try { $uploadedFile = $request->file('image'); - // Kirim file langsung ke Flask tanpa resize ulang di sini - // Flask sudah handle resize 256x256 sendiri + // Kirim ke Flask (file original) $response = Http::timeout(30) ->attach( 'image', @@ -50,22 +47,58 @@ public function classify(Request $request) return back()->with('error', 'Error dari API: ' . $errorMsg); } + // ✅ Kompress dan simpan gambar + $filename = time() . '_' . uniqid() . '.jpg'; + $savePath = storage_path('app/public/uploads/' . $filename); + + // Buat folder jika belum ada + if (!file_exists(storage_path('app/public/uploads'))) { + mkdir(storage_path('app/public/uploads'), 0755, true); + } + + // Kompress gambar: resize max 800px, quality 75% + Image::make($uploadedFile->getRealPath()) + ->resize(400, null, function ($constraint) { + $constraint->aspectRatio(); + $constraint->upsize(); + }) + ->save($savePath, 60); + + $imagePath = 'uploads/' . $filename; + + // ✅ Simpan ke tabel uploads + $upload = Upload::create([ + 'image_path' => $imagePath, + 'category' => $result['prediction']['class'], + 'confidence' => $result['prediction']['confidence_percentage'], + ]); + + // ✅ Simpan ke tabel predictions + Predictions::create([ + 'upload_id' => $upload->id, + 'predicted_label' => $result['prediction']['class'], + 'probability' => $result['prediction']['confidence'], + ]); + // Simpan hasil ke session session([ 'prediction_result' => $result, 'processed_at' => now(), 'original_size' => $uploadedFile->getSize(), + 'compressed_path' => $imagePath, ]); return redirect()->route('tomat.result'); } catch (\Illuminate\Http\Client\ConnectionException $e) { - return back()->with('error', 'Tidak dapat terhubung ke API Flask. Pastikan server Python sudah berjalan (python app.py).'); + return back()->with('error', 'Tidak dapat terhubung ke API Flask.'); } catch (\Exception $e) { return back()->with('error', 'Terjadi kesalahan: ' . $e->getMessage()); } } + + // ... sisa method tidak berubah /** * Show prediction result */ diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index 5631d23..cdfb013 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -5,10 +5,15 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; +use App\Models\Upload; +use Intervention\Image\Facades\Image; class UploadController extends Controller { + protected $flaskApiUrl = 'http://127.0.0.1:5000/predict'; + /** * Display the upload page. * @@ -20,7 +25,7 @@ public function index() } /** - * Handle the file upload. + * Handle the file upload and send to Flask API. * * @param \Illuminate\Http\Request $request * @return \Illuminate\Http\RedirectResponse @@ -54,134 +59,190 @@ public function store(Request $request) try { $file = $request->file('tomato_image'); - // Generate unique filename $filename = Str::uuid() . '.' . $file->getClientOriginalExtension(); - - // Create directory if it doesn't exist $uploadPath = 'uploads/tomatoes'; + if (!Storage::disk('public')->exists($uploadPath)) { Storage::disk('public')->makeDirectory($uploadPath); } - // Store the file - $path = $file->storeAs($uploadPath, $filename, 'public'); + // ✅ Ganti dengan ini (kompress sebelum simpan) +$savePath = storage_path('app/public/' . $uploadPath . '/' . $filename); + +// Buat folder jika belum ada +if (!file_exists(storage_path('app/public/' . $uploadPath))) { + mkdir(storage_path('app/public/' . $uploadPath), 0755, true); +} + +// Kompress: resize max 800px, quality 75% +Image::make($file->getRealPath()) + ->resize(400, null, function ($constraint) { + $constraint->aspectRatio(); + $constraint->upsize(); + }) + ->save($savePath, 60); + +$imagePath = $uploadPath . '/' . $filename; - // Simulate AI classification (replace with actual AI logic) - $classification = $this->classifyTomato($path); + $apiResponse = $this->sendToFlaskAPI($file); - // Redirect to result page with classification data - return redirect()->route('upload.result', [ - 'image' => $path, - 'category' => $classification['category'], - 'probability' => $classification['probability'] + if (!$apiResponse['success']) { + return redirect() + ->route('upload.index') + ->withErrors(['error' => $apiResponse['error']]) + ->withInput(); + } + + $upload = Upload::create([ + 'image_path' => $imagePath, + 'category' => $apiResponse['category'], + 'confidence' => $apiResponse['confidence'], ]); + + return redirect()->route('upload.result', ['id' => $upload->id]) + ->with('success', 'Klasifikasi berhasil diproses.'); } catch (\Exception $e) { return redirect() ->route('upload.index') - ->withErrors(['upload' => 'Terjadi kesalahan saat mengunggah file: ' . $e->getMessage()]) + ->withErrors(['error' => 'Terjadi kesalahan saat mengunggah file: ' . $e->getMessage()]) ->withInput(); } } /** - * Display the classification result. + * Send image to Flask API for classification with retry mechanism. + * + * @param \Illuminate\Http\UploadedFile $file + * @return array + */ + private function sendToFlaskAPI($file) + { + $maxRetries = 2; + $retryDelay = 1000; // milliseconds + + for ($attempt = 1; $attempt <= $maxRetries + 1; $attempt++) { + try { + $response = Http::timeout(30) + ->attach( + 'image', + file_get_contents($file->getRealPath()), + $file->getClientOriginalName() + ) + ->post($this->flaskApiUrl); + + if ($response->failed()) { + if ($attempt < $maxRetries + 1) { + usleep($retryDelay * 1000); + continue; + } + return [ + 'success' => false, + 'error' => 'Gagal menghubungi API klasifikasi. Status: ' . $response->status() + ]; + } + + $result = $response->json(); + + // Validate API response structure + if (!isset($result['success'])) { + return [ + 'success' => false, + 'error' => 'Format response API tidak valid' + ]; + } + + if (!$result['success']) { + $errorMsg = $result['message'] ?? 'Prediksi gagal'; + return [ + 'success' => false, + 'error' => 'Error dari API: ' . $errorMsg + ]; + } + + // Validate prediction structure + if (!isset($result['prediction']['class']) || !isset($result['prediction']['confidence_percentage'])) { + return [ + 'success' => false, + 'error' => 'Format response API tidak valid' + ]; + } + + // Extract and validate prediction data + $category = $result['prediction']['class']; + $confidence = round((float)$result['prediction']['confidence_percentage'], 2); + + // Validate category + if (!in_array($category, ['matang', 'mentah', 'setengah_matang'])) { + return [ + 'success' => false, + 'error' => 'Kategori prediksi tidak valid' + ]; + } + + return [ + 'success' => true, + 'category' => $category, + 'confidence' => $confidence + ]; + + } catch (\Illuminate\Http\Client\ConnectionException $e) { + if ($attempt < $maxRetries + 1) { + usleep($retryDelay * 1000); + continue; + } + return [ + 'success' => false, + 'error' => 'Tidak dapat terhubung ke API Flask. Pastikan server Python sudah berjalan.' + ]; + } catch (\Exception $e) { + if ($attempt < $maxRetries + 1) { + usleep($retryDelay * 1000); + continue; + } + return [ + 'success' => false, + 'error' => 'Terjadi kesalahan saat memproses prediksi.' + ]; + } + } + + return [ + 'success' => false, + 'error' => 'Gagal memproses prediksi setelah beberapa percobaan.' + ]; + } + + /** + * Display the classification result from database. * * @param \Illuminate\Http\Request $request * @return \Illuminate\View\View */ public function result(Request $request) { - // Get parameters from query string - $imagePath = $request->get('image'); - $category = $request->get('category', 'matang'); - $probability = (int) $request->get('probability', 85); + $uploadId = $request->route('id') ?? $request->query('id'); - // Validate inputs - if (!$imagePath) { - return redirect()->route('upload.index')->withErrors(['error' => 'Tidak ada gambar yang dianalisis.']); + if (!$uploadId) { + return redirect()->route('upload.index') + ->withErrors(['error' => 'ID upload tidak valid.']); } - // Get color classes based on category - $colors = $this->getCategoryColors($category); - - // Get description based on category - $description = $this->getCategoryDescription($category, $probability); + $upload = Upload::find($uploadId); + + if (!$upload) { + return redirect()->route('upload.index') + ->withErrors(['error' => 'Data klasifikasi tidak ditemukan.']); + } return view('result', [ - 'imagePath' => $imagePath, - 'category' => $category, - 'probability' => $probability, - 'maturityColor' => $colors['badge'], - 'progressColor' => $colors['progress'], - 'description' => $description + 'imagePath' => $upload->image_path, + 'category' => $upload->category, + 'confidence' => $upload->confidence, + 'processedAt' => $upload->created_at ]); } - /** - * Simulate tomato classification (replace with actual AI implementation). - * - * @param string $imagePath - * @return array - */ - private function classifyTomato($imagePath) - { - // Simulate AI classification with random results - // In real implementation, this would call your AI model - $categories = ['mentah', 'setengah matang', 'matang']; - $category = $categories[array_rand($categories)]; - $probability = rand(75, 98); // Random probability between 75-98% - - return [ - 'category' => $category, - 'probability' => $probability - ]; - } - - /** - * Get color classes based on maturity category. - * - * @param string $category - * @return array - */ - private function getCategoryColors($category) - { - $colors = [ - 'mentah' => [ - 'badge' => 'bg-green-500', - 'progress' => 'bg-green-500' - ], - 'setengah matang' => [ - 'badge' => 'bg-yellow-500', - 'progress' => 'bg-yellow-500' - ], - 'matang' => [ - 'badge' => 'bg-red-500', - 'progress' => 'bg-red-500' - ] - ]; - - return $colors[$category] ?? $colors['matang']; - } - - /** - * Get description based on maturity category and probability. - * - * @param string $category - * @param int $probability - * @return string - */ - private function getCategoryDescription($category, $probability) - { - $descriptions = [ - 'mentah' => "Tomat ini masih dalam tahap pertumbuhan awal dengan tingkat kematangan {$probability}%. Warna hijau menunjukkan bahwa tomat belum matang sempurna dan teksturnya masih keras. Direkomendasikan untuk menunggu beberapa hari agar tomat mencapai kematangan optimal.", - 'setengah matang' => "Tomat ini dalam proses pematangan dengan tingkat kematangan {$probability}%. Perpaduan warna hijau dan merah menunjukkan tomat sedang transisi. Tekstur mulai melunak dan rasa mulai terasa. Ideal untuk penggunaan dalam salad atau masakan yang membutuhkan tomat sedikit masak.", - 'matang' => "Tomat ini telah mencapai kematangan optimal dengan tingkat keyakinan {$probability}%. Warna merah cerah dan tekstur lembut menunjukkan tomat siap dikonsumsi. Kandungan nutrisi dan rasa manis alami telah mencapai puncaknya. Sempurna untuk dimakan langsung atau diolah dalam berbagai masakan." - ]; - - return $descriptions[$category] ?? $descriptions['matang']; - } - /** * Handle admin login. * @@ -215,9 +276,8 @@ public function adminLogin(Request $request) // Query hanya admin dengan role 'admin' $user = \DB::table('users') - ->where('email', $email) - ->where('role', 'admin') // Hanya admin yang dapat login - ->first(); + ->where('email', $email) + ->first(); // Verifikasi password dan pastikan role adalah 'admin' if ($user && \Hash::check($password, $user->password) && $user->role === 'admin') { diff --git a/app/Models/Predictions.php b/app/Models/Predictions.php new file mode 100644 index 0000000..d20a208 --- /dev/null +++ b/app/Models/Predictions.php @@ -0,0 +1,27 @@ + 'float', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function upload() + { + return $this->belongsTo(Upload::class, 'upload_id'); + } +} \ No newline at end of file diff --git a/app/Models/Upload.php b/app/Models/Upload.php new file mode 100644 index 0000000..0562c58 --- /dev/null +++ b/app/Models/Upload.php @@ -0,0 +1,28 @@ + 'float', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + // Tambahkan ini + public function prediction() + { + return $this->hasOne(Prediction::class, 'upload_id'); + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index 99639df..17b99d6 100644 --- a/composer.json +++ b/composer.json @@ -7,9 +7,9 @@ "license": "MIT", "require": { "php": "^8.2", + "intervention/image": "2.7", "laravel/framework": "^12.0", - "laravel/tinker": "^2.10.1", - "intervention/image": "^2.7" + "laravel/tinker": "^2.10.1" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 6efa171..2cca1d4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c514d8f7b9fc5970bdd94287905ef584", + "content-hash": "1c02b53f5f6daedbd6f7bb6a63f7c353", "packages": [ { "name": "brick/math", @@ -1052,6 +1052,90 @@ ], "time": "2025-08-22T14:27:06+00:00" }, + { + "name": "intervention/image", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image.git", + "reference": "9a8cc99d30415ec0b3f7649e1647d03a55698545" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image/zipball/9a8cc99d30415ec0b3f7649e1647d03a55698545", + "reference": "9a8cc99d30415ec0b3f7649e1647d03a55698545", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "guzzlehttp/psr7": "~1.1 || ^2.0", + "php": ">=5.4.0" + }, + "require-dev": { + "mockery/mockery": "~0.9.2", + "phpunit/phpunit": "^4.8 || ^5.7 || ^7.5.15" + }, + "suggest": { + "ext-gd": "to use GD library based image processing.", + "ext-imagick": "to use Imagick based image processing.", + "intervention/imagecache": "Caching extension for the Intervention Image library" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Image": "Intervention\\Image\\Facades\\Image" + }, + "providers": [ + "Intervention\\Image\\ImageServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "2.4-dev" + } + }, + "autoload": { + "psr-4": { + "Intervention\\Image\\": "src/Intervention/Image" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@olivervogel.com", + "homepage": "http://olivervogel.com/" + } + ], + "description": "Image handling and manipulation library with support for Laravel integration", + "homepage": "http://image.intervention.io/", + "keywords": [ + "gd", + "image", + "imagick", + "laravel", + "thumbnail", + "watermark" + ], + "support": { + "issues": "https://github.com/Intervention/image/issues", + "source": "https://github.com/Intervention/image/tree/2.7.0" + }, + "funding": [ + { + "url": "https://www.paypal.me/interventionphp", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + } + ], + "time": "2021-10-03T14:17:12+00:00" + }, { "name": "laravel/framework", "version": "v12.39.0", diff --git a/config/app.php b/config/app.php index 423eed5..c74a68d 100644 --- a/config/app.php +++ b/config/app.php @@ -65,7 +65,7 @@ | */ - 'timezone' => 'UTC', + 'timezone' => 'Asia/Jakarta', /* |-------------------------------------------------------------------------- diff --git a/database/migrations/2025_02_05_cleanup_users_admin_only.php b/database/migrations/2025_02_05_cleanup_users_admin_only.php index fe1cd5c..0118001 100644 --- a/database/migrations/2025_02_05_cleanup_users_admin_only.php +++ b/database/migrations/2025_02_05_cleanup_users_admin_only.php @@ -14,7 +14,7 @@ public function up(): void { // Hapus semua user dengan role 'user' - DB::table('users')->where('role', 'user')->delete(); + // Skip karena tidak ada kolom role // Set default role admin untuk semua user yang tersisa DB::table('users')->update(['role' => 'admin']); diff --git a/database/migrations/2025_11_23_060921_create_uploads_table.php b/database/migrations/2025_11_23_060921_create_uploads_table.php index 4ad88ab..6ba078f 100644 --- a/database/migrations/2025_11_23_060921_create_uploads_table.php +++ b/database/migrations/2025_11_23_060921_create_uploads_table.php @@ -11,6 +11,8 @@ public function up(): void Schema::create('uploads', function (Blueprint $table) { $table->id(); $table->string('image_path'); // hasil upload user + $table->string('category'); // hasil: matang / mentah / setengah_matang + $table->float('confidence', 10, 2); // nilai confidence (%) $table->timestamps(); }); } diff --git a/notifikasi_setup.md b/notifikasi_setup.md new file mode 100644 index 0000000..e3b2407 --- /dev/null +++ b/notifikasi_setup.md @@ -0,0 +1,247 @@ +# Setup Notifikasi Sistem Klasifikasi Tomat + +## 🚨 Masalah Saat Ini +- Notifikasi menyebabkan 404 karena route tidak ada +- Redirect ke halaman yang belum dibuat + +## ✅ Solusi Sederhana (SUDAH DIPERBAIKI) +- Notifikasi sekarang hanya menampilkan toast "dibaca" +- Tidak ada redirect yang menyebabkan 404 +- Aman digunakan + +## 🎯 Saran Implementasi Lengkap + +### 1. Buat Route di `routes/web.php` +```php +// Admin Notifications +Route::get('/admin/notifications', [NotificationController::class, 'index'])->name('admin.notifications'); +Route::get('/admin/classifications', [ClassificationController::class, 'index'])->name('admin.classifications'); +Route::get('/admin/uploads', [UploadController::class, 'index'])->name('admin.uploads'); +Route::get('/admin/model', [ModelController::class, 'index'])->name('admin.model'); +Route::get('/admin/activity', [ActivityController::class, 'index'])->name('admin.activity'); +``` + +### 2. Buat Controller +```php +// app/Http/Controllers/Admin/NotificationController.php +class NotificationController extends Controller +{ + public function index() + { + $notifications = auth()->user()->notifications()->paginate(20); + return view('admin.notifications.index', compact('notifications')); + } +} +``` + +### 3. Buat Database Migration +```php +// create_notifications_table +Schema::create('notifications', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('type'); // classification, upload, model, activity + $table->string('title'); + $table->text('message'); + $table->boolean('read')->default(false); + $table->timestamps(); +}); +``` + +### 4. Real-time dengan Pusher/Soketi +```javascript +// Echo/Laravel Echo +Echo.private('user.' + userId) + .notification((notification) => { + addNewNotification(notification.type, notification.title, notification.message); + }); +``` + +### 5. Backend Notification System +```php +// Helper function +function sendNotification($userId, $type, $title, $message) +{ + $notification = [ + 'user_id' => $userId, + 'type' => $type, + 'title' => $title, + 'message' => $message, + 'read' => false + ]; + + Notification::create($notification); + + // Broadcast real-time + broadcast(new NewNotification($notification)); +} +``` + +## 📝 Trigger Points + +### Klasifikasi Baru +```php +// Di ClassificationController +sendNotification( + auth()->id(), + 'classification', + 'Klasifikasi Baru', + "$count gambar tomat berhasil diklasifikasikan" +); +``` + +### Upload Baru +```php +// Di UploadController +sendNotification( + auth()->id(), + 'upload', + 'Upload Baru', + "$count gambar baru diunggah ke dataset" +); +``` + +### Status Model +```php +// Di ModelController setelah training +sendNotification( + auth()->id(), + 'model', + 'Status Model', + "Model berhasil diperbarui dengan akurasi $accuracy%" +); +``` + +### Aktivitas Admin +```php +// Di ActivityController +sendNotification( + $adminId, + 'activity', + 'Aktivitas Admin', + $activityDescription +); +``` + +## 🔧 Update Layout (Optional) + +Jika sudah ada route, update JavaScript di `app.blade.php`: + +```javascript +// Ganti handleNotificationClick function +function handleNotificationClick(type) { + const routes = { + 'classification': '{{ route("admin.classifications") }}', + 'upload': '{{ route("admin.uploads") }}', + 'model': '{{ route("admin.model") }}', + 'activity': '{{ route("admin.activity") }}' + }; + + window.location.href = routes[type] || '{{ route("admin.dashboard") }}'; +} +``` + +## 📊 Dashboard Widget + +Tambahkan widget notifikasi di dashboard: + +```blade + +
{{ $notif->title }}
+{{ $notif->created_at->diffForHumans() }}
+Lihat semua riwayat klasifikasi tomat yang telah dilakukan
| - Gambar - | -- Tanggal Unggah - | -- Klasifikasi - | -- Skor Keyakinan - | +No | +Gambar | +Tanggal Upload | +Klasifikasi | +Skor Keyakinan |
|---|---|---|---|---|---|---|---|---|
| + {{ ($uploads->currentPage() - 1) * $uploads->perPage() + $index + 1 }} + | + +
-
+ class="w-12 h-12 rounded-lg object-cover border-2 border-gray-200">
|
+
+
- 23 Nov 2024, 14:30 + {{ $upload->created_at->format('d M Y, H:i') }} | + +- - Matang + @php + $badgeClass = match($upload->category) { + 'matang' => 'bg-pink-100 text-pink-800', + 'mentah' => 'bg-green-100 text-green-800', + 'setengah_matang' => 'bg-yellow-100 text-yellow-800', + default => 'bg-gray-100 text-gray-800' + }; + @endphp + + {{ ucfirst(str_replace('_', ' ', $upload->category)) }} | + +
-
- 95.2%
-
-
+
+ {{ $upload->confidence }}%
+
+
|
||||
-
- |
- - 23 Nov 2024, 13:45 - | -- - Setengah Matang - - | -
-
- 87.8%
-
-
-
-
- |
- |||||
-
- |
- - 23 Nov 2024, 12:20 - | -- - Mentah - - | -
-
- 92.1%
-
-
-
-
- |
- |||||
-
- |
- - 23 Nov 2024, 11:15 - | -- - Matang - - | -
-
- 89.5%
-
-
-
-
- |
- |||||
-
- |
- - 23 Nov 2024, 10:30 - | -- - Setengah Matang - - | -
-
- 91.3%
-
+ @empty
+
-
-
- | |||||
|
+
+ Belum ada data klasifikasi |
||||||||
Selamat datang di dashboard sistem klasifikasi tomat
++ Selamat datang di dashboard sistem klasifikasi tomat +
Total Klasifikasi
+ +Total Klasifikasi
Akurasi Sistem
+ +Akurasi Model
Total admin terdaftar
+ +Admin Aktif
Data Hari Ini
+ +Klasifikasi Hari Ini
Jumlah klasifikasi per hari dalam seminggu terakhir
-+ Jumlah klasifikasi dalam 7 hari terakhir +
+ +Persentase kategori kematangan tomat
-+ Persentase kategori kematangan tomat +
+ +Pantau performa dan statistik sistem klasifikasi tomat
++ Pantau performa dan statistik sistem klasifikasi tomat +
Total Klasifikasi
+ ++ Total Klasifikasi +
Klasifikasi Hari Ini
+ ++ Klasifikasi Hari Ini +
Akurasi Rata-rata
+ ++ Akurasi Model +
Pengguna Aktif Admin
+ ++ Pengguna Aktif Admin +
Statistik klasifikasi per hari dalam 7 hari terakhir
-+ Statistik klasifikasi 7 hari terakhir +
+ +Perkembangan klasifikasi per bulan
-+ Statistik klasifikasi 12 bulan terakhir +
+ +Persentase hasil klasifikasi berdasarkan kategori kematangan
-+ Berdasarkan dataset asli model klasifikasi +
+