select(['merek', 'price', 'iso', 'af_point']) ->whereNotNull('price') ->whereNotNull('iso') ->whereNotNull('af_point') ->get(); $allGuide = [ 'label' => 'Semua Merek', 'min_price' => (int) $kameras->min('price'), 'max_price' => (int) $kameras->max('price'), 'min_iso' => (int) $kameras->min('iso'), 'max_iso' => (int) $kameras->max('iso'), 'min_af_point' => (int) $kameras->min('af_point'), 'max_af_point' => (int) $kameras->max('af_point'), ]; $brandGuides = $kameras ->groupBy('merek') ->sortKeys() ->mapWithKeys(function (Collection $items, string $merek): array { return [ $merek => [ 'label' => $merek, 'min_price' => (int) $items->min('price'), 'max_price' => (int) $items->max('price'), 'min_iso' => (int) $items->min('iso'), 'max_iso' => (int) $items->max('iso'), 'min_af_point' => (int) $items->min('af_point'), 'max_af_point' => (int) $items->max('af_point'), ], ]; }) ->all(); return [ 'all' => $allGuide, 'brands' => $brandGuides, ]; } private function isAllBrands(string $merekInput): bool { return $merekInput === '' || in_array(Str::lower($merekInput), ['semua', 'semua merek', 'all'], true); } private function buildNoResultMessage(Request $request, Collection $availableForBrand, string $merekLabel): string { if ($availableForBrand->isEmpty()) { return 'Merek kamera yang dipilih belum tersedia di dataset.'; } $parts = []; if ($request->filled('budget')) { $parts[] = 'budget tersedia Rp ' . number_format((int) $availableForBrand->min('price'), 0, ',', '.') . ' sampai Rp ' . number_format((int) $availableForBrand->max('price'), 0, ',', '.'); } if ($request->filled('iso_min') || $request->filled('iso_max')) { $parts[] = 'ISO tersedia ' . number_format((int) $availableForBrand->min('iso'), 0, ',', '.') . ' sampai ' . number_format((int) $availableForBrand->max('iso'), 0, ',', '.'); } if ($parts === []) { $parts[] = 'harga tersedia Rp ' . number_format((int) $availableForBrand->min('price'), 0, ',', '.') . ' sampai Rp ' . number_format((int) $availableForBrand->max('price'), 0, ',', '.'); $parts[] = 'ISO tersedia ' . number_format((int) $availableForBrand->min('iso'), 0, ',', '.') . ' sampai ' . number_format((int) $availableForBrand->max('iso'), 0, ',', '.'); } return 'Tidak ada kamera yang cocok dengan filter yang dipilih untuk ' . $merekLabel . '. ' . 'Data saat ini memiliki ' . implode(' dan ', $parts) . '. ' . 'Coba longgarkan salah satu filter atau kosongkan budget/ISO terlebih dahulu.'; } // Halaman landing public function index() { $merekList = Kamera::select('merek')->distinct()->orderBy('merek')->pluck('merek'); $filterGuide = $this->getFilterGuide(); return view('landing', compact('merekList', 'filterGuide')); } // Form rekomendasi public function form() { $merekList = Kamera::select('merek')->distinct()->orderBy('merek')->pluck('merek'); $filterGuide = $this->getFilterGuide(); return view('form_rekomendasi', compact('merekList', 'filterGuide')); } /** * Menghitung skor kriteria kamera sesuai notebook Jupyter: * Price (cost), ISO (benefit), AF Point (benefit), Sensor (benefit). */ private function hitungSkorKriteria(Collection $kameras): array { $minPrice = (float) $kameras->min('price'); $maxPrice = (float) $kameras->max('price'); $maxIso = max((float) $kameras->max('iso'), 1); $maxAfPoint = max((float) $kameras->max('af_point'), 1); $skorKriteria = []; foreach ($kameras as $k) { $priceScore = ($maxPrice === $minPrice) ? 1.0 : (($maxPrice - (float) $k->price) / ($maxPrice - $minPrice)) * 1.0; $isoScore = ((float) $k->iso / $maxIso) * 1.0; $afScore = ((float) $k->af_point / $maxAfPoint) * 1.0; $skorKriteria[] = [ 'price_score' => round($priceScore, 2), 'iso_score' => round($isoScore, 2), 'af_score' => round($afScore, 2), 'sensor_score' => round((float) $k->sensor_score / 10.0, 2), ]; } return $skorKriteria; } /** * Bobot mengikuti notebook Jupyter. * Urutan bobot: [Price, ISO, AF Point, Sensor] */ private function getBobotByPenggunaan(string $penggunaan): array { $bobotMap = [ 'outdoor_day' => [0.30, 0.15, 0.25, 0.30], 'outdoor_night' => [0.15, 0.50, 0.15, 0.20], 'indoor_cerah' => [0.35, 0.20, 0.20, 0.25], 'indoor_gelap' => [0.15, 0.55, 0.15, 0.15], 'all' => [0.25, 0.30, 0.20, 0.25], ]; return $bobotMap[$penggunaan] ?? [0.25, 0.25, 0.25, 0.25]; } // Proses rekomendasi (TOPSIS) public function proses(Request $request) { // Validasi input $request->validate([ 'penggunaan' => 'required|in:outdoor_day,outdoor_night,indoor_cerah,indoor_gelap,all', 'merek' => 'nullable|string', 'budget' => 'nullable|integer|min:0', 'iso_min' => 'nullable|integer', 'iso_max' => 'nullable|integer|gte:iso_min', ]); // Cek apakah data kamera ada if (Kamera::all()->isEmpty()) { return back()->withInput()->with('error', 'Data kamera masih kosong. Silakan isi data kamera terlebih dahulu.'); } $merekInput = trim((string) $request->input('merek', '')); $query = Kamera::query(); if (! $this->isAllBrands($merekInput)) { $query->whereRaw('LOWER(merek) = ?', [Str::lower($merekInput)], 'and'); } $availableForBrand = (clone $query)->get(); $merekLabel = $this->isAllBrands($merekInput) ? 'semua merek' : $merekInput; if ($availableForBrand->isEmpty()) { return back()->withInput()->with('error', 'Merek kamera yang dipilih belum tersedia di dataset.'); } if ($request->budget && $request->budget > 0) { $query->where('price', '<=', $request->budget); } if ($request->filled('iso_min')) { $query->where('iso', '>=', $request->integer('iso_min')); } if ($request->filled('iso_max')) { $query->where('iso', '<=', $request->integer('iso_max')); } $kameras = $query->get(); if ($kameras->isEmpty()) { return back() ->withInput() ->with('error', $this->buildNoResultMessage($request, $availableForBrand, $merekLabel)); } $skorKriteria = $this->hitungSkorKriteria($kameras); $dataMatrix = []; foreach ($skorKriteria as $skor) { $dataMatrix[] = [ $skor['price_score'], $skor['iso_score'], $skor['af_score'], $skor['sensor_score'], ]; } $bobot = $this->getBobotByPenggunaan($request->penggunaan); $jenis = ['cost', 'benefit', 'benefit', 'benefit']; $penggunaanLabels = [ 'outdoor_day' => 'Outdoor - Day (Siang)', 'outdoor_night' => 'Outdoor - Night (Malam)', 'indoor_cerah' => 'Indoor - Cerah', 'indoor_gelap' => 'Indoor - Gelap', 'all' => 'Semua Kondisi', ]; $penggunaanLabel = $penggunaanLabels[$request->penggunaan] ?? $request->penggunaan; try { $scores = TopsisService::hitung($dataMatrix, $bobot, $jenis); $hasil = []; foreach ($kameras as $i => $k) { $hasil[] = (object)[ 'id' => $k->id, 'nama' => $k->nama, 'merek' => $k->merek, 'image_path' => $k->image_path, 'harga' => $k->price, 'iso' => $k->iso, 'af_point' => $k->af_point, 'sensor' => $k->sensor, 'price_score' => $skorKriteria[$i]['price_score'], 'iso_score' => $skorKriteria[$i]['iso_score'], 'af_score' => $skorKriteria[$i]['af_score'], 'sensor_score' => $skorKriteria[$i]['sensor_score'], 'skor' => round($scores[$i], 4), ]; } usort($hasil, fn($a, $b) => $b->skor <=> $a->skor); $top3 = array_slice($hasil, 0, 3); foreach ($top3 as $k) { $lensa = Lensa::query() ->where('merek', $k->merek) ->orderByDesc('aperture_score') ->first(); $k->lensa_rekomendasi = $lensa ? $lensa->nama : null; $k->harga_lensa = $lensa ? $lensa->harga_sewa : 0; $k->total_harga = $k->harga + ($lensa ? $lensa->harga_sewa : 0); } } catch (\Throwable $e) { report($e); return back() ->withInput() ->with('error', 'Proses rekomendasi gagal: ' . $e->getMessage()); } return view('hasil_rekomendasi', [ 'hasil' => $top3, 'total_kamera_diproses' => count($kameras), 'penggunaan_terpilih' => $penggunaanLabel, 'bobot_yang_digunakan' => $bobot, ]); } // Dashboard admin public function dashboard() { $kameras = Kamera::all(); $lensas = Lensa::all(); $penyewaans = Penyewaan::with(['kamera', 'lensa']) ->latest() ->take(15) ->get(); $users = User::query() ->where('role', 'user') ->withCount('penyewaans') ->latest() ->get(); return view('dashboard', compact('kameras', 'lensas', 'penyewaans', 'users')); } public function createKameraAdmin(): View { return view('admin.kamera_form', [ 'kamera' => new Kamera([]), 'formMode' => 'create', 'sensorOptions' => KameraScoringService::sensorOptions(), ]); } public function storeKameraAdmin(Request $request): RedirectResponse { $validated = $request->validate([ 'nama' => ['required', 'string', 'max:255'], 'merek' => ['required', 'string', 'max:255'], 'harga_sewa' => ['required', 'integer', 'min:0'], 'iso' => ['required', 'integer', 'min:0'], 'af_point' => ['required', 'integer', 'min:0'], 'sensor' => ['required', 'string', 'max:255'], ]); $kamera = Kamera::create(array_merge( $validated, KameraScoringService::deriveFromSpecs($validated) )); $kamera->update([ 'kode_kamera' => CatalogCodeService::forKameraId((int) $kamera->id), ]); return redirect()->route('dashboard')->with('status', 'Data kamera baru berhasil ditambahkan.'); } public function editKameraAdmin(Kamera $kamera): View { return view('admin.kamera_form', [ 'kamera' => $kamera, 'formMode' => 'edit', 'sensorOptions' => KameraScoringService::sensorOptions(), ]); } public function updateKameraAdmin(Request $request, Kamera $kamera): RedirectResponse { $validated = $request->validate([ 'nama' => ['required', 'string', 'max:255'], 'merek' => ['required', 'string', 'max:255'], 'harga_sewa' => ['required', 'integer', 'min:0'], 'iso' => ['required', 'integer', 'min:0'], 'af_point' => ['required', 'integer', 'min:0'], 'sensor' => ['required', 'string', 'max:255'], ]); $kamera->update(array_merge( $validated, KameraScoringService::deriveFromSpecs($validated) )); return redirect()->route('dashboard')->with('status', 'Data kamera berhasil diperbarui.'); } public function destroyKameraAdmin(Kamera $kamera): RedirectResponse { $nama = $kamera->nama; Kamera::destroy($kamera->getKey()); return redirect()->route('dashboard')->with('status', 'Data kamera ' . $nama . ' berhasil dihapus.'); } public function createLensaAdmin(): View { return view('admin.lensa_form', [ 'lensa' => new Lensa([]), 'formMode' => 'create', ]); } public function storeLensaAdmin(Request $request): RedirectResponse { $validated = $request->validate([ 'nama' => ['required', 'string', 'max:255'], 'merek' => ['required', 'string', 'max:255'], 'harga_sewa' => ['required', 'integer', 'min:0'], 'aperture_score' => ['required', 'numeric', 'min:0', 'max:10'], 'stabilisasi' => ['required', 'numeric', 'min:0', 'max:10'], 'fleksibilitas' => ['required', 'numeric', 'min:0', 'max:10'], ]); $lensa = Lensa::create($validated); $lensa->update([ 'kode_lensa' => CatalogCodeService::forLensaId((int) $lensa->id), ]); return redirect()->route('dashboard')->with('status', 'Data lensa baru berhasil ditambahkan.'); } public function editLensaAdmin(Lensa $lensa): View { return view('admin.lensa_form', [ 'lensa' => $lensa, 'formMode' => 'edit', ]); } public function updateLensaAdmin(Request $request, Lensa $lensa): RedirectResponse { $validated = $request->validate([ 'nama' => ['required', 'string', 'max:255'], 'merek' => ['required', 'string', 'max:255'], 'harga_sewa' => ['required', 'integer', 'min:0'], 'aperture_score' => ['required', 'numeric', 'min:0', 'max:10'], 'stabilisasi' => ['required', 'numeric', 'min:0', 'max:10'], 'fleksibilitas' => ['required', 'numeric', 'min:0', 'max:10'], ]); $lensa->update($validated); return redirect()->route('dashboard')->with('status', 'Data lensa berhasil diperbarui.'); } public function destroyLensaAdmin(Lensa $lensa): RedirectResponse { $nama = $lensa->nama; Lensa::destroy($lensa->getKey()); return redirect()->route('dashboard')->with('status', 'Data lensa ' . $nama . ' berhasil dihapus.'); } public function verifyUserMember(Request $request, User $user) { if (! $user->isUser()) { return back()->with('error', 'Verifikasi hanya bisa dilakukan untuk akun user.'); } $validated = $request->validate([ 'member_level' => ['required', 'in:tanpa_member,bronze,silver,gold'], ]); $user->update([ 'member_level' => $validated['member_level'], ]); return back()->with( 'status', 'Level member untuk ' . $user->name . ' berhasil diperbarui menjadi ' . Str::of($validated['member_level'])->replace('_', ' ')->title() . '.' ); } public function updatePenyewaanStatus(Request $request, Penyewaan $penyewaan) { $validated = $request->validate([ 'status' => ['required', 'in:disetujui,ditolak,dikembalikan'], ]); if ($penyewaan->status === $validated['status']) { return back()->with('status', 'Status penyewaan sudah sesuai dan tidak ada perubahan.'); } InventoryStockService::transaction(function () use ($penyewaan, $validated): void { InventoryStockService::syncPenyewaanStock( $penyewaan, (int) $penyewaan->qty, $validated['status'] ); $penyewaan->update([ 'status' => $validated['status'], ]); }); return back()->with( 'status', 'Pengajuan sewa atas nama ' . $penyewaan->nama_penyewa . ' berhasil ' . match ($validated['status']) { 'disetujui' => 'disetujui', 'dikembalikan' => 'ditandai sudah dikembalikan', default => 'ditolak', } . '.' ); } public function updatePenyewaanAdminNote(Request $request, Penyewaan $penyewaan): RedirectResponse { $validated = $request->validate([ 'admin_catatan' => ['nullable', 'string', 'max:2000'], ]); $catatanBaru = filled($validated['admin_catatan'] ?? null) ? trim((string) $validated['admin_catatan']) : null; $updateData = [ 'admin_catatan' => $catatanBaru, ]; if ($catatanBaru !== null && $catatanBaru !== $penyewaan->admin_catatan) { $updateData['admin_catatan_read_at'] = null; } if ($catatanBaru === null) { $updateData['admin_catatan_read_at'] = null; } $penyewaan->update($updateData); return back()->with('status', 'Catatan admin untuk pengajuan sewa berhasil diperbarui.'); } public function editPenyewaan(Penyewaan $penyewaan): View { return view('admin.penyewaan_edit', [ 'penyewaan' => $penyewaan->load(['kamera', 'lensa']), ]); } public function updatePenyewaan(Request $request, Penyewaan $penyewaan): RedirectResponse { $validated = $request->validate([ 'nama_penyewa' => ['required', 'string', 'max:100'], 'email' => ['required', 'email'], 'telepon' => ['required', 'string', 'max:30'], 'no_wa' => ['required', 'string', 'max:30'], 'alamat_ktp' => ['required', 'string', 'max:500'], 'alamat_tinggal' => ['required', 'string', 'max:500'], 'instagram' => ['nullable', 'string', 'max:100'], 'jaminan' => ['required', 'string', 'max:255'], 'member_level' => ['required', 'in:tanpa_member,bronze,silver,gold'], 'qty' => ['required', 'integer', 'min:1', 'max:'.max(Kamera::DEFAULT_STOCK, Lensa::DEFAULT_STOCK)], 'tanggal_mulai' => ['required', 'date'], 'tanggal_selesai' => ['required', 'date', 'after_or_equal:tanggal_mulai'], 'jam_sewa' => ['required', 'date_format:H:i'], 'jam_kembali' => ['required', 'date_format:H:i'], 'metode_pembayaran' => ['required', 'in:qris,transfer_bca'], 'status' => ['required', 'in:menunggu,disetujui,ditolak,dikembalikan'], 'catatan' => ['nullable', 'string', 'max:500'], 'admin_catatan' => ['nullable', 'string', 'max:2000'], 'foto_jaminan' => ['nullable', 'image', 'mimes:jpg,jpeg,png', 'max:5120'], 'foto_diri' => ['nullable', 'image', 'mimes:jpg,jpeg,png', 'max:5120'], 'bukti_pembayaran' => ['nullable', 'file', 'mimes:jpg,jpeg,png,pdf', 'max:5120'], ]); $durasiHari = Carbon::parse($validated['tanggal_mulai']) ->diffInDays(Carbon::parse($validated['tanggal_selesai'])) + 1; $hargaHarian = match ($penyewaan->jenis_sewa) { 'kamera' => (int) ($penyewaan->kamera->harga_sewa ?? 0), 'lensa' => (int) ($penyewaan->lensa->harga_sewa ?? 0), default => (int) (($penyewaan->kamera->harga_sewa ?? 0) + ($penyewaan->lensa->harga_sewa ?? 0)), }; $memberDiskon = [ 'tanpa_member' => 0.00, 'bronze' => 0.05, 'silver' => 0.10, 'gold' => 0.15, ]; $subTotal = $hargaHarian * $validated['qty'] * $durasiHari; $diskon = (int) round($subTotal * ($memberDiskon[$validated['member_level']] ?? 0)); $totalHarga = max($subTotal - $diskon, 0); $updateData = [ 'nama_penyewa' => $validated['nama_penyewa'], 'email' => $validated['email'], 'telepon' => $validated['telepon'], 'no_wa' => $validated['no_wa'], 'alamat_ktp' => $validated['alamat_ktp'], 'alamat_tinggal' => $validated['alamat_tinggal'], 'instagram' => $validated['instagram'] ?? null, 'jaminan' => $validated['jaminan'], 'member_level' => $validated['member_level'], 'qty' => $validated['qty'], 'tanggal_mulai' => $validated['tanggal_mulai'], 'jam_sewa' => $validated['jam_sewa'], 'tanggal_selesai' => $validated['tanggal_selesai'], 'jam_kembali' => $validated['jam_kembali'], 'durasi_hari' => $durasiHari, 'total_harga' => $totalHarga, 'metode_pembayaran' => $validated['metode_pembayaran'], 'catatan' => $validated['catatan'] ?? null, 'admin_catatan' => filled($validated['admin_catatan'] ?? null) ? trim((string) $validated['admin_catatan']) : null, 'status' => $validated['status'], ]; if (($updateData['admin_catatan'] ?? null) !== $penyewaan->admin_catatan) { $updateData['admin_catatan_read_at'] = $updateData['admin_catatan'] ? null : null; } if ($request->hasFile('foto_jaminan')) { if ($penyewaan->foto_jaminan_path) { Storage::disk('public')->delete($penyewaan->foto_jaminan_path); } $updateData['foto_jaminan_path'] = $request->file('foto_jaminan')->store('foto-jaminan', 'public'); } if ($request->hasFile('foto_diri')) { if ($penyewaan->foto_diri_path) { Storage::disk('public')->delete($penyewaan->foto_diri_path); } $updateData['foto_diri_path'] = $request->file('foto_diri')->store('foto-diri', 'public'); } if ($request->hasFile('bukti_pembayaran')) { if ($penyewaan->bukti_pembayaran_path) { Storage::disk('public')->delete($penyewaan->bukti_pembayaran_path); } $updateData['bukti_pembayaran_path'] = $request->file('bukti_pembayaran')->store('bukti-pembayaran', 'public'); } InventoryStockService::transaction(function () use ($penyewaan, $validated, $updateData): void { InventoryStockService::syncPenyewaanStock( $penyewaan, (int) $validated['qty'], $validated['status'] ); $penyewaan->update($updateData); }); return redirect()->route('dashboard')->with('status', 'Pengajuan sewa berhasil diperbarui.'); } public function destroyPenyewaan(Penyewaan $penyewaan): RedirectResponse { if ($penyewaan->foto_jaminan_path) { Storage::disk('public')->delete($penyewaan->foto_jaminan_path); } if ($penyewaan->foto_diri_path) { Storage::disk('public')->delete($penyewaan->foto_diri_path); } if ($penyewaan->bukti_pembayaran_path) { Storage::disk('public')->delete($penyewaan->bukti_pembayaran_path); } $namaPenyewa = $penyewaan->nama_penyewa; InventoryStockService::transaction(function () use ($penyewaan): void { InventoryStockService::restoreForDeletion($penyewaan); Penyewaan::destroy($penyewaan->getKey()); }); return redirect()->route('dashboard')->with('status', 'Pengajuan sewa atas nama ' . $namaPenyewa . ' berhasil dihapus.'); } }