From 1c4cc1b35b897f522ca861b7a2f5e733e1f4d43a Mon Sep 17 00:00:00 2001 From: E31232094SalsabilaJJ <158023453+E31232094SalsabilaJJ@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:49:33 +0700 Subject: [PATCH] Up project - SiPakarTebu Laravel Project --- .gitignore | 19 + .htaccess | 6 + app/Http/Controllers/ArticleController.php | 110 +++ .../Auth/ForgotPasswordController.php | 129 +++ app/Http/Controllers/Auth/LoginController.php | 40 + app/Http/Controllers/AuthController.php | 70 ++ app/Http/Controllers/Controller.php | 8 + app/Http/Controllers/DashboardController.php | 70 ++ app/Http/Controllers/DiagnosisController.php | 163 ++++ app/Http/Controllers/DiseaseController.php | 267 ++++++ .../Controllers/GuestDiagnosisController.php | 108 +++ app/Http/Controllers/HistoryController.php | 17 + .../Controllers/NotificationController.php | 47 + app/Http/Controllers/ProfileController.php | 83 ++ app/Http/Controllers/UserController.php | 94 ++ app/Http/Controllers/WelcomeController.php | 74 ++ app/Http/Middleware/Authenticate.php | 14 + .../Middleware/RedirectIfAuthenticated.php | 24 + app/Http/Middleware/RoleMiddleware.php | 28 + app/Models/Diagnosis.php | 26 + app/Models/Disease.php | 27 + app/Models/Notification.php | 21 + app/Models/PasswordResetOtp.php | 28 + app/Models/Symptom.php | 14 + app/Models/Treatment.php | 13 + app/Models/User.php | 54 ++ app/Providers/AppServiceProvider.php | 24 + app/mail/otpmail.php | 30 + artisan | 18 + bootstrap/app.php | 22 + bootstrap/cache/.gitignore | 2 + bootstrap/providers.php | 5 + composer.json | 88 ++ config/app.php | 126 +++ config/auth.php | 115 +++ config/cache.php | 108 +++ config/database.php | 183 ++++ config/filesystems.php | 80 ++ config/logging.php | 132 +++ config/mail.php | 127 +++ config/queue.php | 120 +++ config/services.php | 38 + config/session.php | 217 +++++ database/.gitignore | 1 + database/factories/UserFactory.php | 44 + .../0001_01_01_000001_create_cache_table.php | 35 + .../0001_01_01_000002_create_jobs_table.php | 57 ++ .../2025_10_16_071401_create_users_table.php | 31 + ...26_02_12_073657_create_diagnoses_table.php | 33 + ...0420_add_cf_columns_to_diagnoses_table.php | 28 + ...026_02_18_061719_create_sessions_table.php | 31 + ...73441_create_password_reset_otps_table.php | 25 + ..._03_29_070616_add_photo_to_users_table.php | 25 + ...3_29_072038_create_notifications_table.php | 26 + ...026_03_29_120320_create_diseases_table.php | 32 + ...026_03_29_120341_create_symptoms_table.php | 30 + ...6_03_29_120347_create_treatments_table.php | 31 + ...9_120501_create_disease_symptoms_table.php | 29 + ...6_03_30_034752_add_role_to_users_table.php | 23 + database/seeders/DatabaseSeeder.php | 15 + database/seeders/DiseaseSeeder.php | 140 +++ public/.htaccess | 19 + public/favicon.ico | 0 public/index.php | 17 + public/js/tour.js | 181 ++++ public/robots.txt | 2 + resources/css/app.css | 11 + resources/js/app.js | 1 + resources/js/bootstrap.js | 4 + .../views/auth/forgot-password.blade.php | 129 +++ resources/views/auth/login.blade.php | 386 ++++++++ resources/views/auth/otp-verivy.blade.php | 158 ++++ resources/views/auth/otp.blade.php | 162 ++++ resources/views/auth/register.blade.php | 448 +++++++++ resources/views/auth/reset-password.blade.php | 291 ++++++ resources/views/dashboard.blade.php | 579 ++++++++++++ resources/views/diagnosis/create.blade.php | 485 ++++++++++ resources/views/diagnosis/index.blade.php | 71 ++ resources/views/diagnosis/result.blade.php | 319 +++++++ resources/views/diseases/create.blade.php | 279 ++++++ resources/views/diseases/index.blade.php | 165 ++++ resources/views/diseases/show.blade.php | 165 ++++ resources/views/errors/403.blade.php | 34 + resources/views/history/index.blade.php | 255 +++++ resources/views/layouts/app.blade.php | 166 ++++ resources/views/layouts/auth.blade.php | 234 +++++ resources/views/notifications/index.blade.php | 93 ++ resources/views/profile/index.blade.php | 285 ++++++ resources/views/users/index.blade.php | 274 ++++++ resources/views/welcome.blade.php | 870 ++++++++++++++++++ routes/console.php | 8 + routes/web.php | 104 +++ 92 files changed, 9820 insertions(+) create mode 100644 .gitignore create mode 100644 .htaccess create mode 100644 app/Http/Controllers/ArticleController.php create mode 100644 app/Http/Controllers/Auth/ForgotPasswordController.php create mode 100644 app/Http/Controllers/Auth/LoginController.php create mode 100644 app/Http/Controllers/AuthController.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/DashboardController.php create mode 100644 app/Http/Controllers/DiagnosisController.php create mode 100644 app/Http/Controllers/DiseaseController.php create mode 100644 app/Http/Controllers/GuestDiagnosisController.php create mode 100644 app/Http/Controllers/HistoryController.php create mode 100644 app/Http/Controllers/NotificationController.php create mode 100644 app/Http/Controllers/ProfileController.php create mode 100644 app/Http/Controllers/UserController.php create mode 100644 app/Http/Controllers/WelcomeController.php create mode 100644 app/Http/Middleware/Authenticate.php create mode 100644 app/Http/Middleware/RedirectIfAuthenticated.php create mode 100644 app/Http/Middleware/RoleMiddleware.php create mode 100644 app/Models/Diagnosis.php create mode 100644 app/Models/Disease.php create mode 100644 app/Models/Notification.php create mode 100644 app/Models/PasswordResetOtp.php create mode 100644 app/Models/Symptom.php create mode 100644 app/Models/Treatment.php create mode 100644 app/Models/User.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/mail/otpmail.php create mode 100644 artisan create mode 100644 bootstrap/app.php create mode 100644 bootstrap/cache/.gitignore create mode 100644 bootstrap/providers.php create mode 100644 composer.json create mode 100644 config/app.php create mode 100644 config/auth.php create mode 100644 config/cache.php create mode 100644 config/database.php create mode 100644 config/filesystems.php create mode 100644 config/logging.php create mode 100644 config/mail.php create mode 100644 config/queue.php create mode 100644 config/services.php create mode 100644 config/session.php create mode 100644 database/.gitignore create mode 100644 database/factories/UserFactory.php create mode 100644 database/migrations/0001_01_01_000001_create_cache_table.php create mode 100644 database/migrations/0001_01_01_000002_create_jobs_table.php create mode 100644 database/migrations/2025_10_16_071401_create_users_table.php create mode 100644 database/migrations/2026_02_12_073657_create_diagnoses_table.php create mode 100644 database/migrations/2026_02_18_060420_add_cf_columns_to_diagnoses_table.php create mode 100644 database/migrations/2026_02_18_061719_create_sessions_table.php create mode 100644 database/migrations/2026_03_28_073441_create_password_reset_otps_table.php create mode 100644 database/migrations/2026_03_29_070616_add_photo_to_users_table.php create mode 100644 database/migrations/2026_03_29_072038_create_notifications_table.php create mode 100644 database/migrations/2026_03_29_120320_create_diseases_table.php create mode 100644 database/migrations/2026_03_29_120341_create_symptoms_table.php create mode 100644 database/migrations/2026_03_29_120347_create_treatments_table.php create mode 100644 database/migrations/2026_03_29_120501_create_disease_symptoms_table.php create mode 100644 database/migrations/2026_03_30_034752_add_role_to_users_table.php create mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 database/seeders/DiseaseSeeder.php create mode 100644 public/.htaccess create mode 100644 public/favicon.ico create mode 100644 public/index.php create mode 100644 public/js/tour.js create mode 100644 public/robots.txt create mode 100644 resources/css/app.css create mode 100644 resources/js/app.js create mode 100644 resources/js/bootstrap.js create mode 100644 resources/views/auth/forgot-password.blade.php create mode 100644 resources/views/auth/login.blade.php create mode 100644 resources/views/auth/otp-verivy.blade.php create mode 100644 resources/views/auth/otp.blade.php create mode 100644 resources/views/auth/register.blade.php create mode 100644 resources/views/auth/reset-password.blade.php create mode 100644 resources/views/dashboard.blade.php create mode 100644 resources/views/diagnosis/create.blade.php create mode 100644 resources/views/diagnosis/index.blade.php create mode 100644 resources/views/diagnosis/result.blade.php create mode 100644 resources/views/diseases/create.blade.php create mode 100644 resources/views/diseases/index.blade.php create mode 100644 resources/views/diseases/show.blade.php create mode 100644 resources/views/errors/403.blade.php create mode 100644 resources/views/history/index.blade.php create mode 100644 resources/views/layouts/app.blade.php create mode 100644 resources/views/layouts/auth.blade.php create mode 100644 resources/views/notifications/index.blade.php create mode 100644 resources/views/profile/index.blade.php create mode 100644 resources/views/users/index.blade.php create mode 100644 resources/views/welcome.blade.php create mode 100644 routes/console.php create mode 100644 routes/web.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b4d35b --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +/vendor +/node_modules +.env +/storage/*.key +/public/hot +/public/storage +/storage/app/public +/storage/framework/cache +/storage/framework/sessions +/storage/framework/views +/storage/logs +.phpunit.result.cache +Homestead.json +Homestead.yaml +auth.json +npm-debug.log +yarn-error.log +.idea +.vscode \ No newline at end of file diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..4546d16 --- /dev/null +++ b/.htaccess @@ -0,0 +1,6 @@ + + RewriteEngine On + RewriteBase /E31232094/ + RewriteCond %{REQUEST_URI} !^/E31232094/public/ + RewriteRule ^(.*)$ /E31232094/public/$1 [L,R=301] + \ No newline at end of file diff --git a/app/Http/Controllers/ArticleController.php b/app/Http/Controllers/ArticleController.php new file mode 100644 index 0000000..a62a0ba --- /dev/null +++ b/app/Http/Controllers/ArticleController.php @@ -0,0 +1,110 @@ + 'Kementerian Pertanian', + 'url' => 'https://www.pertanian.go.id/feed/', + 'logo' => null, + ], + [ + 'name' => 'CYBEX Pertanian', + 'url' => 'https://cybex.pertanian.go.id/feed', + 'logo' => null, + ], + [ + 'name' => 'Litbang Pertanian', + 'url' => 'https://www.litbang.pertanian.go.id/rss/berita.xml', + 'logo' => null, + ], + [ + 'name' => 'P3GI', + 'url' => 'https://p3gi.co.id/feed/', + 'logo' => null, + ], +]; + + public function fetchArticles(): array + { + return Cache::remember('rss_articles', 3600, function () { + $articles = []; + + foreach ($this->feeds as $feed) { + try { + + $response = Http::timeout(8)->get($feed['url']); + if (!$response->ok()) continue; + + $xml = simplexml_load_string($response->body(), 'SimpleXMLElement', LIBXML_NOCDATA); + if (!$xml) continue; + + $items = $xml->channel->item ?? []; + $count = 0; + + foreach ($items as $item) { + if ($count >= 3) break; + + // Filter keyword tebu ← TAMBAHKAN DI SINI + $keywords = ['tebu', 'gula', 'perkebunan', 'penyakit tanaman', 'pertanian']; + $title = strtolower((string) $item->title); + $desc = strtolower((string) $item->description); + $relevant = false; + foreach ($keywords as $kw) { + if (str_contains($title, $kw) || str_contains($desc, $kw)) { + $relevant = true; + break; + } + } + if (!$relevant) continue; + + // Coba ambil gambar dari enclosure atau media:content + $image = null; + + // Coba ambil gambar dari enclosure atau media:content + $image = null; + if (isset($item->enclosure)) { + $image = (string) $item->enclosure->attributes()->url; + } + if (!$image) { + $ns = $item->getNamespaces(true); + if (isset($ns['media'])) { + $media = $item->children($ns['media']); + $image = (string) ($media->content->attributes()->url ?? ''); + } + } + // Fallback: ekstrak gambar dari description + if (!$image) { + preg_match('/]+src=["\']([^"\']+)["\']/', (string)$item->description, $m); + $image = $m[1] ?? null; + } + + $articles[] = [ + 'title' => (string) $item->title, + 'link' => (string) $item->link, + 'date' => date('d M Y', strtotime((string) $item->pubDate)), + 'source' => $feed['name'], + 'logo' => $feed['logo'], + 'image' => $image, + 'excerpt' => strip_tags(substr((string) $item->description, 0, 120)) . '...', + ]; + + + $count++; + } + } catch (\Exception $e) { + continue; + } + } + + // Acak urutan artikel + shuffle($articles); + return array_slice($articles, 0, 6); + }); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Auth/ForgotPasswordController.php b/app/Http/Controllers/Auth/ForgotPasswordController.php new file mode 100644 index 0000000..263a9f9 --- /dev/null +++ b/app/Http/Controllers/Auth/ForgotPasswordController.php @@ -0,0 +1,129 @@ +validate([ + 'email' => 'required|email|exists:users,email', + ], [ + 'email.exists' => 'Email tidak ditemukan. Pastikan email sudah terdaftar.', + ]); + + $otp = rand(100000, 999999); + + // Simpan OTP di cache selama 5 menit + Cache::put('otp_' . $request->email, $otp, now()->addMinutes(5)); + Cache::put('otp_email', $request->email, now()->addMinutes(5)); + + // Coba kirim email, kalau gagal tetap lanjut (untuk development) + try { + Mail::raw("Kode OTP kamu: $otp (berlaku 5 menit)", function ($message) use ($request) { + $message->to($request->email)->subject('Kode OTP Reset Password'); + }); + } catch (\Exception $e) { + // Log error tapi jangan crash — tampilkan OTP langsung untuk testing + \Log::error('Mail gagal: ' . $e->getMessage()); + } + + // Untuk keperluan development: flash OTP ke session agar bisa dilihat + session()->flash('otp_debug', "OTP kamu: $otp"); + + return redirect()->route('password.otp.form'); +} + // STEP 2 — Tampilkan form OTP + public function showOtpForm() + { + return view('auth.otp'); + } + + // STEP 2 — Verifikasi OTP + public function verifyOtp(Request $request) + { + $request->validate(['otp' => 'required|digits:6']); + + $email = Cache::get('otp_email'); + $cachedOtp = Cache::get('otp_' . $email); + + if (!$cachedOtp || $request->otp != $cachedOtp) { + return back()->withErrors(['otp' => 'OTP salah atau sudah kadaluarsa.']); + } + + // Tandai OTP sudah diverifikasi + Cache::put('otp_verified_' . $email, true, now()->addMinutes(10)); + + return redirect()->route('password.reset.form'); + } + + // STEP 2 — Resend OTP + public function resendOtp(Request $request) + { + $email = Cache::get('otp_email'); + + if (!$email) { + return redirect()->route('password.email')->withErrors(['email' => 'Sesi habis, ulangi dari awal.']); + } + + $otp = rand(100000, 999999); + Cache::put('otp_' . $email, $otp, now()->addMinutes(5)); + + Mail::raw("Kode OTP kamu: $otp (berlaku 5 menit)", function ($message) use ($email) { + $message->to($email)->subject('Kode OTP Reset Password'); + }); + + return back()->with('status', 'OTP baru telah dikirim.'); + } + + // STEP 3 — Tampilkan form reset password + public function showResetForm() + { + $email = Cache::get('otp_email'); + + if (!$email || !Cache::get('otp_verified_' . $email)) { + return redirect()->route('password.email')->withErrors(['email' => 'Verifikasi OTP terlebih dahulu.']); + } + + return view('auth.reset-password'); + } + + // STEP 3 — Proses reset password + public function resetPassword(Request $request) + { + $request->validate([ + 'password' => 'required|min:8|confirmed', + ]); + + $email = Cache::get('otp_email'); + + if (!$email || !Cache::get('otp_verified_' . $email)) { + return redirect()->route('password.email')->withErrors(['email' => 'Sesi tidak valid.']); + } + + User::where('email', $email)->update([ + 'password' => Hash::make($request->password), + ]); + + // Hapus semua cache terkait + Cache::forget('otp_' . $email); + Cache::forget('otp_email'); + Cache::forget('otp_verified_' . $email); + + return redirect()->route('login')->with('status', 'Password berhasil direset!'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php new file mode 100644 index 0000000..bb49c59 --- /dev/null +++ b/app/Http/Controllers/Auth/LoginController.php @@ -0,0 +1,40 @@ +validate([ + 'email' => 'required|email', + 'password' => 'required', + ]); + + if (Auth::attempt($credentials)) { + $request->session()->regenerate(); + return redirect()->intended('/dashboard'); + } + + return back()->withErrors([ + 'email' => 'Email atau password salah.', + ])->onlyInput('email'); + } + + public function logout(Request $request) + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + return redirect('/login'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php new file mode 100644 index 0000000..2e80fee --- /dev/null +++ b/app/Http/Controllers/AuthController.php @@ -0,0 +1,70 @@ +validate([ + 'email' => 'required|email', + 'password' => 'required', + ]); + + if (Auth::attempt($credentials)) { + $request->session()->regenerate(); + return redirect()->intended(url('/dashboard')); + } + + return back()->withErrors([ + 'email' => 'Email atau password salah.', + ])->onlyInput('email'); + } + + public function register(Request $request) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|string|email|max:255|unique:users', + 'password' => 'required|string|min:8|confirmed', + 'role' => 'required|in:admin,user', + ], [ + 'role.required' => 'Pilih jenis akun terlebih dahulu.', + 'role.in' => 'Jenis akun tidak valid.', + ]); + + $user = User::create([ + 'name' => $validated['name'], + 'email' => $validated['email'], + 'password' => Hash::make($validated['password']), + 'role' => $validated['role'], + ]); + + Auth::login($user); + + return redirect(url('/dashboard')); + } + + public function logout(Request $request) + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + return redirect(url('/')); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +id())->count(); + + $recentDiagnosis = Diagnosis::where('user_id', auth()->id()) + ->latest() + ->take(5) + ->get(); + + // Data untuk chart bulanan + $monthlyData = Diagnosis::where('user_id', auth()->id()) + ->whereYear('created_at', date('Y')) + ->selectRaw('MONTH(created_at) as month, COUNT(*) as count') + ->groupBy('month') + ->get(); + + // Data tanggal diagnosa bulan ini + $diagnosaDates = Diagnosis::where('user_id', auth()->id()) + ->whereYear('created_at', date('Y')) + ->whereMonth('created_at', date('n')) + ->selectRaw('DAY(created_at) as day, COUNT(*) as count') + ->groupBy('day') + ->pluck('count', 'day') + ->toArray(); + + // 🔥 FIX UTAMA: AKURASI RATA-RATA + $avgAccuracy = Diagnosis::where('user_id', auth()->id()) + ->whereNotNull('confidence') + ->avg('confidence'); + + // biar ga null & tampil bagus + $avgAccuracy = $avgAccuracy ? round($avgAccuracy, 1) : 0; + + $monthlyAccuracyRaw = Diagnosis::where('user_id', auth()->id()) + ->whereYear('created_at', date('Y')) + ->whereNotNull('confidence') + ->selectRaw('MONTH(created_at) as month, AVG(confidence) as avg_confidence') + ->groupBy('month')->get()->keyBy('month'); + +$monthlyAccuracy = collect(range(1, 12))->map(function ($m) use ($monthlyAccuracyRaw) { + return $monthlyAccuracyRaw->has($m) ? round($monthlyAccuracyRaw[$m]->avg_confidence, 1) : null; +})->values()->toArray(); + +// Lalu di compact() tambahkan 'monthlyAccuracy' +return view('dashboard', compact( + 'totalDiagnosis', + 'recentDiagnosis', + 'monthlyData', + 'diagnosaDates', + 'avgAccuracy', + 'monthlyAccuracy' // ← tambahkan ini +)); + + return view('dashboard', compact( + 'totalDiagnosis', + 'recentDiagnosis', + 'monthlyData', + 'diagnosaDates', + 'avgAccuracy' + )); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/DiagnosisController.php b/app/Http/Controllers/DiagnosisController.php new file mode 100644 index 0000000..4cbcf81 --- /dev/null +++ b/app/Http/Controllers/DiagnosisController.php @@ -0,0 +1,163 @@ +get(); + return view('diagnosis.create', compact('symptoms')); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'symptoms' => 'required|array|min:4', + ]); + + $gejalaInput = []; + $gejalaLabels = []; + + $namaGejala = Symptom::pluck('name', 'code'); + + foreach ($validated['symptoms'] as $kode => $cfUser) { + if (isset($cfUser) && $cfUser !== '') { + $cfUser = (float) $cfUser; + $cfUser = min($cfUser, 0.8); + $gejalaInput[$kode] = $cfUser; + $gejalaLabels[$kode] = [ + 'nama' => $namaGejala[$kode] ?? $kode, + 'cf' => $cfUser, + ]; + } + } + + if (count($gejalaInput) < 4) { + return back()->withErrors(['symptoms' => 'Pilih minimal 4 gejala.']); + } + + $hasilDiagnosa = $this->diagnosaLengkap($gejalaInput); + $utama = $hasilDiagnosa[0] ?? null; + + $diagnosis = Diagnosis::create([ + 'user_id' => auth()->id(), + 'plant_name' => 'Tebu', + 'symptoms' => $gejalaLabels, + 'disease_name' => $utama ? $utama['nama'] : 'Tidak Terdeteksi', + 'treatment' => $utama ? implode('; ', $utama['solusi']) : '-', + 'confidence' => $utama ? $utama['persentase'] : 0, + ]); + + Notification::create([ + 'user_id' => auth()->id(), + 'type' => 'diagnosis', + 'title' => 'Diagnosa Selesai', + 'message' => 'Diagnosa tanaman tebu selesai. Penyakit terdeteksi: ' . ($utama ? $utama['nama'] . ' (' . $utama['persentase'] . '%)' : 'Tidak Terdeteksi') . '.', + 'is_read' => false, + ]); + + $admins = \App\Models\User::where('role', 'admin') + ->where('id', '!=', auth()->id()) + ->get(); + + foreach ($admins as $admin) { + Notification::create([ + 'user_id' => $admin->id, + 'type' => 'diagnosis', + 'title' => 'Diagnosa Baru dari ' . auth()->user()->name, + 'message' => 'User ' . auth()->user()->name . ' baru saja melakukan diagnosa. Penyakit terdeteksi: ' . ($utama ? $utama['nama'] . ' (' . $utama['persentase'] . '%)' : 'Tidak Terdeteksi') . '.', + 'is_read' => false, + ]); + } + + return redirect()->route('diagnosis.result', $diagnosis->id); + } + + public function result($id) + { + $diagnosis = Diagnosis::where('user_id', auth()->id())->findOrFail($id); + + // DEBUG SEMENTARA — hapus setelah selesai debug + $gejalaInputDebug = collect($diagnosis->symptoms) + ->mapWithKeys(fn($v, $k) => [$k => $v['cf']]) + ->toArray(); + + $debugInfo = $this->diagnosaLengkap($gejalaInputDebug, debug: true); + + return view('diagnosis.result', compact('diagnosis', 'debugInfo')); + } + + private function diagnosaLengkap(array $gejalaInput, bool $debug = false): array + { + $hasil = []; + $diseases = Disease::with(['symptoms', 'treatments'])->get(); + + foreach ($diseases as $disease) { + $cfCombine = 0; + $first = true; + $cocok = false; + $steps = []; + + foreach ($disease->symptoms as $symptom) { + $kodeGejala = $symptom->code; + $cfPakar = (float) $symptom->pivot->cf_value; + + if (isset($gejalaInput[$kodeGejala])) { + $cocok = true; + $cfUser = $gejalaInput[$kodeGejala]; + $cf = $cfUser * $cfPakar; + + if ($first) { + $cfCombine = $cf; + $first = false; + } else { + $cfCombine = $cfCombine + ($cf * (1 - $cfCombine)); + } + + if ($debug) { + $steps[] = [ + 'gejala' => $kodeGejala, + 'cf_pakar' => $cfPakar, + 'cf_user' => $cfUser, + 'cf_komb' => round($cf, 6), + 'cf_akum' => round($cfCombine, 6), + ]; + } + } + } + + if ($cocok) { + $treatments = []; + if ($disease->treatments && $disease->treatments->count()) { + $treatments = $disease->treatments + ->sortBy('order') + ->pluck('description') + ->toArray(); + } + + $entry = [ + 'nama' => $disease->name, + 'persentase' => round($cfCombine * 100, 2), + 'solusi' => count($treatments) ? $treatments : ['-'], + ]; + + if ($debug) { + $entry['steps'] = $steps; + } + + $hasil[] = $entry; + } + } + + usort($hasil, fn($a, $b) => $b['persentase'] <=> $a['persentase']); + + return $hasil; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/DiseaseController.php b/app/Http/Controllers/DiseaseController.php new file mode 100644 index 0000000..72aa5cc --- /dev/null +++ b/app/Http/Controllers/DiseaseController.php @@ -0,0 +1,267 @@ +getClientOriginalExtension(); + $file->move(public_path('storage/' . $folder), $filename); + return $folder . '/' . $filename; + } + + private function deletePhoto($path) + { + $fullPath = public_path('storage/' . $path); + if (file_exists($fullPath)) { + unlink($fullPath); + } + } + + public function index() + { + $diseases = Disease::with(['symptoms','treatments'])->get(); + return view('diseases.index', compact('diseases')); + } + + public function create() + { + $nextDiseaseCode = 'P' . str_pad(Disease::count() + 1, 2, '0', STR_PAD_LEFT); + $nextSymptomCode = 'G' . str_pad(Symptom::count() + 1, 2, '0', STR_PAD_LEFT); + $nextTreatmentCode = 'S' . str_pad(Treatment::count() + 1, 2, '0', STR_PAD_LEFT); + + return view('diseases.create', compact( + 'nextDiseaseCode', + 'nextSymptomCode', + 'nextTreatmentCode' + )); + } + + public function edit(Disease $disease) + { + $disease->load(['symptoms','treatments']); + + return view('diseases.create', [ + 'disease' => $disease, + 'isEdit' => true, + 'nextDiseaseCode' => $disease->code, + ]); + } + + public function update(Request $request, Disease $disease) + { + $request->validate([ + 'name' => 'required|string|max:255', + 'latin_name' => 'nullable|string|max:255', + 'description' => 'nullable|string', + ]); + + DB::transaction(function () use ($request, $disease) { + + $disease->update([ + 'name' => $request->name, + 'latin_name' => $request->latin_name, + 'description' => $request->description, + ]); + + // Handle foto penyakit + if ($request->hasFile('photo')) { + if ($disease->photo) { + $this->deletePhoto($disease->photo); + } + $disease->update(['photo' => $this->savePhoto($request->file('photo'), 'diseases')]); + } + + // RESET RELASI + $disease->symptoms()->detach(); + $disease->treatments()->delete(); + + // SYMPTOMS + foreach ($request->symptoms as $i => $s) { + if (empty($s['name'])) continue; + + $symptomPhoto = null; + if (isset($s['photo']) && $s['photo'] instanceof \Illuminate\Http\UploadedFile) { + $symptomPhoto = $this->savePhoto($s['photo'], 'symptoms'); + } + + $symptom = Symptom::where('name', $s['name'])->first(); + if (!$symptom) { + $symptom = Symptom::create([ + 'code' => 'G' . str_pad(Symptom::count() + 1, 2, '0', STR_PAD_LEFT), + 'name' => $s['name'], + 'photo' => $symptomPhoto, + ]); + } + + $disease->symptoms()->attach($symptom->id, ['cf_value' => $s['cf'] ?? 0.7]); + } + + // TREATMENTS + $maxRow = DB::select('SELECT MAX(CAST(SUBSTRING(code, 2) AS UNSIGNED)) as maxnum FROM treatments')[0]; + $lastNum = $maxRow->maxnum ?? 0; + + foreach ($request->treatments as $i => $t) { + if (!isset($t['description']) || trim($t['description']) === '') continue; + + $lastNum++; + DB::table('treatments')->insert([ + 'code' => 'S' . str_pad($lastNum, 2, '0', STR_PAD_LEFT), + 'disease_id' => $disease->id, + 'description' => trim($t['description']), + 'order' => $i + 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + }); + + return redirect()->route('diseases.show', $disease->id) + ->with('status', 'Penyakit berhasil diupdate!'); + } + + public function store(Request $request) + { + $request->validate([ + 'name' => 'required|string|max:255', + 'latin_name' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'photo' => 'nullable|image|mimes:jpg,jpeg,png|max:3072', + 'symptoms' => 'required|array|min:3', + 'symptoms.*.name' => 'required|string', + 'treatments' => 'required|array|min:3', + 'treatments.*.description' => 'nullable|string', + ]); + + $disease = DB::transaction(function () use ($request) { + + $photoPath = null; + if ($request->hasFile('photo')) { + $photoPath = $this->savePhoto($request->file('photo'), 'diseases'); + } + + $disease = Disease::create([ + 'code' => 'P' . str_pad(Disease::count() + 1, 2, '0', STR_PAD_LEFT), + 'name' => $request->name, + 'latin_name' => $request->latin_name, + 'description' => $request->description, + 'photo' => $photoPath, + ]); + + // SYMPTOMS + foreach ($request->symptoms as $s) { + if (empty($s['name'])) continue; + + $symptomPhoto = null; + if (isset($s['photo']) && $s['photo'] instanceof \Illuminate\Http\UploadedFile) { + $symptomPhoto = $this->savePhoto($s['photo'], 'symptoms'); + } + + $symptom = Symptom::where('name', $s['name'])->first(); + if (!$symptom) { + $symptom = Symptom::create([ + 'code' => 'G' . str_pad(Symptom::count() + 1, 2, '0', STR_PAD_LEFT), + 'name' => $s['name'], + 'photo' => $symptomPhoto, + ]); + } + + $disease->symptoms()->attach( + $symptom->id, + ['cf_value' => $s['cf'] ?? 0.7] + ); + } + + // TREATMENTS + if ($request->has('treatments')) { + $maxRow = DB::select('SELECT MAX(CAST(SUBSTRING(code, 2) AS UNSIGNED)) as maxnum FROM treatments')[0]; + $lastNum = $maxRow->maxnum ?? 0; + + foreach ($request->treatments as $i => $t) { + if (!isset($t['description']) || trim($t['description']) === '') continue; + + $lastNum++; + DB::table('treatments')->insert([ + 'code' => 'S' . str_pad($lastNum, 2, '0', STR_PAD_LEFT), + 'disease_id' => $disease->id, + 'description' => trim($t['description']), + 'order' => $i + 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + return $disease; + }); + + Notification::create([ + 'user_id' => auth()->id(), + 'type' => 'system', + 'title' => 'Penyakit Baru Ditambahkan', + 'message' => "Penyakit \"{$disease->name}\" ({$disease->code}) berhasil ditambahkan dengan " + . $disease->symptoms()->count() . " gejala.", + 'is_read' => false, + ]); + + return redirect()->route('diseases.index') + ->with('status', 'Penyakit berhasil ditambahkan!'); + } + + public function show(Disease $disease) + { + $disease->load(['symptoms','treatments']); + return view('diseases.show', compact('disease')); + } + + public function destroy(Disease $disease) + { + $diseaseName = $disease->name; + $diseaseCode = $disease->code; + + DB::transaction(function () use ($disease) { + + if ($disease->photo) { + $this->deletePhoto($disease->photo); + } + + foreach ($disease->symptoms as $symptom) { + $usedByOthers = $symptom->diseases() + ->where('diseases.id', '!=', $disease->id) + ->exists(); + + if (!$usedByOthers) { + if ($symptom->photo) { + $this->deletePhoto($symptom->photo); + } + $symptom->delete(); + } + } + + $disease->symptoms()->detach(); + $disease->treatments()->delete(); + $disease->delete(); + }); + + Notification::create([ + 'user_id' => auth()->id(), + 'type' => 'system', + 'title' => 'Penyakit Dihapus', + 'message' => "Penyakit \"{$diseaseName}\" ({$diseaseCode}) telah dihapus.", + 'is_read' => false, + ]); + + return redirect()->route('diseases.index') + ->with('status', 'Penyakit berhasil dihapus!'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/GuestDiagnosisController.php b/app/Http/Controllers/GuestDiagnosisController.php new file mode 100644 index 0000000..3d89ad1 --- /dev/null +++ b/app/Http/Controllers/GuestDiagnosisController.php @@ -0,0 +1,108 @@ +get(['code', 'name']); + return response()->json($symptoms); + } + + /** + * Proses diagnosis guest — hitung CF, kembalikan hasil ringkas (JSON) + * Tidak disimpan ke DB, tidak butuh auth + */ + public function process(Request $request) + { + $validated = $request->validate([ + 'symptoms' => 'required|array|min:4', + 'symptoms.*' => 'numeric|min:0|max:0.8', + ]); + + $gejalaInput = []; + foreach ($validated['symptoms'] as $kode => $cfUser) { + if ($cfUser !== '' && $cfUser !== null) { + $gejalaInput[$kode] = min((float) $cfUser, 0.8); + } + } + + if (count($gejalaInput) < 4) { + return response()->json(['error' => 'Pilih minimal 4 gejala.'], 422); + } + + $hasil = $this->hitungCF($gejalaInput); + $utama = $hasil[0] ?? null; + + // Kembalikan hasil RINGKAS saja (tanpa penanganan lengkap) + return response()->json([ + 'disease_name' => $utama ? $utama['nama'] : 'Tidak Terdeteksi', + 'confidence' => $utama ? $utama['persentase'] : 0, + 'level' => $utama ? $this->level($utama['persentase']) : 'Tidak Terdeteksi', + 'top_results' => array_slice(array_map(fn($h) => [ + 'nama' => $h['nama'], + 'persentase' => $h['persentase'], + 'level' => $this->level($h['persentase']), + ], $hasil), 0, 3), + ]); + } + + /* ── Helpers ──────────────────────────────────────────────────────── */ + + private function hitungCF(array $gejalaInput): array + { + $hasil = []; + $diseases = Disease::with('symptoms')->get(); + + foreach ($diseases as $disease) { + $cfCombine = 0; + $first = true; + $cocok = false; + + foreach ($disease->symptoms as $symptom) { + $kode = $symptom->code; + $cfPakar = (float) $symptom->pivot->cf_value; + + if (isset($gejalaInput[$kode])) { + $cocok = true; + $cf = $gejalaInput[$kode] * $cfPakar; + + if ($first) { + $cfCombine = $cf; + $first = false; + } else { + $cfCombine = $cfCombine + ($cf * (1 - $cfCombine)); + } + } + } + + if ($cocok) { + $hasil[] = [ + 'nama' => $disease->name, + 'persentase' => round($cfCombine * 100, 2), + ]; + } + } + + usort($hasil, fn($a, $b) => $b['persentase'] <=> $a['persentase']); + + return $hasil; + } + + private function level(float $persen): string + { + if ($persen <= 20) return 'Sangat Rendah'; + if ($persen <= 40) return 'Rendah'; + if ($persen <= 60) return 'Sedang'; + if ($persen <= 80) return 'Tinggi'; + return 'Sangat Tinggi'; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/HistoryController.php b/app/Http/Controllers/HistoryController.php new file mode 100644 index 0000000..fde5e90 --- /dev/null +++ b/app/Http/Controllers/HistoryController.php @@ -0,0 +1,17 @@ +id()) + ->latest() + ->paginate(15); + return view('history.index', compact('diagnoses')); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 0000000..3c95362 --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,47 @@ +user()->notifications() + ->orderBy('created_at', 'desc') + ->get(); + + // Tandai semua sebagai sudah dibaca saat halaman dibuka + auth()->user()->notifications()->where('is_read', false)->update(['is_read' => true]); + + return view('notifications.index', compact('notifications')); + } + + public function markRead($id) + { + $notif = Notification::where('id', $id) + ->where('user_id', auth()->id()) + ->firstOrFail(); + + $notif->update(['is_read' => true]); + + return back(); + } + + public function destroy($id) + { + Notification::where('id', $id) + ->where('user_id', auth()->id()) + ->delete(); + + return back()->with('status', 'Notifikasi dihapus.'); + } + + public function destroyAll() + { + auth()->user()->notifications()->delete(); + return back()->with('status', 'Semua notifikasi dihapus.'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php new file mode 100644 index 0000000..805759b --- /dev/null +++ b/app/Http/Controllers/ProfileController.php @@ -0,0 +1,83 @@ + auth()->user()]); + } + + public function update(Request $request) + { + $user = auth()->user(); + + $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|email|unique:users,email,' . $user->id, + 'photo' => 'nullable|image|mimes:jpg,jpeg,png|max:2048', + ]); + + $user->name = $request->name; + $user->email = $request->email; + + if ($request->hasFile('photo')) { + // Hapus foto lama + if ($user->photo) { + $oldPath = public_path('storage/' . $user->photo); + if (file_exists($oldPath)) unlink($oldPath); + } + // Simpan foto baru langsung ke public/storage/photos/ + $file = $request->file('photo'); + $filename = uniqid() . '_' . time() . '.' . $file->getClientOriginalExtension(); + $file->move(public_path('storage/photos'), $filename); + $user->photo = 'photos/' . $filename; + } + + $user->save(); + + return back()->with('status', 'Profil berhasil diperbarui!'); + } + + public function updatePassword(Request $request) + { + $request->validate([ + 'current_password' => 'required', + 'password' => 'required|min:8|confirmed', + ]); + + $user = auth()->user(); + + if (!Hash::check($request->current_password, $user->password)) { + return back()->withErrors(['current_password' => 'Password saat ini salah.']); + } + + $user->password = Hash::make($request->password); + $user->save(); + + return back()->with('status_password', 'Password berhasil diubah!'); + } + + public function destroy(Request $request) + { + $user = auth()->user(); + + // Hapus foto profil + if ($user->photo) { + $photoPath = public_path('storage/' . $user->photo); + if (file_exists($photoPath)) unlink($photoPath); + } + + auth()->logout(); + $user->delete(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect(url('/')); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php new file mode 100644 index 0000000..4770567 --- /dev/null +++ b/app/Http/Controllers/UserController.php @@ -0,0 +1,94 @@ +filled('role')) { + $query->where('role', $request->role); + } + + // Search by name/email + if ($request->filled('search')) { + $query->where(function ($q) use ($request) { + $q->where('name', 'like', '%' . $request->search . '%') + ->orWhere('email', 'like', '%' . $request->search . '%'); + }); + } + + $users = $query->latest()->paginate(10)->withQueryString(); + + $totalAdmin = User::where('role', 'admin')->count(); + $totalUser = User::where('role', 'user')->count(); + + return view('users.index', compact('users', 'totalAdmin', 'totalUser')); + } + + // Ubah role user + public function updateRole(Request $request, User $user) + { + // Admin tidak bisa ubah role dirinya sendiri + if ($user->id === auth()->id()) { + return back()->withErrors(['error' => 'Kamu tidak bisa mengubah role akunmu sendiri.']); + } + + $request->validate([ + 'role' => 'required|in:admin,user', + ]); + + $oldRole = $user->role; + $user->update(['role' => $request->role]); + + $roleLabel = $request->role === 'admin' ? 'Ahli Tanaman' : 'Petani'; + $oldLabel = $oldRole === 'admin' ? 'Ahli Tanaman' : 'Petani'; + + // Notifikasi + Notification::create([ + 'user_id' => auth()->id(), + 'type' => 'system', + 'title' => 'Role User Diubah', + 'message' => "Role \"{$user->name}\" berhasil diubah dari {$oldLabel} menjadi {$roleLabel}.", + 'is_read' => false, + ]); + + return back()->with('status', "Role {$user->name} berhasil diubah menjadi {$roleLabel}."); + } + + // Hapus user + public function destroy(User $user) + { + // Admin tidak bisa hapus dirinya sendiri + if ($user->id === auth()->id()) { + return back()->withErrors(['error' => 'Kamu tidak bisa menghapus akunmu sendiri.']); + } + + $userName = $user->name; + + // Hapus data terkait + $user->notifications()->delete(); + $user->diagnoses()->delete(); + $user->delete(); + + // Notifikasi + Notification::create([ + 'user_id' => auth()->id(), + 'type' => 'system', + 'title' => 'User Dihapus', + 'message' => "Akun user \"{$userName}\" berhasil dihapus dari sistem.", + 'is_read' => false, + ]); + + return back()->with('status', "User {$userName} berhasil dihapus."); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/WelcomeController.php b/app/Http/Controllers/WelcomeController.php new file mode 100644 index 0000000..766b852 --- /dev/null +++ b/app/Http/Controllers/WelcomeController.php @@ -0,0 +1,74 @@ +fetchArticles(); + return !empty($articles) ? $articles : $this->getDummyArticles(); + }); + + return view('welcome', compact('articles')); + } + + private function getDummyArticles() + { + return [ + [ + 'title' => 'Teknologi Pertanian Modern Tingkatkan Produktivitas Tebu', + 'link' => 'https://www.pertanian.go.id', + 'excerpt' => 'Penerapan teknologi pertanian presisi dan sistem pakar membantu petani meningkatkan hasil panen hingga 30%.', + 'date' => date('d M Y'), + 'source' => 'Kementerian Pertanian', + 'image' => 'https://images.unsplash.com/photo-1625246333195-78d9c38ad449?w=600&q=80', + ], + [ + 'title' => 'Cara Mencegah Penyakit Karat Daun pada Tanaman Tebu', + 'link' => 'https://cybex.pertanian.go.id', + 'excerpt' => 'Penyakit karat daun menjadi ancaman serius bagi perkebunan tebu. Deteksi dini dan penanganan tepat sangat penting.', + 'date' => date('d M Y', strtotime('-2 days')), + 'source' => 'CYBEX Pertanian', + 'image' => 'https://images.unsplash.com/photo-1574943320219-553eb213f72d?w=600&q=80', + ], + [ + 'title' => 'Inovasi Sistem Pakar untuk Diagnosis Penyakit Tanaman', + 'link' => 'https://www.pertanian.go.id', + 'excerpt' => 'Sistem pakar berbasis AI menggunakan metode Certainty Factor terbukti efektif dalam mendiagnosis penyakit tanaman.', + 'date' => date('d M Y', strtotime('-5 days')), + 'source' => 'Litbang Pertanian', + 'image' => 'https://images.unsplash.com/photo-1416879595882-3373a0480b5b?w=600&q=80', + ], + [ + 'title' => 'Budidaya Tebu Organik Ramah Lingkungan', + 'link' => 'https://www.pertanian.go.id', + 'excerpt' => 'Metode budidaya organik tidak hanya ramah lingkungan tetapi juga menghasilkan tebu berkualitas tinggi.', + 'date' => date('d M Y', strtotime('-7 days')), + 'source' => 'Kementerian Pertanian', + 'image' => 'https://images.unsplash.com/photo-1464226184884-fa280b87c399?w=600&q=80', + ], + [ + 'title' => 'Pelatihan Petani dalam Identifikasi Penyakit Tanaman', + 'link' => 'https://cybex.pertanian.go.id', + 'excerpt' => 'Program pelatihan nasional membekali petani dengan kemampuan mengenali gejala awal penyakit tanaman.', + 'date' => date('d M Y', strtotime('-10 days')), + 'source' => 'CYBEX Pertanian', + 'image' => 'https://images.unsplash.com/photo-1500651230702-0e2d8a49d4ad?w=600&q=80', + ], + [ + 'title' => 'Riset Varietas Tebu Tahan Penyakit', + 'link' => 'https://www.pertanian.go.id', + 'excerpt' => 'Penelitian terbaru berhasil mengembangkan varietas tebu unggul yang tahan terhadap berbagai jenis penyakit utama.', + 'date' => date('d M Y', strtotime('-14 days')), + 'source' => 'Litbang Pertanian', + 'image' => 'https://images.unsplash.com/photo-1530836369250-ef72a3f5cda8?w=600&q=80', + ], + ]; + } +} \ No newline at end of file diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php new file mode 100644 index 0000000..8625e93 --- /dev/null +++ b/app/Http/Middleware/Authenticate.php @@ -0,0 +1,14 @@ +expectsJson() ? null : url('/login'); + } +} \ No newline at end of file diff --git a/app/Http/Middleware/RedirectIfAuthenticated.php b/app/Http/Middleware/RedirectIfAuthenticated.php new file mode 100644 index 0000000..458391b --- /dev/null +++ b/app/Http/Middleware/RedirectIfAuthenticated.php @@ -0,0 +1,24 @@ +check()) { + return redirect('/dashboard'); + } + } + + return $next($request); + } +} \ No newline at end of file diff --git a/app/Http/Middleware/RoleMiddleware.php b/app/Http/Middleware/RoleMiddleware.php new file mode 100644 index 0000000..0306e61 --- /dev/null +++ b/app/Http/Middleware/RoleMiddleware.php @@ -0,0 +1,28 @@ +check()) { + return redirect()->route('login'); + } + + if (auth()->user()->role !== $role) { + // Jika user biasa mencoba akses halaman admin + abort(403, 'Akses ditolak. Halaman ini hanya untuk ' . ($role === 'admin' ? 'Ahli Tanaman' : 'Petani') . '.'); + } + + return $next($request); + } +} \ No newline at end of file diff --git a/app/Models/Diagnosis.php b/app/Models/Diagnosis.php new file mode 100644 index 0000000..825f298 --- /dev/null +++ b/app/Models/Diagnosis.php @@ -0,0 +1,26 @@ + 'array', + ]; + + public function user() + { + return $this->belongsTo(User::class); + } +} \ No newline at end of file diff --git a/app/Models/Disease.php b/app/Models/Disease.php new file mode 100644 index 0000000..ece69bf --- /dev/null +++ b/app/Models/Disease.php @@ -0,0 +1,27 @@ +belongsToMany(Symptom::class, 'disease_symptoms') + ->withPivot('cf_value'); + } + + public function treatments() + { + return $this->hasMany(Treatment::class)->orderBy('order'); + } +} \ No newline at end of file diff --git a/app/Models/Notification.php b/app/Models/Notification.php new file mode 100644 index 0000000..9e0f483 --- /dev/null +++ b/app/Models/Notification.php @@ -0,0 +1,21 @@ +belongsTo(User::class); + } +} \ No newline at end of file diff --git a/app/Models/PasswordResetOtp.php b/app/Models/PasswordResetOtp.php new file mode 100644 index 0000000..de67a2d --- /dev/null +++ b/app/Models/PasswordResetOtp.php @@ -0,0 +1,28 @@ + 'datetime', + 'is_used' => 'boolean', + ]; + + /** + * Cek apakah OTP masih valid (belum expired & belum dipakai) + */ + public function isValid(): bool + { + return ! $this->is_used && $this->expires_at->isFuture(); + } +} \ No newline at end of file diff --git a/app/Models/Symptom.php b/app/Models/Symptom.php new file mode 100644 index 0000000..c375e51 --- /dev/null +++ b/app/Models/Symptom.php @@ -0,0 +1,14 @@ +belongsToMany(Disease::class, 'disease_symptoms') + ->withPivot('cf_value'); + } +} \ No newline at end of file diff --git a/app/Models/Treatment.php b/app/Models/Treatment.php new file mode 100644 index 0000000..0a67e67 --- /dev/null +++ b/app/Models/Treatment.php @@ -0,0 +1,13 @@ +belongsTo(Disease::class); + } +} \ No newline at end of file diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..2ececbf --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,54 @@ + 'datetime', + 'password' => 'hashed', + ]; + + // ── Helper role ─────────────────────────────────────────────── + + public function isAdmin(): bool + { + return $this->role === 'admin'; + } + + public function isUser(): bool + { + return $this->role === 'user'; + } + + // ── Relasi ─────────────────────────────────────────────────── + + public function notifications() + { + return $this->hasMany(Notification::class); + } + + public function diagnoses() + { + return $this->hasMany(Diagnosis::class); + } +} \ No newline at end of file diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..452e6b6 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,24 @@ +handleCommand(new ArgvInput); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..bd4ffc1 --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,22 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware) { + $middleware->alias([ + 'auth' => \App\Http\Middleware\Authenticate::class, + 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, + 'role' => \App\Http\Middleware\RoleMiddleware::class, // ← tambahkan ini + ]); +}) + ->withExceptions(function (Exceptions $exceptions) { + // + })->create(); \ No newline at end of file diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 0000000..38b258d --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,5 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..7d1eb0d --- /dev/null +++ b/config/auth.php @@ -0,0 +1,115 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', App\Models\User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..c2d927d --- /dev/null +++ b/config/cache.php @@ -0,0 +1,108 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "octane", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..53dcae0 --- /dev/null +++ b/config/database.php @@ -0,0 +1,183 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..3d671bd --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,80 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..9e998a4 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..1534c87 --- /dev/null +++ b/config/mail.php @@ -0,0 +1,127 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => 'tls', // ← ganti dari env() ke langsung 'tls' + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 587), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + + // Tambahkan ini ↓ + 'stream' => [ + 'ssl' => [ + 'allow_self_signed' => true, + 'verify_peer' => false, + 'verify_peer_name' => false, + ], + ], +], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', 'Example'), + ], + +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..df9a1ab --- /dev/null +++ b/config/queue.php @@ -0,0 +1,120 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'sync', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..6182e4b --- /dev/null +++ b/config/services.php @@ -0,0 +1,38 @@ + [ + 'token' => env('POSTMARK_TOKEN'), + ], + + 'resend' => [ + 'key' => env('RESEND_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..d075384 --- /dev/null +++ b/config/session.php @@ -0,0 +1,217 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 480), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain and all subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..584104c --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,44 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..b9c106b --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration'); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..425e705 --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2025_10_16_071401_create_users_table.php b/database/migrations/2025_10_16_071401_create_users_table.php new file mode 100644 index 0000000..ad0c86a --- /dev/null +++ b/database/migrations/2025_10_16_071401_create_users_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); +}); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + } +}; diff --git a/database/migrations/2026_02_12_073657_create_diagnoses_table.php b/database/migrations/2026_02_12_073657_create_diagnoses_table.php new file mode 100644 index 0000000..b80460d --- /dev/null +++ b/database/migrations/2026_02_12_073657_create_diagnoses_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('user_id'); + $table->string('plant_name')->nullable(); + $table->text('symptoms')->nullable(); + $table->text('diagnosis')->nullable(); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); +} + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('diagnoses'); + } +}; diff --git a/database/migrations/2026_02_18_060420_add_cf_columns_to_diagnoses_table.php b/database/migrations/2026_02_18_060420_add_cf_columns_to_diagnoses_table.php new file mode 100644 index 0000000..4bf39de --- /dev/null +++ b/database/migrations/2026_02_18_060420_add_cf_columns_to_diagnoses_table.php @@ -0,0 +1,28 @@ +string('disease_name')->nullable()->after('symptoms'); + $table->text('treatment')->nullable()->after('disease_name'); + $table->float('confidence')->default(0)->after('treatment'); + $table->json('cf_results')->nullable()->after('confidence'); + }); +} + +public function down(): void +{ + Schema::table('diagnoses', function (Blueprint $table) { + $table->dropColumn(['disease_name', 'treatment', 'confidence', 'cf_results']); + }); +} +}; diff --git a/database/migrations/2026_02_18_061719_create_sessions_table.php b/database/migrations/2026_02_18_061719_create_sessions_table.php new file mode 100644 index 0000000..f60625b --- /dev/null +++ b/database/migrations/2026_02_18_061719_create_sessions_table.php @@ -0,0 +1,31 @@ +string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/2026_03_28_073441_create_password_reset_otps_table.php b/database/migrations/2026_03_28_073441_create_password_reset_otps_table.php new file mode 100644 index 0000000..ec7c5ad --- /dev/null +++ b/database/migrations/2026_03_28_073441_create_password_reset_otps_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('email')->index(); + $table->string('otp', 6); + $table->timestamp('expires_at'); + $table->boolean('is_used')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('password_reset_otps'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_29_070616_add_photo_to_users_table.php b/database/migrations/2026_03_29_070616_add_photo_to_users_table.php new file mode 100644 index 0000000..d953ce1 --- /dev/null +++ b/database/migrations/2026_03_29_070616_add_photo_to_users_table.php @@ -0,0 +1,25 @@ +string('photo')->nullable()->after('email'); + }); +} + +public function down(): void +{ + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('photo'); + }); +} +}; diff --git a/database/migrations/2026_03_29_072038_create_notifications_table.php b/database/migrations/2026_03_29_072038_create_notifications_table.php new file mode 100644 index 0000000..7a97c2f --- /dev/null +++ b/database/migrations/2026_03_29_072038_create_notifications_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('type'); // diagnosis, reminder, system + $table->string('title'); + $table->text('message'); + $table->boolean('is_read')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_03_29_120320_create_diseases_table.php b/database/migrations/2026_03_29_120320_create_diseases_table.php new file mode 100644 index 0000000..c3c34ab --- /dev/null +++ b/database/migrations/2026_03_29_120320_create_diseases_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('code')->unique(); // P01, P02, dst + $table->string('name'); + $table->string('latin_name')->nullable(); + $table->text('description')->nullable(); + $table->string('photo')->nullable(); + $table->timestamps(); + }); +} + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('diseases'); + } +}; diff --git a/database/migrations/2026_03_29_120341_create_symptoms_table.php b/database/migrations/2026_03_29_120341_create_symptoms_table.php new file mode 100644 index 0000000..eeec49c --- /dev/null +++ b/database/migrations/2026_03_29_120341_create_symptoms_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('code')->unique(); // G01, G02, dst + $table->string('name'); + $table->string('photo')->nullable(); + $table->timestamps(); + }); +} + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('symptoms'); + } +}; diff --git a/database/migrations/2026_03_29_120347_create_treatments_table.php b/database/migrations/2026_03_29_120347_create_treatments_table.php new file mode 100644 index 0000000..0c04ff0 --- /dev/null +++ b/database/migrations/2026_03_29_120347_create_treatments_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('code')->unique(); // S01, S02, dst + $table->foreignId('disease_id')->constrained()->onDelete('cascade'); + $table->text('description'); + $table->integer('order')->default(1); + $table->timestamps(); + }); +} + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('treatments'); + } +}; diff --git a/database/migrations/2026_03_29_120501_create_disease_symptoms_table.php b/database/migrations/2026_03_29_120501_create_disease_symptoms_table.php new file mode 100644 index 0000000..8d85f76 --- /dev/null +++ b/database/migrations/2026_03_29_120501_create_disease_symptoms_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('disease_id')->constrained()->onDelete('cascade'); + $table->foreignId('symptom_id')->constrained()->onDelete('cascade'); + $table->float('cf_value')->default(0.5); + }); +} + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('disease_symptoms'); + } +}; diff --git a/database/migrations/2026_03_30_034752_add_role_to_users_table.php b/database/migrations/2026_03_30_034752_add_role_to_users_table.php new file mode 100644 index 0000000..f941c7b --- /dev/null +++ b/database/migrations/2026_03_30_034752_add_role_to_users_table.php @@ -0,0 +1,23 @@ +enum('role', ['admin', 'user'])->default('user')->after('email'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('role'); + }); + } +}; \ No newline at end of file diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..cf970bd --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,15 @@ +call([ + DiseaseSeeder::class, + ]); + } +} \ No newline at end of file diff --git a/database/seeders/DiseaseSeeder.php b/database/seeders/DiseaseSeeder.php new file mode 100644 index 0000000..97f0ec8 --- /dev/null +++ b/database/seeders/DiseaseSeeder.php @@ -0,0 +1,140 @@ + 'P01', 'name' => 'Luka Api (Leaf Scald)', + 'latin_name' => 'Xanthomonas albilineans', + 'description' => 'Penyakit bakteri yang menyebabkan garis putih memanjang pada daun tebu', + 'symptoms' => [ + ['code'=>'G01','name'=>'Garis putih memanjang pada daun (stripe putih)','cf'=>0.90], + ['code'=>'G02','name'=>'Daun layu dan mengering','cf'=>0.70], + ['code'=>'G03','name'=>'Tanaman kerdil / tumbuh tidak normal','cf'=>0.60], + ['code'=>'G15','name'=>'Daun terlihat kusam / tidak segar','cf'=>0.50], + ['code'=>'G18','name'=>'Warna daun pucat kekuningan','cf'=>0.60], + ['code'=>'G20','name'=>'Mata tunas mati','cf'=>0.70], + ], + 'treatments' => [ + 'Gunakan bibit sehat dan bebas penyakit', + 'Sterilisasi alat potong dengan larutan formalin 2%', + 'Tanam varietas tahan seperti PS 881, BL', + 'Buang dan bakar tanaman yang terinfeksi', + 'Rotasi tanaman dengan palawija', + ], + ], + [ + 'code' => 'P02', 'name' => 'Pokahbung', + 'latin_name' => 'Fusarium moniliforme', + 'description' => 'Penyakit jamur yang menyebabkan busuk pada batang tebu dan munculnya tunas abnormal', + 'symptoms' => [ + ['code'=>'G04','name'=>'Batang busuk dari dalam','cf'=>0.90], + ['code'=>'G05','name'=>'Bau busuk pada batang','cf'=>0.80], + ['code'=>'G06','name'=>'Munculnya tunas-tunas kecil abnormal (pokah)','cf'=>0.90], + ['code'=>'G16','name'=>'Tanaman mudah rebah','cf'=>0.70], + ['code'=>'G19','name'=>'Luka memanjang pada batang','cf'=>0.60], + ['code'=>'G20','name'=>'Mata tunas mati','cf'=>0.70], + ], + 'treatments' => [ + 'Gunakan bibit dari bagian pangkal batang', + 'Rendam bibit dengan fungisida (Benlate 2g/liter selama 30 menit)', + 'Hindari luka mekanis pada batang saat penanaman', + 'Perbaiki sistem drainase lahan agar tidak tergenang', + 'Tanam varietas tahan seperti PS 862, PS 881', + ], + ], + [ + 'code' => 'P03', 'name' => 'Karat Daun (Leaf Rust)', + 'latin_name' => 'Puccinia melanocephala', + 'description' => 'Penyakit jamur yang menyebabkan bercak karat kemerahan pada permukaan daun', + 'symptoms' => [ + ['code'=>'G07','name'=>'Bercak coklat kemerahan pada daun','cf'=>0.90], + ['code'=>'G08','name'=>'Bercak berkembang menjadi pustula karat','cf'=>0.95], + ['code'=>'G09','name'=>'Daun mengering dimulai dari ujung','cf'=>0.70], + ['code'=>'G15','name'=>'Daun terlihat kusam / tidak segar','cf'=>0.60], + ['code'=>'G17','name'=>'Produksi turun drastis','cf'=>0.60], + ], + 'treatments' => [ + 'Semprot dengan fungisida berbahan aktif Mancozeb', + 'Tanam varietas tahan seperti PS 862, PSJT 941', + 'Jaga jarak tanam yang optimal (120–130 cm)', + 'Bersihkan gulma secara rutin di sekitar tanaman', + 'Pangkas dan musnahkan daun yang terinfeksi berat', + ], + ], + [ + 'code' => 'P04', 'name' => 'Mozaik', + 'latin_name' => 'Sugarcane Mosaic Virus (SCMV)', + 'description' => 'Penyakit virus yang menyebabkan pola belang hijau tua dan hijau muda pada daun', + 'symptoms' => [ + ['code'=>'G10','name'=>'Belang hijau tua dan hijau muda pada daun (mozaik)','cf'=>0.95], + ['code'=>'G11','name'=>'Pertumbuhan tanaman terhambat','cf'=>0.70], + ['code'=>'G15','name'=>'Daun terlihat kusam / tidak segar','cf'=>0.60], + ['code'=>'G17','name'=>'Produksi turun drastis','cf'=>0.70], + ['code'=>'G18','name'=>'Warna daun pucat kekuningan','cf'=>0.50], + ], + 'treatments' => [ + 'Gunakan bibit sehat yang bebas virus', + 'Kendalikan vektor kutu daun dengan insektisida sistemik', + 'Cabut dan musnahkan tanaman yang terinfeksi virus', + 'Tanam varietas tahan terhadap SCMV', + 'Rotasi dengan tanaman non-inang virus selama 1 musim', + ], + ], + [ + 'code' => 'P05', 'name' => 'Ratoon Stunting Disease (RSD)', + 'latin_name' => 'Leifsonia xyli subsp. xyli', + 'description' => 'Penyakit bakteri sistemik yang menghambat pertumbuhan dan menurunkan produksi gula', + 'symptoms' => [ + ['code'=>'G03','name'=>'Tanaman kerdil / tumbuh tidak normal','cf'=>0.80], + ['code'=>'G11','name'=>'Pertumbuhan tanaman terhambat','cf'=>0.90], + ['code'=>'G12','name'=>'Ruas batang memendek','cf'=>0.85], + ['code'=>'G13','name'=>'Pembentukan gula terhambat','cf'=>0.80], + ['code'=>'G14','name'=>'Pembuluh batang berwarna merah kecoklatan','cf'=>0.90], + ['code'=>'G17','name'=>'Produksi turun drastis','cf'=>0.80], + ], + 'treatments' => [ + 'Perlakuan air panas pada bibit (suhu 50°C selama 2 jam)', + 'Sterilisasi alat potong sebelum dan sesudah digunakan', + 'Gunakan bibit dari hasil kultur jaringan yang bersertifikat', + 'Tanam varietas toleran RSD yang direkomendasikan', + 'Hindari penggunaan bibit dari kebun yang terindikasi terinfeksi', + ], + ], + ]; + + $treatmentCounter = 1; + foreach ($data as $d) { + $disease = Disease::create([ + 'code' => $d['code'], + 'name' => $d['name'], + 'latin_name' => $d['latin_name'], + 'description' => $d['description'], + ]); + + foreach ($d['symptoms'] as $s) { + $symptom = Symptom::firstOrCreate( + ['code' => $s['code']], + ['name' => $s['name']] + ); + $disease->symptoms()->attach($symptom->id, ['cf_value' => $s['cf']]); + } + + foreach ($d['treatments'] as $t) { + Treatment::create([ + 'code' => 'S' . str_pad($treatmentCounter++, 2, '0', STR_PAD_LEFT), + 'disease_id' => $disease->id, + 'description' => $t, + 'order' => array_search($t, $d['treatments']) + 1, + ]); + } + } + } +} \ No newline at end of file diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..86aa6b9 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,19 @@ + + AddType application/x-httpd-lsphp .php + + + + + Options -MultiViews -Indexes + + RewriteEngine On + RewriteBase /E31232094/public/ + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..8a4900b --- /dev/null +++ b/public/index.php @@ -0,0 +1,17 @@ +handleRequest(Request::capture()); \ No newline at end of file diff --git a/public/js/tour.js b/public/js/tour.js new file mode 100644 index 0000000..42b9d1c --- /dev/null +++ b/public/js/tour.js @@ -0,0 +1,181 @@ +// tour.js — SiPakarTebu Onboarding Tour +const tourSteps = [ + { + title: "Selamat datang di SiPakarTebu! 🌱", + desc: "Ini adalah panduan singkat untuk membantu kamu memahami fitur-fitur utama aplikasi.", + target: null, + }, + { + title: "Menu Navigasi", + desc: "Gunakan menu di sebelah kiri untuk berpindah antar halaman: Dashboard, Diagnosa, Riwayat, Kamus, dan Profil.", + target: "#sidebar", + pos: "right", + }, + { + title: "Statistik Kamu", + desc: "Ringkasan total diagnosa yang sudah kamu lakukan dan akurasi rata-rata model.", + target: ".stat-cards", + pos: "bottom", + }, + { + title: "Grafik Diagnosa", + desc: "Grafik ini menunjukkan aktivitas diagnosamu per bulan sepanjang tahun.", + target: ".chart-container", + pos: "top", + }, + { + title: "Mulai Diagnosa Baru", + desc: "Klik menu Diagnosa untuk memulai diagnosa penyakit tebu. Unggah foto dan dapatkan hasilnya!", + target: "#nav-diagnosa", + pos: "right", + }, +]; + +let tourCurrent = 0; + +function createTourOverlay() { + const overlay = document.createElement("div"); + overlay.id = "tour-overlay"; + overlay.style.cssText = ` + position: fixed; inset: 0; z-index: 9999; + pointer-events: none; + `; + document.body.appendChild(overlay); + return overlay; +} + +function createTooltip() { + const el = document.createElement("div"); + el.id = "tour-tooltip"; + el.style.cssText = ` + position: fixed; z-index: 10000; + background: #fff; border-radius: 14px; + padding: 20px 22px; width: 280px; + box-shadow: 0 8px 40px rgba(0,0,0,0.22); + pointer-events: auto; + font-family: inherit; + `; + document.body.appendChild(el); + return el; +} + +function createHighlight() { + const el = document.createElement("div"); + el.id = "tour-highlight"; + el.style.cssText = ` + position: fixed; z-index: 9998; + border: 2.5px solid #2e7d52; + border-radius: 10px; + pointer-events: none; + transition: all 0.35s ease; + box-shadow: 0 0 0 9999px rgba(0,0,0,0.50); + `; + document.body.appendChild(el); + return el; +} + +function renderTourStep() { + const step = tourSteps[tourCurrent]; + const total = tourSteps.length; + + const highlight = document.getElementById("tour-highlight"); + const tooltip = document.getElementById("tour-tooltip"); + + // Highlight target element + if (step.target) { + const target = document.querySelector(step.target); + if (target) { + const r = target.getBoundingClientRect(); + const pad = 8; + highlight.style.display = "block"; + highlight.style.top = (r.top - pad) + "px"; + highlight.style.left = (r.left - pad) + "px"; + highlight.style.width = (r.width + pad * 2) + "px"; + highlight.style.height = (r.height + pad * 2) + "px"; + + // Position tooltip near target + if (step.pos === "right") { + tooltip.style.top = r.top + "px"; + tooltip.style.left = (r.right + 16) + "px"; + } else if (step.pos === "bottom") { + tooltip.style.top = (r.bottom + 16) + "px"; + tooltip.style.left = Math.max(16, r.left) + "px"; + } else if (step.pos === "top") { + tooltip.style.top = (r.top - 200) + "px"; + tooltip.style.left = Math.max(16, r.left) + "px"; + } else { + tooltip.style.top = "50%"; + tooltip.style.left = "50%"; + tooltip.style.transform = "translate(-50%, -50%)"; + } + } + } else { + highlight.style.display = "none"; + tooltip.style.top = "50%"; + tooltip.style.left = "50%"; + tooltip.style.transform = "translate(-50%, -50%)"; + } + + const dots = Array.from({ length: total }, (_, i) => + `
` + ).join(""); + + tooltip.innerHTML = ` +
+ Langkah ${tourCurrent + 1} dari ${total} +
+
+ ${step.title} +
+
+ ${step.desc} +
+
+
${dots}
+
+ + +
+
+ `; +} + +function tourNext() { + tourCurrent++; + if (tourCurrent >= tourSteps.length) { + endTour(); + return; + } + renderTourStep(); +} + +function endTour() { + ["tour-overlay", "tour-tooltip", "tour-highlight"].forEach((id) => { + const el = document.getElementById(id); + if (el) el.remove(); + }); + // Tandai sudah pernah tour + localStorage.setItem("sipakartebu_tour_done", "1"); +} + +function startTour() { + tourCurrent = 0; + if (!document.getElementById("tour-overlay")) createTourOverlay(); + if (!document.getElementById("tour-tooltip")) createTooltip(); + if (!document.getElementById("tour-highlight")) createHighlight(); + renderTourStep(); +} + +// Auto-start untuk pengguna baru +document.addEventListener("DOMContentLoaded", () => { + if (!localStorage.getItem("sipakartebu_tour_done")) { + setTimeout(startTour, 800); // delay sedikit biar halaman load dulu + } +}); \ No newline at end of file diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/resources/css/app.css b/resources/css/app.css new file mode 100644 index 0000000..3e6abea --- /dev/null +++ b/resources/css/app.css @@ -0,0 +1,11 @@ +@import 'tailwindcss'; + +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../storage/framework/views/*.php'; +@source '../**/*.blade.php'; +@source '../**/*.js'; + +@theme { + --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; +} diff --git a/resources/js/app.js b/resources/js/app.js new file mode 100644 index 0000000..e59d6a0 --- /dev/null +++ b/resources/js/app.js @@ -0,0 +1 @@ +import './bootstrap'; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js new file mode 100644 index 0000000..5f1390b --- /dev/null +++ b/resources/js/bootstrap.js @@ -0,0 +1,4 @@ +import axios from 'axios'; +window.axios = axios; + +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php new file mode 100644 index 0000000..357bb5c --- /dev/null +++ b/resources/views/auth/forgot-password.blade.php @@ -0,0 +1,129 @@ + + + + + + Lupa Password - SiPakarTebu + + + +
+
+

SiPakarTebu

+

Sistem Diagnosis Penyakit Tanaman Tebu

+
+
+

Lupa Password?

+

Masukkan email terdaftar untuk menerima kode OTP

+ + @if(session('status')) +
{{ session('status') }}
+ @endif + + @if($errors->any()) +
{{ $errors->first() }}
+ @endif + +
+ @csrf + + + +
+ + ← Kembali ke Login +
+
+ + \ No newline at end of file diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..67d21f3 --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,386 @@ + + + + + + Login - SiPakarTebu + + + + + + +
+ + + + + +
+
+ + +
+
+ + + + + + + + + +
+

SiPakarTebu

+

Sistem Diagnosis Penyakit Tebu

+
+ + +
+

Selamat Datang

+

Masuk untuk melanjutkan ke SiPakarTebu

+
+ + @if ($errors->any()) +
+ + {{ $errors->first() }} +
+ @endif + + +
+ @csrf + +
+ +
+ + +
+
+ +
+
+ + + Lupa Password? + +
+
+ + + +
+
+ + +
+ +
+

+ Belum punya akun? + + Daftar Gratis + +

+
+ +
+
+ +
+ + + + \ No newline at end of file diff --git a/resources/views/auth/otp-verivy.blade.php b/resources/views/auth/otp-verivy.blade.php new file mode 100644 index 0000000..063eb8a --- /dev/null +++ b/resources/views/auth/otp-verivy.blade.php @@ -0,0 +1,158 @@ +{{-- resources/views/auth/otp-verify.blade.php --}} +@extends('layouts.auth') + +@section('title', 'Verifikasi OTP') + +@section('content') + + {{-- Steps indicator --}} +
+
Email
+
+
2
Kode OTP
+
+
3
Password Baru
+
+ +
Masukkan Kode OTP
+

+ Kode 6 digit telah dikirim ke email kamu. Berlaku selama + 05:00. +

+ + {{-- Alert success --}} + @if (session('success')) +
{{ session('success') }}
+ @endif + + {{-- Alert error --}} + @if ($errors->any()) +
{{ $errors->first() }}
+ @endif + +
+ 📧 + Cek folder Spam jika kode tidak muncul dalam 1 menit. +
+ + {{-- Form verifikasi OTP --}} +
+ @csrf + +
+ +
+ @for ($i = 0; $i < 6; $i++) + + @endfor +
+ {{-- Hidden input yang akan dikirim --}} + + @error('otp') + {{ $message }} + @enderror +
+ + +
+ + {{-- Kirim ulang OTP --}} +
+ Tidak menerima kode? +
+ @csrf + +
+
+ + + ← Ganti Email + + +@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/auth/otp.blade.php b/resources/views/auth/otp.blade.php new file mode 100644 index 0000000..7fb6d31 --- /dev/null +++ b/resources/views/auth/otp.blade.php @@ -0,0 +1,162 @@ + + + + + + Verifikasi OTP - SiPakarTebu + + + @if(session('otp_debug')) +
+ {{ session('otp_debug') }} +
+@endif + + + +
+
+

CaneDoc

+

Sistem Diagnosis Penyakit Tanaman Tebu

+
+
+

Verifikasi OTP

+

Masukkan kode 6 digit yang dikirim ke email kamu

+ + @if(session('otp_debug')) +
{{ session('otp_debug') }}
+ @endif + + @if(session('status')) +
{{ session('status') }}
+ @endif + + @if($errors->any()) +
{{ $errors->first() }}
+ @endif + +
+ @csrf + + + +
+ +
+ @csrf + +
+ + ← Kembali +
+
+ + \ No newline at end of file diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php new file mode 100644 index 0000000..cc951a7 --- /dev/null +++ b/resources/views/auth/register.blade.php @@ -0,0 +1,448 @@ + + + + + + Register - SiPakarTebu + + + + + + +
+ + + + + +
+
+ + +
+
+ + + + + + + + + +
+

SiPakarTebu

+
+ +
+

Buat Akun Baru

+

Isi data di bawah untuk mulai menggunakan SiPakarTebu

+
+ + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+ @csrf + +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + +
+ @error('role') +

{{ $message }}

+ @enderror +
+ + +
+ +
+

+ Sudah punya akun? + Masuk di sini +

+
+ +
+
+ +
+ + + + \ No newline at end of file diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php new file mode 100644 index 0000000..665555c --- /dev/null +++ b/resources/views/auth/reset-password.blade.php @@ -0,0 +1,291 @@ + + + + + + Reset Password - SiPakarTebu + + + + +
+ {{-- Header --}} +
+

SiPakarTebu

+

Sistem Diagnosis Penyakit Tanaman Tebu

+
+ + {{-- Body --}} +
+

Reset Password

+

Masukkan password baru kamu

+ + {{-- Error messages --}} + @if ($errors->any()) +
+ @foreach ($errors->all() as $error) +
{{ $error }}
+ @endforeach +
+ @endif + + {{-- Success message --}} + @if (session('status')) +
+ {{ session('status') }} +
+ @endif + +
+ @csrf + + {{-- Hidden fields --}} + + + + {{-- Password Baru --}} +
+ +
+ + +
+ @error('password') +
{{ $message }}
+ @enderror +
+ + {{-- Konfirmasi Password --}} +
+ +
+ + +
+
+ + +
+ + ← Kembali ke Login +
+
+ + + + + \ No newline at end of file diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php new file mode 100644 index 0000000..86ab0d9 --- /dev/null +++ b/resources/views/dashboard.blade.php @@ -0,0 +1,579 @@ +@extends('layouts.app') + +@section('title', 'Dashboard') +@section('page-title', 'Dashboard') + +@section('content') +{{-- Sapaan --}} +
+

+ Halo, {{ auth()->user()->name }}! 👋 +

+

+ Selamat datang kembali. Berikut ringkasan aktivitas diagnosa kamu hari ini. +

+
+ +
+ +
+
+
+

+ Total Diagnosa + +

+

{{ $totalDiagnosis }}

+
+
+ +
+
+
+ + +
+
+
+

+ Bulan Ini + +

+

+ {{ $monthlyData->where('month', (int) date('n'))->first()->count ?? 0 }} +

+
+
+ +
+
+
+ + +
+
+
+

+ Akurasi Rata-rata + +

+

{{ $avgAccuracy }}%

+
+
+ +
+
+
+
+ + +
+

Statistik Diagnosa

+
+ +
+
+ + +
+

Diagnosa Terbaru

+ + {{-- Tampilan mobile: card --}} +
+ @forelse($recentDiagnosis as $diagnosis) +
+
+ {{ $diagnosis->created_at->format('d/m/Y') }} + + {{ $diagnosis->confidence }}% + +
+

{{ $diagnosis->plant_name }}

+

{{ $diagnosis->disease_name }}

+
+ @empty +

Belum ada diagnosa

+ @endforelse +
+ + {{-- Tampilan desktop: tabel --}} + +
+ + + + + + + + + + +@endsection + +@push('scripts') + + + + +@endpush \ No newline at end of file diff --git a/resources/views/diagnosis/create.blade.php b/resources/views/diagnosis/create.blade.php new file mode 100644 index 0000000..07b8251 --- /dev/null +++ b/resources/views/diagnosis/create.blade.php @@ -0,0 +1,485 @@ +@extends('layouts.app') + +@section('title', 'Diagnosa Penyakit') +@section('page-title', 'Diagnosa Penyakit') + +@section('content') +
+ +
+ + {{-- Progress bar --}} +
+
+ Step 1 dari 3 + Pilih Gejala +
+
+
+
+
+ +
+ @csrf + + {{-- STEP 1: Pilih Gejala --}} +
+

Pilih Gejala

+

Pilih minimal 4 gejala yang sesuai dengan kondisi tanaman

+ + + +
+ @foreach($symptoms as $symptom) + + @endforeach +
+ +
+ + Dipilih: 0 gejala + + +
+ + +
+ + {{-- STEP 2: Tingkat Keyakinan --}} + + + {{-- STEP 3: Konfirmasi --}} + + +
+
+
+ + + +@endsection \ No newline at end of file diff --git a/resources/views/diagnosis/index.blade.php b/resources/views/diagnosis/index.blade.php new file mode 100644 index 0000000..bae15e2 --- /dev/null +++ b/resources/views/diagnosis/index.blade.php @@ -0,0 +1,71 @@ +@extends('layouts.app') + +@section('title', 'Riwayat Diagnosa') +@section('page-title', 'Riwayat Diagnosa') + +@section('content') +
+ + +
+

+ Semua Riwayat Diagnosa +

+ + Diagnosa Baru + +
+ + +
+ + + + + + + + + + + + @forelse($diagnoses as $diagnosis) + + + + + + + + @empty + + + + @endforelse + +
TanggalNama TanamanPenyakitAkurasiAksi
{{ $diagnosis->created_at->format('d/m/Y') }}{{ $diagnosis->plant_name }}{{ $diagnosis->disease_name }} + + {{ $diagnosis->confidence }}% + + + + Detail + +
+ + Belum ada riwayat diagnosa +
+
+ + + @if($diagnoses->hasPages()) +
+ {{ $diagnoses->links() }} +
+ @endif +
+@endsection \ No newline at end of file diff --git a/resources/views/diagnosis/result.blade.php b/resources/views/diagnosis/result.blade.php new file mode 100644 index 0000000..e2e14b5 --- /dev/null +++ b/resources/views/diagnosis/result.blade.php @@ -0,0 +1,319 @@ +@extends('layouts.app') + +@section('title', 'Hasil Diagnosa') +@section('page-title', 'Hasil Diagnosa') + +@section('content') +
+ + +
+

+ Diagnosa Selesai +

+

+ {{ now()->format('d F Y, H:i') }} WIB +

+
+ + +
+

Informasi Tanaman

+

Nama: {{ $diagnosis->plant_name }}

+
+ + +
+

+ Gejala yang Dilaporkan ({{ count($diagnosis->symptoms ?? []) }} gejala) +

+
    + @foreach($diagnosis->symptoms ?? [] as $kode => $data) + @php + $cfVal = $data['cf']; + if ($cfVal == 0.0) { + $cfColor = '#6b7280'; $cfBg = '#f3f4f6'; + } elseif ($cfVal <= 0.2) { + $cfColor = '#991b1b'; $cfBg = '#fee2e2'; + } elseif ($cfVal <= 0.4) { + $cfColor = '#92400e'; $cfBg = '#fef3c7'; + } elseif ($cfVal <= 0.6) { + $cfColor = '#1d4ed8'; $cfBg = '#dbeafe'; + } else { + $cfColor = '#2d6a4f'; $cfBg = '#d8f3dc'; + } + @endphp +
  • + ✔ {{ $kode }} - {{ $data['nama'] }} + + {{ number_format($cfVal, 1) }} + +
  • + @endforeach +
+
+ + +
+

Hasil Diagnosa

+

Penyakit: {{ $diagnosis->disease_name }}

+ + @php + $conf = $diagnosis->confidence; + $confVal = $conf / 100; + if ($confVal <= 0.20) { + $label = 'Tidak Terdeteksi'; $labelColor = '#6b7280'; $labelBg = '#f3f4f6'; + } elseif ($confVal <= 0.40) { + $label = 'Rendah'; $labelColor = '#991b1b'; $labelBg = '#fee2e2'; + } elseif ($confVal <= 0.60) { + $label = 'Sedang'; $labelColor = '#92400e'; $labelBg = '#fef3c7'; + } elseif ($confVal <= 0.80) { + $label = 'Tinggi'; $labelColor = '#1d4ed8'; $labelBg = '#dbeafe'; + } else { + $label = 'Sangat Tinggi'; $labelColor = '#2d6a4f'; $labelBg = '#d8f3dc'; + } + @endphp +

+ Kepercayaan: {{ $conf }}% + + {{ $label }} + +

+ +
+

Penanganan:

+ @php $treatments = explode(';', $diagnosis->treatment); @endphp +
    + @foreach($treatments as $item) + @if(trim($item) != '') +
  1. {{ trim($item) }}
  2. + @endif + @endforeach +
+ +
+ Catatan:
+ Hasil diagnosa ini bersifat awal. Untuk penanganan yang lebih tepat dan akurat, + disarankan untuk berkonsultasi langsung dengan ahli tanaman atau penyuluh pertanian. +
+
+
+ + + + + + +
+ + + +@endsection \ No newline at end of file diff --git a/resources/views/diseases/create.blade.php b/resources/views/diseases/create.blade.php new file mode 100644 index 0000000..c583531 --- /dev/null +++ b/resources/views/diseases/create.blade.php @@ -0,0 +1,279 @@ +@extends('layouts.app') +@section('title', isset($isEdit) ? 'Edit Penyakit' : 'Tambah Penyakit') +@section('page-title', isset($isEdit) ? 'Edit Penyakit' : 'Tambah Penyakit Baru') + +@section('content') + + Kembali + + +
+ @csrf + @if(isset($isEdit)) + @method('PUT') + @endif + + +
+

+ Informasi Penyakit +

+
+
+ + +
+
+ + + @error('name')

{{ $message }}

@enderror +
+
+ + +
+
+ + @if(isset($disease) && $disease->photo) +
+ +

Unggah foto baru untuk mengganti

+
+ @endif + +
+
+ + +
+
+
+ + +
+
+

+ Gejala (minimal 3) +

+ +
+ @error('symptoms')

{{ $message }}

@enderror + +
+ @if(isset($isEdit) && $disease->symptoms->count() > 0) + @foreach($disease->symptoms as $i => $symptom) +
+
+ Gejala {{ $i+1 }} + @if($i >= 3) + + @endif +
+
+
+ + +
+
+ + +
+
+ + @if($symptom->photo) +
+ +

Unggah foto baru untuk mengganti

+
+ @endif + +
+
+
+ @endforeach + @else + @for($i = 0; $i < 3; $i++) +
+
+ Gejala {{ $i+1 }} +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ @endfor + @endif +
+
+ + +
+
+

+ Cara Penanganan (minimal 3) +

+ +
+ @error('treatments')

{{ $message }}

@enderror + +
+ @if(isset($isEdit) && $disease->treatments->count() > 0) + @foreach($disease->treatments as $i => $t) +
+
{{ $i+1 }}
+
+ +
+ @if($i >= 3) + + @endif +
+ @endforeach + @else + @for($i = 0; $i < 3; $i++) +
+
{{ $i+1 }}
+
+ +
+
+ @endfor + @endif +
+
+ + +
+ + + Batal + +
+ +
+@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/diseases/index.blade.php b/resources/views/diseases/index.blade.php new file mode 100644 index 0000000..a654b33 --- /dev/null +++ b/resources/views/diseases/index.blade.php @@ -0,0 +1,165 @@ +@extends('layouts.app') +@section('title', 'Kamus Penyakit') +@section('page-title', 'Kamus Penyakit') + +@section('content') + +@if(session('status')) +
+ {{ session('status') }} +
+@endif + + +
+

Total {{ $diseases->count() }} penyakit terdaftar

+ @if(auth()->user()->isAdmin()) + + Tambah Penyakit + + @endif +
+ + +
+ @forelse($diseases as $disease) +
+ + +
+ @if($disease->photo) + + @else +
+ +

Belum ada foto

+
+ @endif +
+ + +
+
+ + {{ $disease->code }} + + {{ $disease->symptoms->count() }} gejala +
+ +

+ {{ $disease->name }} +

+

{{ $disease->latin_name }}

+

{{ $disease->description }}

+ + +
+ @foreach($disease->symptoms->take(3) as $s) + + {{ $s->code }} + + @endforeach + @if($disease->symptoms->count() > 3) + + +{{ $disease->symptoms->count() - 3 }} lainnya + + @endif +
+ + +
+ + Lihat Detail + + @if(auth()->user()->isAdmin()) + + @endif +
+
+
+ @empty +
+ +

Belum ada data penyakit

+
+ @endforelse +
+ + + + +@endsection + +@push('scripts') + + + +@endpush \ No newline at end of file diff --git a/resources/views/diseases/show.blade.php b/resources/views/diseases/show.blade.php new file mode 100644 index 0000000..308aaba --- /dev/null +++ b/resources/views/diseases/show.blade.php @@ -0,0 +1,165 @@ +@extends('layouts.app') +@section('title', $disease->name) +@section('page-title', 'Detail Penyakit') + +@section('content') + + +
+ + Kembali ke Kamus + + + @if(auth()->user()->isAdmin()) + + Edit Penyakit + + +@endif +
+ +
+ + +
+
+
+ @if($disease->photo) + + @else + + @endif +
+
+ {{ $disease->code }} +

{{ $disease->name }}

+

{{ $disease->latin_name }}

+

{{ $disease->description }}

+
+
+
+ + +
+ + +
+

+ Gejala-Gejala +

+
+ @foreach($disease->symptoms as $symptom) +
+ @if($symptom->photo) + + @else +
+ +
+ @endif +
+ {{ $symptom->code }} +

{{ $symptom->name }}

+

CF: {{ $symptom->pivot->cf_value }}

+
+
+ @endforeach +
+
+ + +
+

+ Cara Penanganan +

+
+ @foreach($disease->treatments as $i => $t) +
+
{{ $i+1 }}
+
+ {{ $t->code }} +

{{ $t->description }}

+
+
+ @endforeach +
+
+ +
+
+ + + + +@endsection + +@push('scripts') + + + +@endpush \ No newline at end of file diff --git a/resources/views/errors/403.blade.php b/resources/views/errors/403.blade.php new file mode 100644 index 0000000..5a66fdd --- /dev/null +++ b/resources/views/errors/403.blade.php @@ -0,0 +1,34 @@ + + + + + + Akses Ditolak - PlantCare + + + + + + +
+
+ +
+

+ Akses Ditolak +

+

+ Halaman ini hanya dapat diakses oleh Ahli Tanaman. +

+

+ Akun kamu terdaftar sebagai Petani. +

+ + Kembali ke Dashboard + +
+ + \ No newline at end of file diff --git a/resources/views/history/index.blade.php b/resources/views/history/index.blade.php new file mode 100644 index 0000000..706d96e --- /dev/null +++ b/resources/views/history/index.blade.php @@ -0,0 +1,255 @@ +@extends('layouts.app') + +@section('title', 'Riwayat Diagnosa') +@section('page-title', 'Riwayat Diagnosa') + +@section('content') +
+
+

Daftar Riwayat

+ + + + +
+ + {{-- Mobile: card --}} +
+ @forelse($diagnoses as $index => $diagnosis) +
+
+
+

{{ $diagnosis->plant_name }}

+

{{ $diagnosis->disease_name }}

+
+ + {{ $diagnosis->confidence }}% + +
+
+

{{ $diagnosis->created_at->format('d/m/Y H:i') }}

+ + Detail + +
+
+ @empty +
+ +

Belum ada riwayat diagnosa

+ + Mulai diagnosa pertama Anda + +
+ @endforelse +
+ + {{-- Desktop: tabel --}} + + + @if($diagnoses->hasPages()) +
+ {{ $diagnoses->links() }} +
+ @endif +
+ + + +@endsection \ No newline at end of file diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php new file mode 100644 index 0000000..5ecfd2e --- /dev/null +++ b/resources/views/layouts/app.blade.php @@ -0,0 +1,166 @@ + + + + + + @yield('title', 'SiPakarTebu') + + + + + + + + + + +
+ + + + + + + + + +
+ + + +@stack('scripts') + + + \ No newline at end of file diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php new file mode 100644 index 0000000..bc8418e --- /dev/null +++ b/resources/views/layouts/auth.blade.php @@ -0,0 +1,234 @@ +{{-- resources/views/layouts/auth.blade.php --}} + + + + + + @yield('title', 'PlantCare') - Sistem Diagnosis Penyakit Tanaman + + + @stack('styles') + + +
+
+
🌿
+

PlantCare

+

Sistem Diagnosis Penyakit Tanaman

+
+
+ @yield('content') +
+
+@stack('scripts') + + \ No newline at end of file diff --git a/resources/views/notifications/index.blade.php b/resources/views/notifications/index.blade.php new file mode 100644 index 0000000..71a562b --- /dev/null +++ b/resources/views/notifications/index.blade.php @@ -0,0 +1,93 @@ +@extends('layouts.app') + +@section('title', 'Notifikasi') +@section('page-title', 'Notifikasi') + +@section('content') + + + + +
+ + +
+

+ Semua Notifikasi + @if($notifications->where('is_read', false)->count() > 0) + + {{ $notifications->where('is_read', false)->count() }} baru + + @endif +

+ @if($notifications->count() > 0) +
+ @csrf + @method('DELETE') + +
+ @endif +
+ + + @if($notifications->count() === 0) +
+ +

Belum ada notifikasi

+
+ @else + @foreach($notifications as $notif) +
+ + +
+ +
+ + +
+
+

{{ $notif->title }}

+ @if(!$notif->is_read) + + @endif +
+

{{ $notif->message }}

+

{{ $notif->created_at->diffForHumans() }}

+
+ + +
+ @csrf + @method('DELETE') + +
+
+ @endforeach + @endif +
+@endsection \ No newline at end of file diff --git a/resources/views/profile/index.blade.php b/resources/views/profile/index.blade.php new file mode 100644 index 0000000..2ab04ba --- /dev/null +++ b/resources/views/profile/index.blade.php @@ -0,0 +1,285 @@ +@extends('layouts.app') +@section('title', 'Profil') +@section('page-title', 'Profil Saya') + +@section('content') +
+ + @if(session('status')) +
+ {{ session('status') }} +
+ @endif + + @if($errors->any()) +
+ {{ $errors->first() }} +
+ @endif + + {{-- CARD 1: PROFIL --}} +
+
+

Profil

+
+
+
+ @csrf + + {{-- Avatar --}} +
+
+ @if($user->photo) + + @else +
+ {{ strtoupper(substr($user->name, 0, 1)) }} +
+ @endif + +
+
+

{{ $user->name }}

+

{{ $user->email }}

+ + + {{ $user->role === 'admin' ? 'Ahli Tanaman' : 'Petani' }} + +
+
+ + {{-- Nama --}} +
+ + +
+ + {{-- Email --}} +
+ + +
+ + +
+
+
+ + {{-- CARD 2: PENGATURAN AKUN --}} +
+
+

Pengaturan Akun

+

Keamanan akun dan akses

+
+
+ + {{-- Ubah Password --}} +
+
+
+ +
+
+

Ubah Kata Sandi

+

Perbarui password akun kamu

+
+
+ + Ubah + +
+ + {{-- Manajemen Perangkat --}} +
+
+
+ +
+
+

Manajemen Perangkat

+

Lihat perangkat yang mengakses akun

+
+
+ +
+ + {{-- Hapus Akun --}} +
+
+
+ +
+
+

Hapus Akun

+

Hapus akun dan semua data kamu secara permanen

+
+
+ +
+
+
+ + {{-- CARD 3: LAIN-LAIN --}} +
+
+

Lain-lain

+
+
+ + {{-- Logout --}} +
+
+
+ +
+
+

Logout

+

Keluar dari akun ini

+
+
+
+ @csrf + +
+
+ + {{-- Bantuan --}} +
+
+
+ +
+
+

Bantuan & Dukungan

+

Hubungi kami jika ada masalah

+
+
+ + Hubungi + +
+ +
+
+ +
+ +{{-- Modal Manajemen Perangkat --}} + + +{{-- Modal Hapus Akun --}} + + +@endsection + +@push('scripts') + + +@endpush \ No newline at end of file diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php new file mode 100644 index 0000000..eb6ab3f --- /dev/null +++ b/resources/views/users/index.blade.php @@ -0,0 +1,274 @@ +@extends('layouts.app') +@section('title', 'Kelola User') +@section('page-title', 'Kelola User') + +@section('content') + +@if(session('status')) +
+ {{ session('status') }} +
+@endif + +@if($errors->any()) +
+ {{ $errors->first() }} +
+@endif + +{{-- Kartu Statistik --}} +
+
+

Total User

+

+ {{ $totalAdmin + $totalUser }} +

+
+
+

Ahli Tanaman

+

+ {{ $totalAdmin }} +

+
+
+

Petani

+

+ {{ $totalUser }} +

+
+
+ +{{-- Filter & Search --}} +
+
+
+ + +
+
+ + +
+ + @if(request('search') || request('role')) + + Reset + + @endif +
+
+ +{{-- Tabel User --}} +
+ + {{-- Mobile: card --}} + + + {{-- Desktop: tabel --}} +
+ + + + + + + + + + + + @forelse($users as $user) + + + + + + + + @empty + + + + @endforelse + +
UserEmailRoleBergabungAksi
+
+
+ {{ strtoupper(substr($user->name, 0, 1)) }} +
+
+

+ {{ $user->name }} + @if($user->id === auth()->id()) + (Kamu) + @endif +

+
+
+
{{ $user->email }} + + + {{ $user->role === 'admin' ? 'Ahli Tanaman' : 'Petani' }} + + {{ $user->created_at->format('d M Y') }} + @if($user->id !== auth()->id()) +
+
+ @csrf + @method('PATCH') + + +
+ +
+ @else + + @endif +
+ + Tidak ada user ditemukan +
+
+ + @if($users->hasPages()) +
+ {{ $users->links() }} +
+ @endif +
+ +{{-- Modal Konfirmasi Hapus User --}} + + +@endsection + +@push('scripts') + + +@endpush \ No newline at end of file diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php new file mode 100644 index 0000000..1956ce7 --- /dev/null +++ b/resources/views/welcome.blade.php @@ -0,0 +1,870 @@ + + + + + + + SiPakarTebu — Diagnosa Penyakit Tanaman Tebu + + + + + + + + + +
+
+
Didukung data oleh pakar
+

Kenali Penyakit
Tanaman Tebu
Lebih Cepat

+

+ SiPakarTebu menggunakan kecerdasan buatan berbasis Certainty Factor untuk mendiagnosa penyakit tanaman secara akurat — cukup jawab beberapa pertanyaan gejala. +

+
+ {{-- Tombol utama: buka modal diagnosis guest --}} + + + Sudah punya akun? + +
+
+
+ 90% + Akurasi Rata-rata +
+
+ 10 + Jenis Penyakit +
+
+ 1 mnt + Waktu Diagnosa +
+
+
+ +
+
+
✓ Diagnosa Selesai
+
+ + + + + + + + + + +
+
⚠️ Terdeteksi
+
Luka Api (Leaf Scald)
+
Penyakit disebabkan bakteri Xanthomonas albilineans
+
+
Tingkat Kepastian78%
+
+
+
+
Kesesuaian Gejala91%
+
+
+
+
+
+ + +
+ +

Teknologi Cermat untuk
Kebun Sehat Kamu

+

Sistem pakar kami menggabungkan pengetahuan ahli pertanian dengan teknologi untuk hasil diagnosa yang dapat dipercaya.

+
+
🔬
Diagnosa Akurat

Menggunakan metode Certainty Factor yang telah terbukti untuk mengidentifikasi penyakit tanaman dengan tingkat akurasi tinggi.

+
Hasil Instan

Dapatkan hasil diagnosa dalam hitungan menit. Cukup jawab pertanyaan gejala dan sistem kami akan menganalisis secara otomatis.

+
📋
Rekomendasi Penanganan

Setiap hasil diagnosa disertai panduan penanganan lengkap yang bisa langsung dipraktikkan di kebun kamu.

+
📊
Riwayat Diagnosa

Simpan dan pantau semua diagnosa sebelumnya. Lacak perkembangan kesehatan tanaman kamu dari waktu ke waktu.

+
🌱
Database Penyakit Lengkap

Mencakup lebih dari 5 jenis penyakit umum pada tanaman dengan gejala dan penanganan yang terperinci.

+
🛡️
Data Aman & Privat

Data diagnosa kamu tersimpan dengan aman. Hanya kamu yang bisa mengakses riwayat dan hasil diagnosa milik kamu.

+
+
+ + +
+
+
+

BERITA & RISET TERKINI

+

Dari Sumber Terpercaya

+

Artikel terbaru seputar pertanian dan penyakit tanaman dari media & lembaga penelitian nasional

+
+ @if(isset($articles) && count($articles) > 0) + + + @else +

Artikel sedang dimuat...

+ @endif +
+
+ + +
+

Mulai Sekarang

+

Jaga Tanaman Kamu
tetap Sehat & Subur

+

Daftar gratis sekarang dan mulai diagnosa pertama kamu dalam hitungan menit. Tidak perlu kartu kredit.

+ +
+ + +
+

© 2026 SiPakarTebu — Sistem Pakar Diagnosa Penyakit Tanaman. Dibuat dengan 🌿

+
+ + +
+
+ + +
+ +
+ + Coba Diagnosa + +
+
+ + Step 1 dari 3 — + Pilih Gejala + +
+
+
+
+
+ + +
+ + +
+

Pilih Gejala

+

Pilih minimal 4 gejala yang tampak pada tanaman

+ + + +
+
+ ⏳ Memuat daftar gejala... +
+
+ +
+ + Dipilih: 0 gejala + + +
+ +
+ +
+
+ + +
+

Tingkat Keyakinan

+

Seberapa yakin kamu melihat gejala ini pada tanaman?

+ +
+ +
+ + +
+
+ + +
+

Konfirmasi

+

Periksa kembali sebelum diagnosa dimulai

+ +
+ +
+ + +
+
+ + +
+
+
+

Menganalisis gejala...

+
+
+ + +
+

Hasil Diagnosa

+

Berdasarkan gejala yang kamu pilih

+ + +
+ + +
+
+

Penanganan yang Disarankan

+

• Semprot fungisida berbasis tembaga...

+

• Cabut dan musnahkan tanaman yang terinfeksi berat

+

• Lakukan rotasi tanaman minimal 2 musim

+

Kemungkinan Penyakit Lain

+

• ██████████ (██%)

+

• ██████████ (██%)

+
+
+
🔒
+
Detail Lengkap Dikunci
+
Daftar atau masuk untuk melihat penanganan lengkap, kemungkinan penyakit lain, dan simpan riwayat diagnosa kamu.
+ + Daftar Gratis — Lihat Hasil Lengkap + + + Sudah punya akun? Masuk + +
+
+ + +
+ +
+
+
+ + + + + + \ No newline at end of file diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..3c9adf1 --- /dev/null +++ b/routes/console.php @@ -0,0 +1,8 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..a0f326d --- /dev/null +++ b/routes/web.php @@ -0,0 +1,104 @@ +name('guest.symptoms'); +Route::post('/guest/diagnosis', [GuestDiagnosisController::class, 'process'])->name('guest.diagnosis'); + + +// ── Halaman utama ───────────────────────────────────────────────── +Route::get('/', [WelcomeController::class, 'index'])->name('home'); + +Route::delete('/profile/delete', [ProfileController::class, 'destroy'])->name('profile.destroy')->middleware('auth'); + +// ── Guest routes ────────────────────────────────────────────────── +Route::middleware('guest')->group(function () { + Route::get('/login', [AuthController::class, 'showLogin'])->name('login'); + Route::get('/register', [AuthController::class, 'showRegister'])->name('register'); + Route::post('/login', [AuthController::class, 'login']); + Route::post('/register',[AuthController::class, 'register']); + + Route::get('/forgot-password', [ForgotPasswordController::class, 'showEmailForm'])->name('password.email'); + Route::post('/forgot-password', [ForgotPasswordController::class, 'sendOtp'])->name('password.send-otp'); + Route::get('/forgot-password/otp', [ForgotPasswordController::class, 'showOtpForm'])->name('password.otp.form'); + Route::post('/forgot-password/otp', [ForgotPasswordController::class, 'verifyOtp'])->name('password.otp.verify'); + Route::post('/forgot-password/otp/resend', [ForgotPasswordController::class, 'resendOtp'])->name('password.otp.resend'); + Route::get('/forgot-password/reset', [ForgotPasswordController::class, 'showResetForm'])->name('password.reset.form'); + Route::post('/forgot-password/reset', [ForgotPasswordController::class, 'resetPassword'])->name('password.reset'); +}); + +// ── Logout ──────────────────────────────────────────────────────── +Route::post('/logout', [AuthController::class, 'logout'])->name('logout'); + +// ── Authenticated routes ───────────────────────────────────────── +Route::middleware('auth')->group(function () { + + // Dashboard + Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard'); + + // Diagnosis + Route::prefix('diagnosis')->name('diagnosis.')->group(function () { + Route::get('/', [DiagnosisController::class, 'index'])->name('index'); + Route::get('/create', [DiagnosisController::class, 'create'])->name('create'); + Route::post('/', [DiagnosisController::class, 'store'])->name('store'); + Route::get('/{id}/result', [DiagnosisController::class, 'result'])->name('result'); + }); + + // Riwayat + Route::get('/riwayat', [HistoryController::class, 'index'])->name('history'); + + // ── KAMUS (READ dulu) ───────────────────────────── + Route::get('/kamus', [DiseaseController::class, 'index'])->name('diseases.index'); + + // ⚠️ JANGAN TARUH {disease} DI ATAS + // nanti bentrok dengan /kamus/tambah + + // Notifikasi + Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications'); + Route::post('/notifications/{id}/read', [NotificationController::class, 'markRead'])->name('notifications.read'); + Route::delete('/notifications/{id}', [NotificationController::class, 'destroy'])->name('notifications.destroy'); + Route::delete('/notifications', [NotificationController::class, 'destroyAll'])->name('notifications.destroyAll'); + + // Profil + Route::get('/profile', [ProfileController::class, 'show'])->name('profile'); + Route::post('/profile', [ProfileController::class, 'update'])->name('profile.update'); + Route::post('/profile/password', [ProfileController::class, 'updatePassword'])->name('profile.password'); +}); + +// ── ADMIN ONLY ─────────────────────────────────────────────────── +Route::middleware(['auth', 'role:admin'])->group(function () { + + Route::get('/kamus/tambah', [DiseaseController::class, 'create'])->name('diseases.create'); + Route::post('/kamus', [DiseaseController::class, 'store'])->name('diseases.store'); + Route::delete('/kamus/{disease}', [DiseaseController::class, 'destroy'])->name('diseases.destroy'); + Route::get('/diseases/{disease}/edit',[DiseaseController::class, 'edit'])->name('diseases.edit'); + Route::put('/diseases/{disease}', [DiseaseController::class, 'update'])->name('diseases.update'); + + // User management + Route::get('/users', [UserController::class, 'index'])->name('users.index'); + Route::patch('/users/{user}/role', [UserController::class, 'updateRole'])->name('users.updateRole'); + Route::delete('/users/{user}', [UserController::class, 'destroy'])->name('users.destroy'); +}); + +// ── TARUH PALING BAWAH (ANTI BENTROK) ─────────────────────────── +Route::get('/kamus/{disease}', [DiseaseController::class, 'show']) + ->middleware('auth') + ->name('diseases.show'); + +// Redirect /diseases/{id} ke /kamus/{id} supaya tidak 405 +Route::get('/diseases/{disease}', function($disease) { + return redirect()->route('diseases.show', $disease); +})->middleware('auth'); \ No newline at end of file