feat: modernize mobile UI and normalize laundry service handling
This commit is contained in:
parent
a8e72f1203
commit
864be692e8
|
|
@ -62,9 +62,6 @@ public function login(LoginRequest $request)
|
||||||
], 401);
|
], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hapus token lama untuk device ini (limit token bloat)
|
|
||||||
$user->tokens()->where('name', 'mobile-app-token')->delete();
|
|
||||||
|
|
||||||
$token = $user->createToken('mobile-app-token')->plainTextToken;
|
$token = $user->createToken('mobile-app-token')->plainTextToken;
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ public function calculateKontrakan(Request $request)
|
||||||
public function calculateLaundry(Request $request)
|
public function calculateLaundry(Request $request)
|
||||||
{
|
{
|
||||||
$validator = Validator::make($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'jenis_layanan' => 'nullable|string|in:reguler,express',
|
'jenis_layanan' => 'nullable|string|in:harian,jam,reguler,express,kiloan,satuan,kilat',
|
||||||
'harga_min' => 'nullable|numeric',
|
'harga_min' => 'nullable|numeric',
|
||||||
'harga_max' => 'nullable|numeric',
|
'harga_max' => 'nullable|numeric',
|
||||||
'jarak_max' => 'nullable|numeric',
|
'jarak_max' => 'nullable|numeric',
|
||||||
|
|
@ -468,8 +468,12 @@ private function prosesMetodeSAWLaundry($items, $kriteria, $customBobot = null,
|
||||||
private function mapJenisLayananQueryValues($jenisLayanan)
|
private function mapJenisLayananQueryValues($jenisLayanan)
|
||||||
{
|
{
|
||||||
$map = [
|
$map = [
|
||||||
'reguler' => ['reguler', 'kiloan'],
|
// New canonical labels for mobile
|
||||||
'express' => ['express', 'satuan'],
|
'harian' => ['harian', 'reguler', 'kiloan'],
|
||||||
|
'jam' => ['jam', 'express', 'satuan', 'kilat'],
|
||||||
|
// Backward compatibility for old clients/data
|
||||||
|
'reguler' => ['harian', 'reguler', 'kiloan'],
|
||||||
|
'express' => ['jam', 'express', 'satuan', 'kilat'],
|
||||||
];
|
];
|
||||||
|
|
||||||
return $map[$jenisLayanan] ?? [$jenisLayanan];
|
return $map[$jenisLayanan] ?? [$jenisLayanan];
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
@ -31,10 +32,13 @@ public function index(Request $request)
|
||||||
$files = File::files($this->backupPath);
|
$files = File::files($this->backupPath);
|
||||||
|
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
|
$modifiedAt = Carbon::createFromTimestamp($file->getMTime(), config('app.timezone'));
|
||||||
|
|
||||||
$backups[] = [
|
$backups[] = [
|
||||||
'name' => $file->getFilename(),
|
'name' => $file->getFilename(),
|
||||||
'size' => $file->getSize(),
|
'size' => $file->getSize(),
|
||||||
'date' => $file->getMTime(),
|
'date' => $modifiedAt->timestamp,
|
||||||
|
'date_display' => $modifiedAt->format('d M Y H:i'),
|
||||||
'path' => $file->getRealPath(),
|
'path' => $file->getRealPath(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -376,6 +376,72 @@ public function destroy(Booking $booking)
|
||||||
return redirect()->route('admin.bookings.index')->with('success', 'Booking berhasil dihapus.');
|
return redirect()->route('admin.bookings.index')->with('success', 'Booking berhasil dihapus.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hapus booking secara massal (terpilih / semua data sesuai filter)
|
||||||
|
*/
|
||||||
|
public function bulkDestroy(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'action' => 'required|in:selected,all',
|
||||||
|
'booking_ids' => 'nullable|array',
|
||||||
|
'booking_ids.*' => 'integer|exists:bookings,id',
|
||||||
|
'status' => 'nullable|string',
|
||||||
|
'kontrakan_id' => 'nullable|integer|exists:kontrakans,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$query = Booking::query();
|
||||||
|
|
||||||
|
if ($request->action === 'selected') {
|
||||||
|
$ids = $request->input('booking_ids', []);
|
||||||
|
|
||||||
|
if (empty($ids)) {
|
||||||
|
return back()->with('error', 'Pilih minimal 1 booking yang ingin dihapus.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->whereIn('id', $ids);
|
||||||
|
} else {
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->where('status', $request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('kontrakan_id')) {
|
||||||
|
$query->where('kontrakan_id', $request->kontrakan_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$bookings = $query->get();
|
||||||
|
|
||||||
|
if ($bookings->isEmpty()) {
|
||||||
|
return back()->with('error', 'Tidak ada data booking yang bisa dihapus.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$isSuperAdmin = auth()->user()->role === 'super_admin';
|
||||||
|
$deletedCount = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
|
||||||
|
foreach ($bookings as $booking) {
|
||||||
|
$canDelete = $isSuperAdmin || in_array($booking->status, [Booking::STATUS_PENDING, Booking::STATUS_CANCELLED]);
|
||||||
|
|
||||||
|
if (!$canDelete) {
|
||||||
|
$skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$booking->delete();
|
||||||
|
$deletedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($deletedCount === 0) {
|
||||||
|
return back()->with('error', 'Tidak ada booking yang dihapus. Admin biasa hanya boleh menghapus status pending/dibatalkan.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($skippedCount > 0) {
|
||||||
|
return back()->with('success', "Berhasil hapus {$deletedCount} booking. {$skippedCount} booking dilewati karena tidak memiliki izin hapus.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', "Berhasil hapus {$deletedCount} booking.");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API: Cek ketersediaan kontrakan
|
* API: Cek ketersediaan kontrakan
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ public function map(Request $request)
|
||||||
'harga_min' => 'nullable|numeric|min:0',
|
'harga_min' => 'nullable|numeric|min:0',
|
||||||
'harga_max' => 'nullable|numeric|min:0|gte:harga_min',
|
'harga_max' => 'nullable|numeric|min:0|gte:harga_min',
|
||||||
'jarak' => 'nullable|in:dekat,sedang,jauh',
|
'jarak' => 'nullable|in:dekat,sedang,jauh',
|
||||||
'jenis_layanan' => 'nullable|in:express,reguler',
|
'jenis_layanan' => 'nullable|in:jam,harian',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$query = Laundry::with('layanan');
|
$query = Laundry::with('layanan');
|
||||||
|
|
@ -308,10 +308,11 @@ public function store(Request $request)
|
||||||
|
|
||||||
// Validasi Layanan
|
// Validasi Layanan
|
||||||
'layanan' => 'required|array|min:1',
|
'layanan' => 'required|array|min:1',
|
||||||
'layanan.*.jenis_layanan' => 'required|in:express,reguler',
|
'layanan.*.jenis_layanan' => 'required|in:jam,harian',
|
||||||
'layanan.*.nama_paket' => 'required|string|max:255',
|
'layanan.*.nama_paket' => 'required|string|max:255',
|
||||||
'layanan.*.harga' => 'required|numeric|min:0',
|
'layanan.*.harga' => 'required|numeric|min:0',
|
||||||
'layanan.*.estimasi_selesai' => 'required|numeric|min:1',
|
'layanan.*.estimasi_selesai' => 'required|numeric|min:1',
|
||||||
|
'layanan.*.estimasi_satuan' => 'required|in:jam,harian',
|
||||||
'layanan.*.deskripsi' => 'nullable|string|max:1000',
|
'layanan.*.deskripsi' => 'nullable|string|max:1000',
|
||||||
'layanan.*.status' => 'required|in:aktif,nonaktif',
|
'layanan.*.status' => 'required|in:aktif,nonaktif',
|
||||||
], [
|
], [
|
||||||
|
|
@ -327,12 +328,14 @@ public function store(Request $request)
|
||||||
'layanan.required' => 'Minimal harus ada 1 jenis layanan',
|
'layanan.required' => 'Minimal harus ada 1 jenis layanan',
|
||||||
'layanan.min' => 'Minimal harus ada 1 jenis layanan',
|
'layanan.min' => 'Minimal harus ada 1 jenis layanan',
|
||||||
'layanan.*.jenis_layanan.required' => 'Jenis layanan harus dipilih',
|
'layanan.*.jenis_layanan.required' => 'Jenis layanan harus dipilih',
|
||||||
'layanan.*.jenis_layanan.in' => 'Jenis layanan harus express atau reguler',
|
'layanan.*.jenis_layanan.in' => 'Jenis layanan harus jam atau harian',
|
||||||
'layanan.*.nama_paket.required' => 'Nama paket harus diisi',
|
'layanan.*.nama_paket.required' => 'Nama paket harus diisi',
|
||||||
'layanan.*.harga.required' => 'Harga layanan harus diisi',
|
'layanan.*.harga.required' => 'Harga layanan harus diisi',
|
||||||
'layanan.*.harga.min' => 'Harga tidak boleh negatif',
|
'layanan.*.harga.min' => 'Harga tidak boleh negatif',
|
||||||
'layanan.*.estimasi_selesai.required' => 'Estimasi waktu selesai harus diisi (jam)',
|
'layanan.*.estimasi_selesai.required' => 'Estimasi waktu selesai harus diisi',
|
||||||
'layanan.*.estimasi_selesai.min' => 'Estimasi minimal 1 jam',
|
'layanan.*.estimasi_selesai.min' => 'Estimasi minimal 1',
|
||||||
|
'layanan.*.estimasi_satuan.required' => 'Satuan estimasi harus dipilih',
|
||||||
|
'layanan.*.estimasi_satuan.in' => 'Satuan estimasi harus jam atau harian',
|
||||||
'layanan.*.status.required' => 'Status layanan harus dipilih',
|
'layanan.*.status.required' => 'Status layanan harus dipilih',
|
||||||
'layanan.*.status.in' => 'Status harus aktif atau nonaktif',
|
'layanan.*.status.in' => 'Status harus aktif atau nonaktif',
|
||||||
]);
|
]);
|
||||||
|
|
@ -405,11 +408,16 @@ public function store(Request $request)
|
||||||
|
|
||||||
// Simpan Layanan
|
// Simpan Layanan
|
||||||
foreach ($request->layanan as $layananData) {
|
foreach ($request->layanan as $layananData) {
|
||||||
|
$estimasiJam = (float) $layananData['estimasi_selesai'];
|
||||||
|
if (($layananData['estimasi_satuan'] ?? 'jam') === 'harian') {
|
||||||
|
$estimasiJam *= 24;
|
||||||
|
}
|
||||||
|
|
||||||
$laundry->layanan()->create([
|
$laundry->layanan()->create([
|
||||||
'jenis_layanan' => $layananData['jenis_layanan'],
|
'jenis_layanan' => $layananData['jenis_layanan'],
|
||||||
'nama_paket' => $layananData['nama_paket'],
|
'nama_paket' => $layananData['nama_paket'],
|
||||||
'harga' => $layananData['harga'],
|
'harga' => $layananData['harga'],
|
||||||
'estimasi_selesai' => $layananData['estimasi_selesai'],
|
'estimasi_selesai' => max(1, (int) round($estimasiJam)),
|
||||||
'deskripsi' => $layananData['deskripsi'] ?? null,
|
'deskripsi' => $layananData['deskripsi'] ?? null,
|
||||||
'status' => $layananData['status'] ?? 'aktif',
|
'status' => $layananData['status'] ?? 'aktif',
|
||||||
]);
|
]);
|
||||||
|
|
@ -504,10 +512,11 @@ public function update(Request $request, Laundry $laundry)
|
||||||
|
|
||||||
// Validasi Layanan
|
// Validasi Layanan
|
||||||
'layanan' => 'required|array|min:1',
|
'layanan' => 'required|array|min:1',
|
||||||
'layanan.*.jenis_layanan' => 'required|in:express,reguler',
|
'layanan.*.jenis_layanan' => 'required|in:jam,harian',
|
||||||
'layanan.*.nama_paket' => 'required|string|max:255',
|
'layanan.*.nama_paket' => 'required|string|max:255',
|
||||||
'layanan.*.harga' => 'required|numeric|min:0',
|
'layanan.*.harga' => 'required|numeric|min:0',
|
||||||
'layanan.*.estimasi_selesai' => 'required|numeric|min:1',
|
'layanan.*.estimasi_selesai' => 'required|numeric|min:1',
|
||||||
|
'layanan.*.estimasi_satuan' => 'required|in:jam,harian',
|
||||||
'layanan.*.deskripsi' => 'nullable|string|max:1000',
|
'layanan.*.deskripsi' => 'nullable|string|max:1000',
|
||||||
'layanan.*.status' => 'required|in:aktif,nonaktif',
|
'layanan.*.status' => 'required|in:aktif,nonaktif',
|
||||||
], [
|
], [
|
||||||
|
|
@ -519,12 +528,14 @@ public function update(Request $request, Laundry $laundry)
|
||||||
'layanan.required' => 'Minimal harus ada 1 jenis layanan',
|
'layanan.required' => 'Minimal harus ada 1 jenis layanan',
|
||||||
'layanan.min' => 'Minimal harus ada 1 jenis layanan',
|
'layanan.min' => 'Minimal harus ada 1 jenis layanan',
|
||||||
'layanan.*.jenis_layanan.required' => 'Jenis layanan harus dipilih',
|
'layanan.*.jenis_layanan.required' => 'Jenis layanan harus dipilih',
|
||||||
'layanan.*.jenis_layanan.in' => 'Jenis layanan harus express atau reguler',
|
'layanan.*.jenis_layanan.in' => 'Jenis layanan harus jam atau harian',
|
||||||
'layanan.*.nama_paket.required' => 'Nama paket harus diisi',
|
'layanan.*.nama_paket.required' => 'Nama paket harus diisi',
|
||||||
'layanan.*.harga.required' => 'Harga layanan harus diisi',
|
'layanan.*.harga.required' => 'Harga layanan harus diisi',
|
||||||
'layanan.*.harga.min' => 'Harga tidak boleh negatif',
|
'layanan.*.harga.min' => 'Harga tidak boleh negatif',
|
||||||
'layanan.*.estimasi_selesai.required' => 'Estimasi waktu selesai harus diisi (jam)',
|
'layanan.*.estimasi_selesai.required' => 'Estimasi waktu selesai harus diisi',
|
||||||
'layanan.*.estimasi_selesai.min' => 'Estimasi minimal 1 jam',
|
'layanan.*.estimasi_selesai.min' => 'Estimasi minimal 1',
|
||||||
|
'layanan.*.estimasi_satuan.required' => 'Satuan estimasi harus dipilih',
|
||||||
|
'layanan.*.estimasi_satuan.in' => 'Satuan estimasi harus jam atau harian',
|
||||||
'layanan.*.status.required' => 'Status layanan harus dipilih',
|
'layanan.*.status.required' => 'Status layanan harus dipilih',
|
||||||
'layanan.*.status.in' => 'Status harus aktif atau nonaktif',
|
'layanan.*.status.in' => 'Status harus aktif atau nonaktif',
|
||||||
]);
|
]);
|
||||||
|
|
@ -622,11 +633,16 @@ public function update(Request $request, Laundry $laundry)
|
||||||
$laundry->layanan()->delete();
|
$laundry->layanan()->delete();
|
||||||
|
|
||||||
foreach ($request->layanan as $layananData) {
|
foreach ($request->layanan as $layananData) {
|
||||||
|
$estimasiJam = (float) $layananData['estimasi_selesai'];
|
||||||
|
if (($layananData['estimasi_satuan'] ?? 'jam') === 'harian') {
|
||||||
|
$estimasiJam *= 24;
|
||||||
|
}
|
||||||
|
|
||||||
$laundry->layanan()->create([
|
$laundry->layanan()->create([
|
||||||
'jenis_layanan' => $layananData['jenis_layanan'],
|
'jenis_layanan' => $layananData['jenis_layanan'],
|
||||||
'nama_paket' => $layananData['nama_paket'],
|
'nama_paket' => $layananData['nama_paket'],
|
||||||
'harga' => $layananData['harga'],
|
'harga' => $layananData['harga'],
|
||||||
'estimasi_selesai' => $layananData['estimasi_selesai'],
|
'estimasi_selesai' => max(1, (int) round($estimasiJam)),
|
||||||
'deskripsi' => $layananData['deskripsi'] ?? null,
|
'deskripsi' => $layananData['deskripsi'] ?? null,
|
||||||
'status' => $layananData['status'] ?? 'aktif',
|
'status' => $layananData['status'] ?? 'aktif',
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -178,9 +178,10 @@ private function getData($tipe, $jenisLayanan)
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = Laundry::with('layanan')->get();
|
$data = Laundry::with('layanan')->get();
|
||||||
|
$jenisValues = $this->mapLaundryJenisLayananValues($jenisLayanan);
|
||||||
|
|
||||||
return $data->filter(function($laundry) use ($jenisLayanan) {
|
return $data->filter(function($laundry) use ($jenisValues) {
|
||||||
return $laundry->layanan->where('jenis_layanan', $jenisLayanan)->isNotEmpty();
|
return $laundry->layanan->whereIn('jenis_layanan', $jenisValues)->isNotEmpty();
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
|
@ -192,7 +193,9 @@ private function getData($tipe, $jenisLayanan)
|
||||||
// Proses data dengan hitung fasilitas/jarak
|
// Proses data dengan hitung fasilitas/jarak
|
||||||
private function processData($data, $tipe, $jenisLayanan, $refLat, $refLng)
|
private function processData($data, $tipe, $jenisLayanan, $refLat, $refLng)
|
||||||
{
|
{
|
||||||
return $data->map(function($item) use ($tipe, $jenisLayanan, $refLat, $refLng) {
|
$jenisValues = $this->mapLaundryJenisLayananValues($jenisLayanan);
|
||||||
|
|
||||||
|
return $data->map(function($item) use ($tipe, $jenisValues, $refLat, $refLng) {
|
||||||
try {
|
try {
|
||||||
if ($tipe == 'kontrakan') {
|
if ($tipe == 'kontrakan') {
|
||||||
$item->jumlah_fasilitas = $item->fasilitas ? count(explode(',', $item->fasilitas)) : 0;
|
$item->jumlah_fasilitas = $item->fasilitas ? count(explode(',', $item->fasilitas)) : 0;
|
||||||
|
|
@ -215,7 +218,7 @@ private function processData($data, $tipe, $jenisLayanan, $refLat, $refLng)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// LAUNDRY
|
// LAUNDRY
|
||||||
$layanan = $item->layanan->where('jenis_layanan', $jenisLayanan)->first();
|
$layanan = $item->layanan->whereIn('jenis_layanan', $jenisValues)->first();
|
||||||
|
|
||||||
if (!$layanan) {
|
if (!$layanan) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -249,6 +252,21 @@ private function processData($data, $tipe, $jenisLayanan, $refLat, $refLng)
|
||||||
}
|
}
|
||||||
})->filter();
|
})->filter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mapping jenis layanan baru ke nilai lama agar kompatibel dengan data existing.
|
||||||
|
private function mapLaundryJenisLayananValues($jenisLayanan)
|
||||||
|
{
|
||||||
|
$map = [
|
||||||
|
'harian' => ['harian', 'reguler', 'kiloan'],
|
||||||
|
'jam' => ['jam', 'express', 'kilat', 'satuan'],
|
||||||
|
// Backward compatibility
|
||||||
|
'reguler' => ['harian', 'reguler', 'kiloan'],
|
||||||
|
'express' => ['jam', 'express', 'kilat', 'satuan'],
|
||||||
|
'kilat' => ['jam', 'express', 'kilat', 'satuan'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $map[$jenisLayanan] ?? [$jenisLayanan];
|
||||||
|
}
|
||||||
|
|
||||||
// Hitung max/min untuk setiap kriteria
|
// Hitung max/min untuk setiap kriteria
|
||||||
private function calculateMaxMin($dataWithFasilitas, $tipe)
|
private function calculateMaxMin($dataWithFasilitas, $tipe)
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'timezone' => 'UTC',
|
'timezone' => env('APP_TIMEZONE', 'Asia/Jakarta'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Normalize legacy layanan types to new canonical values:
|
||||||
|
* - harian: regular/day package
|
||||||
|
* - jam: fast/hour package
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('layanan_laundry')
|
||||||
|
->whereIn('jenis_layanan', ['reguler', 'kiloan'])
|
||||||
|
->update(['jenis_layanan' => 'harian']);
|
||||||
|
|
||||||
|
DB::table('layanan_laundry')
|
||||||
|
->whereIn('jenis_layanan', ['express', 'kilat', 'satuan'])
|
||||||
|
->update(['jenis_layanan' => 'jam']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revert to closest legacy labels for compatibility.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::table('layanan_laundry')
|
||||||
|
->where('jenis_layanan', 'harian')
|
||||||
|
->update(['jenis_layanan' => 'reguler']);
|
||||||
|
|
||||||
|
DB::table('layanan_laundry')
|
||||||
|
->where('jenis_layanan', 'jam')
|
||||||
|
->update(['jenis_layanan' => 'express']);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -32,14 +32,14 @@ public function run(): void
|
||||||
],
|
],
|
||||||
'layanan' => [
|
'layanan' => [
|
||||||
[
|
[
|
||||||
'jenis_layanan' => 'kiloan',
|
'jenis_layanan' => 'harian',
|
||||||
'nama_paket' => 'Cuci + Setrika',
|
'nama_paket' => 'Cuci + Setrika',
|
||||||
'harga' => 7000,
|
'harga' => 7000,
|
||||||
'estimasi_selesai' => 24,
|
'estimasi_selesai' => 24,
|
||||||
'deskripsi' => 'Paket cuci komplit dengan setrika rapi',
|
'deskripsi' => 'Paket cuci komplit dengan setrika rapi',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'jenis_layanan' => 'satuan',
|
'jenis_layanan' => 'jam',
|
||||||
'nama_paket' => 'Setrika Saja',
|
'nama_paket' => 'Setrika Saja',
|
||||||
'harga' => 5000,
|
'harga' => 5000,
|
||||||
'estimasi_selesai' => 12,
|
'estimasi_selesai' => 12,
|
||||||
|
|
@ -61,21 +61,21 @@ public function run(): void
|
||||||
],
|
],
|
||||||
'layanan' => [
|
'layanan' => [
|
||||||
[
|
[
|
||||||
'jenis_layanan' => 'kiloan',
|
'jenis_layanan' => 'jam',
|
||||||
'nama_paket' => 'Express 6 Jam',
|
'nama_paket' => 'Express 6 Jam',
|
||||||
'harga' => 12000,
|
'harga' => 12000,
|
||||||
'estimasi_selesai' => 6,
|
'estimasi_selesai' => 6,
|
||||||
'deskripsi' => 'Layanan kilat selesai 6 jam',
|
'deskripsi' => 'Layanan kilat selesai 6 jam',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'jenis_layanan' => 'kiloan',
|
'jenis_layanan' => 'harian',
|
||||||
'nama_paket' => 'Reguler',
|
'nama_paket' => 'Reguler',
|
||||||
'harga' => 8000,
|
'harga' => 8000,
|
||||||
'estimasi_selesai' => 24,
|
'estimasi_selesai' => 24,
|
||||||
'deskripsi' => 'Paket reguler 1 hari selesai',
|
'deskripsi' => 'Paket reguler 1 hari selesai',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'jenis_layanan' => 'satuan',
|
'jenis_layanan' => 'jam',
|
||||||
'nama_paket' => 'Dry Clean',
|
'nama_paket' => 'Dry Clean',
|
||||||
'harga' => 25000,
|
'harga' => 25000,
|
||||||
'estimasi_selesai' => 48,
|
'estimasi_selesai' => 48,
|
||||||
|
|
@ -97,14 +97,14 @@ public function run(): void
|
||||||
],
|
],
|
||||||
'layanan' => [
|
'layanan' => [
|
||||||
[
|
[
|
||||||
'jenis_layanan' => 'kiloan',
|
'jenis_layanan' => 'harian',
|
||||||
'nama_paket' => 'Cuci Lipat',
|
'nama_paket' => 'Cuci Lipat',
|
||||||
'harga' => 5000,
|
'harga' => 5000,
|
||||||
'estimasi_selesai' => 48,
|
'estimasi_selesai' => 48,
|
||||||
'deskripsi' => 'Paket ekonomis cuci dan lipat',
|
'deskripsi' => 'Paket ekonomis cuci dan lipat',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'jenis_layanan' => 'kiloan',
|
'jenis_layanan' => 'harian',
|
||||||
'nama_paket' => 'Cuci + Setrika',
|
'nama_paket' => 'Cuci + Setrika',
|
||||||
'harga' => 6000,
|
'harga' => 6000,
|
||||||
'estimasi_selesai' => 48,
|
'estimasi_selesai' => 48,
|
||||||
|
|
@ -126,14 +126,14 @@ public function run(): void
|
||||||
],
|
],
|
||||||
'layanan' => [
|
'layanan' => [
|
||||||
[
|
[
|
||||||
'jenis_layanan' => 'kiloan',
|
'jenis_layanan' => 'harian',
|
||||||
'nama_paket' => 'Reguler',
|
'nama_paket' => 'Reguler',
|
||||||
'harga' => 7500,
|
'harga' => 7500,
|
||||||
'estimasi_selesai' => 24,
|
'estimasi_selesai' => 24,
|
||||||
'deskripsi' => 'Paket standar cuci setrika',
|
'deskripsi' => 'Paket standar cuci setrika',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'jenis_layanan' => 'satuan',
|
'jenis_layanan' => 'jam',
|
||||||
'nama_paket' => 'Sepatu',
|
'nama_paket' => 'Sepatu',
|
||||||
'harga' => 15000,
|
'harga' => 15000,
|
||||||
'estimasi_selesai' => 48,
|
'estimasi_selesai' => 48,
|
||||||
|
|
@ -155,7 +155,7 @@ public function run(): void
|
||||||
],
|
],
|
||||||
'layanan' => [
|
'layanan' => [
|
||||||
[
|
[
|
||||||
'jenis_layanan' => 'kiloan',
|
'jenis_layanan' => 'harian',
|
||||||
'nama_paket' => 'Paket Mahasiswa',
|
'nama_paket' => 'Paket Mahasiswa',
|
||||||
'harga' => 6000,
|
'harga' => 6000,
|
||||||
'estimasi_selesai' => 36,
|
'estimasi_selesai' => 36,
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,7 @@ private function createLaundryServices($laundry)
|
||||||
{
|
{
|
||||||
$services = [
|
$services = [
|
||||||
[
|
[
|
||||||
'jenis_layanan' => 'kiloan',
|
'jenis_layanan' => 'harian',
|
||||||
'nama_paket' => 'Cuci Biasa',
|
'nama_paket' => 'Cuci Biasa',
|
||||||
'harga' => 5000,
|
'harga' => 5000,
|
||||||
'estimasi_selesai' => 48,
|
'estimasi_selesai' => 48,
|
||||||
|
|
@ -378,7 +378,7 @@ private function createLaundryServices($laundry)
|
||||||
'status' => 'aktif'
|
'status' => 'aktif'
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'jenis_layanan' => 'kiloan',
|
'jenis_layanan' => 'harian',
|
||||||
'nama_paket' => 'Cuci Setrika',
|
'nama_paket' => 'Cuci Setrika',
|
||||||
'harga' => 8000,
|
'harga' => 8000,
|
||||||
'estimasi_selesai' => 24,
|
'estimasi_selesai' => 24,
|
||||||
|
|
@ -386,7 +386,7 @@ private function createLaundryServices($laundry)
|
||||||
'status' => 'aktif'
|
'status' => 'aktif'
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'jenis_layanan' => 'kiloan',
|
'jenis_layanan' => 'jam',
|
||||||
'nama_paket' => 'Cuci Express',
|
'nama_paket' => 'Cuci Express',
|
||||||
'harga' => 12000,
|
'harga' => 12000,
|
||||||
'estimasi_selesai' => 4,
|
'estimasi_selesai' => 4,
|
||||||
|
|
@ -394,7 +394,7 @@ private function createLaundryServices($laundry)
|
||||||
'status' => 'aktif'
|
'status' => 'aktif'
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'jenis_layanan' => 'satuan',
|
'jenis_layanan' => 'jam',
|
||||||
'nama_paket' => 'Setrika Saja',
|
'nama_paket' => 'Setrika Saja',
|
||||||
'harga' => 3000,
|
'harga' => 3000,
|
||||||
'estimasi_selesai' => 12,
|
'estimasi_selesai' => 12,
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 471 KiB |
|
|
@ -453,26 +453,66 @@ class="form-control border-start-0 @error('jarak') is-invalid @enderror"
|
||||||
|
|
||||||
<!-- Fasilitas -->
|
<!-- Fasilitas -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
|
@php
|
||||||
|
$facilityOptions = [
|
||||||
|
'WiFi',
|
||||||
|
'Kasur',
|
||||||
|
'Lemari',
|
||||||
|
'Meja Belajar',
|
||||||
|
'Kamar Mandi Dalam',
|
||||||
|
'Kamar Mandi Luar',
|
||||||
|
'Closet Duduk',
|
||||||
|
'Dapur',
|
||||||
|
'Dapur Bersama',
|
||||||
|
'Tempat Cuci Piring',
|
||||||
|
'Kompor',
|
||||||
|
'Ruang Tamu',
|
||||||
|
'Jemuran',
|
||||||
|
'Parkir Motor',
|
||||||
|
'Parkir Mobil',
|
||||||
|
'Garasi',
|
||||||
|
'Listrik Termasuk',
|
||||||
|
'Air PDAM/Sumur',
|
||||||
|
'Keamanan 24 Jam',
|
||||||
|
'CCTV',
|
||||||
|
'Akses 24 Jam',
|
||||||
|
'Musholla',
|
||||||
|
'Perlengkapan Tidur',
|
||||||
|
];
|
||||||
|
$selectedFacilities = collect(explode(',', old('fasilitas', '')))
|
||||||
|
->map(fn($item) => strtolower(trim($item)))
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
@endphp
|
||||||
<label for="fasilitas" class="form-label fw-semibold">
|
<label for="fasilitas" class="form-label fw-semibold">
|
||||||
Fasilitas
|
Fasilitas
|
||||||
</label>
|
</label>
|
||||||
<div class="input-group">
|
<input type="hidden" name="fasilitas" id="fasilitas" value="{{ old('fasilitas') }}">
|
||||||
<span class="input-group-text bg-light border-end-0">
|
<div class="border rounded p-2 bg-light @error('fasilitas') border-danger @enderror">
|
||||||
<i class="bi bi-star text-warning"></i>
|
<div class="row g-2">
|
||||||
</span>
|
@foreach($facilityOptions as $facility)
|
||||||
<input
|
<div class="col-6">
|
||||||
type="text"
|
<div class="form-check">
|
||||||
name="fasilitas"
|
<input
|
||||||
class="form-control border-start-0 @error('fasilitas') is-invalid @enderror"
|
class="form-check-input fasilitas-checkbox"
|
||||||
id="fasilitas"
|
type="checkbox"
|
||||||
placeholder="Contoh: WiFi, AC, Kasur, Lemari"
|
value="{{ $facility }}"
|
||||||
value="{{ old('fasilitas') }}"
|
id="fasilitas_{{ $loop->index }}"
|
||||||
>
|
{{ in_array(strtolower($facility), $selectedFacilities) ? 'checked' : '' }}
|
||||||
|
>
|
||||||
|
<label class="form-check-label small" for="fasilitas_{{ $loop->index }}">
|
||||||
|
{{ $facility }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
@error('fasilitas')
|
@error('fasilitas')
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
<div class="text-danger small mt-2">{{ $message }}</div>
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">Pisahkan dengan koma (,) untuk fasilitas lebih dari satu</small>
|
<small class="text-muted">Checklist satu atau lebih fasilitas sesuai data kontrakan</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -932,6 +972,33 @@ function(error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== FASILITAS CHECKLIST ==========
|
||||||
|
const fasilitasHiddenInput = document.getElementById('fasilitas');
|
||||||
|
const fasilitasCheckboxes = document.querySelectorAll('.fasilitas-checkbox');
|
||||||
|
let fasilitasTouched = false;
|
||||||
|
|
||||||
|
function syncFasilitasChecklist() {
|
||||||
|
if (!fasilitasHiddenInput || !fasilitasCheckboxes.length) return;
|
||||||
|
const selectedFacilities = Array.from(fasilitasCheckboxes)
|
||||||
|
.filter(checkbox => checkbox.checked)
|
||||||
|
.map(checkbox => checkbox.value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
fasilitasHiddenInput.value = selectedFacilities.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fasilitasCheckboxes.length) {
|
||||||
|
fasilitasCheckboxes.forEach(function(checkbox) {
|
||||||
|
checkbox.addEventListener('change', function() {
|
||||||
|
fasilitasTouched = true;
|
||||||
|
syncFasilitasChecklist();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fasilitasHiddenInput && !fasilitasHiddenInput.value.trim()) {
|
||||||
|
syncFasilitasChecklist();
|
||||||
|
}
|
||||||
|
|
||||||
// ========== LIVE PREVIEW ==========
|
// ========== LIVE PREVIEW ==========
|
||||||
const form = document.getElementById('kontrakanForm');
|
const form = document.getElementById('kontrakanForm');
|
||||||
|
|
@ -1012,6 +1079,10 @@ function updatePreview() {
|
||||||
|
|
||||||
// ========== FORM VALIDATION ==========
|
// ========== FORM VALIDATION ==========
|
||||||
form.addEventListener('submit', function(e) {
|
form.addEventListener('submit', function(e) {
|
||||||
|
if (fasilitasHiddenInput && (fasilitasTouched || !fasilitasHiddenInput.value.trim())) {
|
||||||
|
syncFasilitasChecklist();
|
||||||
|
}
|
||||||
|
|
||||||
const lat = parseFloat(latInput.value);
|
const lat = parseFloat(latInput.value);
|
||||||
const lng = parseFloat(lngInput.value);
|
const lng = parseFloat(lngInput.value);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -481,26 +481,66 @@ class="form-control border-start-0 @error('jarak') is-invalid @enderror"
|
||||||
|
|
||||||
<!-- Fasilitas -->
|
<!-- Fasilitas -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
|
@php
|
||||||
|
$facilityOptions = [
|
||||||
|
'WiFi',
|
||||||
|
'Kasur',
|
||||||
|
'Lemari',
|
||||||
|
'Meja Belajar',
|
||||||
|
'Kamar Mandi Dalam',
|
||||||
|
'Kamar Mandi Luar',
|
||||||
|
'Closet Duduk',
|
||||||
|
'Dapur',
|
||||||
|
'Dapur Bersama',
|
||||||
|
'Tempat Cuci Piring',
|
||||||
|
'Kompor',
|
||||||
|
'Ruang Tamu',
|
||||||
|
'Jemuran',
|
||||||
|
'Parkir Motor',
|
||||||
|
'Parkir Mobil',
|
||||||
|
'Garasi',
|
||||||
|
'Listrik Termasuk',
|
||||||
|
'Air PDAM/Sumur',
|
||||||
|
'Keamanan 24 Jam',
|
||||||
|
'CCTV',
|
||||||
|
'Akses 24 Jam',
|
||||||
|
'Musholla',
|
||||||
|
'Perlengkapan Tidur',
|
||||||
|
];
|
||||||
|
$selectedFacilities = collect(explode(',', old('fasilitas', $kontrakan->fasilitas ?? '')))
|
||||||
|
->map(fn($item) => strtolower(trim($item)))
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
@endphp
|
||||||
<label for="fasilitas" class="form-label fw-semibold">
|
<label for="fasilitas" class="form-label fw-semibold">
|
||||||
Fasilitas
|
Fasilitas
|
||||||
</label>
|
</label>
|
||||||
<div class="input-group">
|
<input type="hidden" name="fasilitas" id="fasilitas" value="{{ old('fasilitas', $kontrakan->fasilitas) }}">
|
||||||
<span class="input-group-text bg-light border-end-0">
|
<div class="border rounded p-2 bg-light @error('fasilitas') border-danger @enderror">
|
||||||
<i class="bi bi-star text-warning"></i>
|
<div class="row g-2">
|
||||||
</span>
|
@foreach($facilityOptions as $facility)
|
||||||
<input
|
<div class="col-6">
|
||||||
type="text"
|
<div class="form-check">
|
||||||
name="fasilitas"
|
<input
|
||||||
class="form-control border-start-0 @error('fasilitas') is-invalid @enderror"
|
class="form-check-input fasilitas-checkbox"
|
||||||
id="fasilitas"
|
type="checkbox"
|
||||||
placeholder="Contoh: WiFi, AC, Kasur, Lemari"
|
value="{{ $facility }}"
|
||||||
value="{{ old('fasilitas', $kontrakan->fasilitas) }}"
|
id="fasilitas_{{ $loop->index }}"
|
||||||
>
|
{{ in_array(strtolower($facility), $selectedFacilities) ? 'checked' : '' }}
|
||||||
|
>
|
||||||
|
<label class="form-check-label small" for="fasilitas_{{ $loop->index }}">
|
||||||
|
{{ $facility }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
@error('fasilitas')
|
@error('fasilitas')
|
||||||
<div class="invalid-feedback">{{ $message }}</div>
|
<div class="text-danger small mt-2">{{ $message }}</div>
|
||||||
@enderror
|
@enderror
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">Pisahkan dengan koma (,) untuk fasilitas lebih dari satu</small>
|
<small class="text-muted">Checklist satu atau lebih fasilitas sesuai data kontrakan</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -991,6 +1031,33 @@ function(error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== FASILITAS CHECKLIST ==========
|
||||||
|
const fasilitasHiddenInput = document.getElementById('fasilitas');
|
||||||
|
const fasilitasCheckboxes = document.querySelectorAll('.fasilitas-checkbox');
|
||||||
|
let fasilitasTouched = false;
|
||||||
|
|
||||||
|
function syncFasilitasChecklist() {
|
||||||
|
if (!fasilitasHiddenInput || !fasilitasCheckboxes.length) return;
|
||||||
|
const selectedFacilities = Array.from(fasilitasCheckboxes)
|
||||||
|
.filter(checkbox => checkbox.checked)
|
||||||
|
.map(checkbox => checkbox.value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
fasilitasHiddenInput.value = selectedFacilities.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fasilitasCheckboxes.length) {
|
||||||
|
fasilitasCheckboxes.forEach(function(checkbox) {
|
||||||
|
checkbox.addEventListener('change', function() {
|
||||||
|
fasilitasTouched = true;
|
||||||
|
syncFasilitasChecklist();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fasilitasHiddenInput && !fasilitasHiddenInput.value.trim()) {
|
||||||
|
syncFasilitasChecklist();
|
||||||
|
}
|
||||||
|
|
||||||
// ========== FOTO PREVIEW ==========
|
// ========== FOTO PREVIEW ==========
|
||||||
const fotoInput = document.getElementById('foto');
|
const fotoInput = document.getElementById('foto');
|
||||||
|
|
@ -1037,6 +1104,10 @@ function(error) {
|
||||||
const form = document.getElementById('kontrakanForm');
|
const form = document.getElementById('kontrakanForm');
|
||||||
|
|
||||||
form.addEventListener('submit', function(e) {
|
form.addEventListener('submit', function(e) {
|
||||||
|
if (fasilitasHiddenInput && (fasilitasTouched || !fasilitasHiddenInput.value.trim())) {
|
||||||
|
syncFasilitasChecklist();
|
||||||
|
}
|
||||||
|
|
||||||
const lat = parseFloat(latInput.value);
|
const lat = parseFloat(latInput.value);
|
||||||
const lng = parseFloat(lngInput.value);
|
const lng = parseFloat(lngInput.value);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1497,7 +1497,7 @@ class="btn btn-sm btn-outline-secondary"
|
||||||
const searchSuggestions = document.getElementById('searchSuggestions');
|
const searchSuggestions = document.getElementById('searchSuggestions');
|
||||||
|
|
||||||
if (searchInput && searchSuggestions) {
|
if (searchInput && searchSuggestions) {
|
||||||
const commonSearches = ['WiFi', 'AC', 'Dapur', 'Kamar Mandi Dalam', 'Parkir', 'Listrik', 'Air', 'Dekat Kampus'];
|
const commonSearches = ['WiFi', 'AC', 'Dapur', 'Tempat Cuci Piring', 'Kamar Mandi Dalam', 'Parkir', 'Listrik', 'Air', 'Dekat Kampus'];
|
||||||
|
|
||||||
searchInput.addEventListener('input', function() {
|
searchInput.addEventListener('input', function() {
|
||||||
const value = this.value.toLowerCase();
|
const value = this.value.toLowerCase();
|
||||||
|
|
|
||||||
|
|
@ -297,7 +297,7 @@ class="form-control border-start-0 @error('jam_tutup') is-invalid @enderror"
|
||||||
|
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<i class="bi bi-info-circle me-2"></i>
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
<strong>Tips:</strong> Klik pada peta untuk menentukan lokasi, atau gunakan tombol "Deteksi Lokasi Saya" untuk mendapatkan koordinat otomatis.
|
<strong>Tips:</strong> Klik pada peta, ketik manual latitude/longitude, atau gunakan tombol "Deteksi Lokasi Saya" untuk mendapatkan koordinat otomatis.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
|
|
@ -317,7 +317,6 @@ class="form-control @error('latitude') is-invalid @enderror"
|
||||||
id="latitude"
|
id="latitude"
|
||||||
placeholder="-7.7828012"
|
placeholder="-7.7828012"
|
||||||
value="{{ old('latitude') }}"
|
value="{{ old('latitude') }}"
|
||||||
readonly
|
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@error('latitude')
|
@error('latitude')
|
||||||
|
|
@ -342,7 +341,6 @@ class="form-control @error('longitude') is-invalid @enderror"
|
||||||
id="longitude"
|
id="longitude"
|
||||||
placeholder="110.4086598"
|
placeholder="110.4086598"
|
||||||
value="{{ old('longitude') }}"
|
value="{{ old('longitude') }}"
|
||||||
readonly
|
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@error('longitude')
|
@error('longitude')
|
||||||
|
|
@ -396,8 +394,8 @@ class="form-control @error('longitude') is-invalid @enderror"
|
||||||
</label>
|
</label>
|
||||||
<select name="layanan[0][jenis_layanan]" class="form-select" required>
|
<select name="layanan[0][jenis_layanan]" class="form-select" required>
|
||||||
<option value="">-- Pilih Jenis --</option>
|
<option value="">-- Pilih Jenis --</option>
|
||||||
<option value="reguler">🕐 Reguler (Normal)</option>
|
<option value="harian">🕐 Harian</option>
|
||||||
<option value="express">⚡ Express (Cepat)</option>
|
<option value="jam">⚡ Jam</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -410,7 +408,7 @@ class="form-control @error('longitude') is-invalid @enderror"
|
||||||
type="text"
|
type="text"
|
||||||
name="layanan[0][nama_paket]"
|
name="layanan[0][nama_paket]"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Paket Reguler"
|
placeholder="Paket Harian"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -436,16 +434,22 @@ class="form-control"
|
||||||
<!-- Estimasi Selesai -->
|
<!-- Estimasi Selesai -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-semibold">
|
<label class="form-label fw-semibold">
|
||||||
Estimasi Selesai (Jam) <span class="text-danger">*</span>
|
Estimasi Selesai <span class="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div class="input-group">
|
||||||
type="number"
|
<input
|
||||||
name="layanan[0][estimasi_selesai]"
|
type="number"
|
||||||
class="form-control"
|
name="layanan[0][estimasi_selesai]"
|
||||||
placeholder="24"
|
class="form-control"
|
||||||
min="1"
|
placeholder="1"
|
||||||
required
|
min="1"
|
||||||
>
|
required
|
||||||
|
>
|
||||||
|
<select name="layanan[0][estimasi_satuan]" class="form-select" style="max-width: 140px;" required>
|
||||||
|
<option value="jam" selected>Jam</option>
|
||||||
|
<option value="harian">Harian</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Deskripsi -->
|
<!-- Deskripsi -->
|
||||||
|
|
@ -543,9 +547,8 @@ class="form-control @error('foto') is-invalid @enderror"
|
||||||
<li>Minimal harus ada 1 jenis layanan</li>
|
<li>Minimal harus ada 1 jenis layanan</li>
|
||||||
<li>Klik pada peta untuk menentukan lokasi laundry</li>
|
<li>Klik pada peta untuk menentukan lokasi laundry</li>
|
||||||
<li>Koordinat akan terisi otomatis saat klik peta</li>
|
<li>Koordinat akan terisi otomatis saat klik peta</li>
|
||||||
<li>Reguler: Layanan normal dengan harga standar</li>
|
<li>Harian: Layanan selesai harian</li>
|
||||||
<li>Express: Layanan cepat dengan harga lebih tinggi</li>
|
<li>Jam: Layanan selesai dalam hitungan jam</li>
|
||||||
<li>Express: Layanan cepat dengan waktu penyelesaian lebih singkat.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -693,6 +696,44 @@ function(error) {
|
||||||
alert('Browser Anda tidak mendukung Geolocation.');
|
alert('Browser Anda tidak mendukung Geolocation.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sinkronkan marker saat koordinat diubah manual
|
||||||
|
const latInput = document.getElementById('latitude');
|
||||||
|
const lngInput = document.getElementById('longitude');
|
||||||
|
|
||||||
|
function handleManualCoordinateInput() {
|
||||||
|
const lat = parseFloat(latInput.value);
|
||||||
|
const lng = parseFloat(lngInput.value);
|
||||||
|
|
||||||
|
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (marker) {
|
||||||
|
marker.setLatLng([lat, lng]);
|
||||||
|
} else {
|
||||||
|
marker = L.marker([lat, lng], {
|
||||||
|
draggable: true
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
marker.on('dragend', function() {
|
||||||
|
const position = marker.getLatLng();
|
||||||
|
updateCoordinates(position.lat, position.lng);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
map.setView([lat, lng], 15);
|
||||||
|
updateJarakKampus(lat, lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
latInput.addEventListener('change', handleManualCoordinateInput);
|
||||||
|
lngInput.addEventListener('change', handleManualCoordinateInput);
|
||||||
|
latInput.addEventListener('blur', handleManualCoordinateInput);
|
||||||
|
lngInput.addEventListener('blur', handleManualCoordinateInput);
|
||||||
|
|
||||||
// ========== LAYANAN FUNCTIONALITY ==========
|
// ========== LAYANAN FUNCTIONALITY ==========
|
||||||
let layananCount = 1;
|
let layananCount = 1;
|
||||||
|
|
@ -719,8 +760,8 @@ function(error) {
|
||||||
</label>
|
</label>
|
||||||
<select name="layanan[${layananCount}][jenis_layanan]" class="form-select" required>
|
<select name="layanan[${layananCount}][jenis_layanan]" class="form-select" required>
|
||||||
<option value="">-- Pilih Jenis --</option>
|
<option value="">-- Pilih Jenis --</option>
|
||||||
<option value="reguler">🕐 Reguler (Normal)</option>
|
<option value="harian">🕐 Harian</option>
|
||||||
<option value="express">⚡ Express (Cepat)</option>
|
<option value="jam">⚡ Jam</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -732,7 +773,7 @@ function(error) {
|
||||||
type="text"
|
type="text"
|
||||||
name="layanan[${layananCount}][nama_paket]"
|
name="layanan[${layananCount}][nama_paket]"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Paket Reguler"
|
placeholder="Paket Harian"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -756,16 +797,22 @@ class="form-control"
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-semibold">
|
<label class="form-label fw-semibold">
|
||||||
Estimasi Selesai (Jam) <span class="text-danger">*</span>
|
Estimasi Selesai <span class="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div class="input-group">
|
||||||
type="number"
|
<input
|
||||||
name="layanan[${layananCount}][estimasi_selesai]"
|
type="number"
|
||||||
class="form-control"
|
name="layanan[${layananCount}][estimasi_selesai]"
|
||||||
placeholder="24"
|
class="form-control"
|
||||||
min="1"
|
placeholder="1"
|
||||||
required
|
min="1"
|
||||||
>
|
required
|
||||||
|
>
|
||||||
|
<select name="layanan[${layananCount}][estimasi_satuan]" class="form-select" style="max-width: 140px;" required>
|
||||||
|
<option value="jam" selected>Jam</option>
|
||||||
|
<option value="harian">Harian</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
|
|
|
||||||
|
|
@ -296,7 +296,7 @@ class="form-control border-start-0 @error('jam_tutup') is-invalid @enderror"
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<i class="bi bi-info-circle me-2 mt-1"></i>
|
<i class="bi bi-info-circle me-2 mt-1"></i>
|
||||||
<div>
|
<div>
|
||||||
<strong>Tips:</strong> Klik pada peta untuk memperbarui lokasi, atau gunakan tombol "Deteksi Lokasi Saya" untuk mendapatkan koordinat otomatis.
|
<strong>Tips:</strong> Klik pada peta, ketik manual latitude/longitude, atau gunakan tombol "Deteksi Lokasi Saya" untuk mendapatkan koordinat otomatis.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -319,7 +319,6 @@ class="form-control"
|
||||||
placeholder="-6.966667"
|
placeholder="-6.966667"
|
||||||
value="{{ old('latitude', $laundry->latitude) }}"
|
value="{{ old('latitude', $laundry->latitude) }}"
|
||||||
required
|
required
|
||||||
readonly
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -341,7 +340,6 @@ class="form-control"
|
||||||
placeholder="110.416664"
|
placeholder="110.416664"
|
||||||
value="{{ old('longitude', $laundry->longitude) }}"
|
value="{{ old('longitude', $laundry->longitude) }}"
|
||||||
required
|
required
|
||||||
readonly
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -391,8 +389,8 @@ class="form-control"
|
||||||
</label>
|
</label>
|
||||||
<select name="layanan[{{ $index }}][jenis_layanan]" class="form-select" required>
|
<select name="layanan[{{ $index }}][jenis_layanan]" class="form-select" required>
|
||||||
<option value="">-- Pilih Jenis --</option>
|
<option value="">-- Pilih Jenis --</option>
|
||||||
<option value="reguler" {{ $layanan->jenis_layanan == 'reguler' ? 'selected' : '' }}>🕐 Reguler (Normal)</option>
|
<option value="harian" {{ in_array($layanan->jenis_layanan, ['harian', 'reguler', 'kiloan']) ? 'selected' : '' }}>🕐 Harian</option>
|
||||||
<option value="express" {{ $layanan->jenis_layanan == 'express' ? 'selected' : '' }}>⚡ Express (Cepat)</option>
|
<option value="jam" {{ in_array($layanan->jenis_layanan, ['jam', 'express', 'kilat', 'satuan']) ? 'selected' : '' }}>⚡ Jam</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -404,7 +402,7 @@ class="form-control"
|
||||||
type="text"
|
type="text"
|
||||||
name="layanan[{{ $index }}][nama_paket]"
|
name="layanan[{{ $index }}][nama_paket]"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Paket Reguler"
|
placeholder="Paket Harian"
|
||||||
value="{{ $layanan->nama_paket }}"
|
value="{{ $layanan->nama_paket }}"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
|
|
@ -429,18 +427,32 @@ class="form-control"
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
|
@php
|
||||||
|
$estimasiValue = (float) $layanan->estimasi_selesai;
|
||||||
|
$estimasiSatuan = 'jam';
|
||||||
|
if ($estimasiValue >= 24 && fmod($estimasiValue, 24) == 0.0) {
|
||||||
|
$estimasiSatuan = 'harian';
|
||||||
|
$estimasiValue = $estimasiValue / 24;
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
<label class="form-label fw-semibold">
|
<label class="form-label fw-semibold">
|
||||||
Estimasi Selesai (Jam) <span class="text-danger">*</span>
|
Estimasi Selesai <span class="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div class="input-group">
|
||||||
type="number"
|
<input
|
||||||
name="layanan[{{ $index }}][estimasi_selesai]"
|
type="number"
|
||||||
class="form-control"
|
name="layanan[{{ $index }}][estimasi_selesai]"
|
||||||
placeholder="24"
|
class="form-control"
|
||||||
value="{{ $layanan->estimasi_selesai }}"
|
placeholder="1"
|
||||||
min="1"
|
value="{{ rtrim(rtrim(number_format($estimasiValue, 2, '.', ''), '0'), '.') }}"
|
||||||
required
|
min="1"
|
||||||
>
|
required
|
||||||
|
>
|
||||||
|
<select name="layanan[{{ $index }}][estimasi_satuan]" class="form-select" style="max-width: 140px;" required>
|
||||||
|
<option value="jam" {{ $estimasiSatuan == 'jam' ? 'selected' : '' }}>Jam</option>
|
||||||
|
<option value="harian" {{ $estimasiSatuan == 'harian' ? 'selected' : '' }}>Harian</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
|
|
@ -485,8 +497,8 @@ class="form-control"
|
||||||
</label>
|
</label>
|
||||||
<select name="layanan[0][jenis_layanan]" class="form-select" required>
|
<select name="layanan[0][jenis_layanan]" class="form-select" required>
|
||||||
<option value="">-- Pilih Jenis --</option>
|
<option value="">-- Pilih Jenis --</option>
|
||||||
<option value="reguler">🕐 Reguler (Normal)</option>
|
<option value="harian">🕐 Harian</option>
|
||||||
<option value="express">⚡ Express (Cepat)</option>
|
<option value="jam">⚡ Jam</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -516,23 +528,29 @@ class="form-control"
|
||||||
type="text"
|
type="text"
|
||||||
name="layanan[0][nama_paket]"
|
name="layanan[0][nama_paket]"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Paket Reguler"
|
placeholder="Paket Harian"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-semibold">
|
<label class="form-label fw-semibold">
|
||||||
Estimasi Selesai (Jam) <span class="text-danger">*</span>
|
Estimasi Selesai <span class="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div class="input-group">
|
||||||
type="number"
|
<input
|
||||||
name="layanan[0][estimasi_selesai]"
|
type="number"
|
||||||
class="form-control"
|
name="layanan[0][estimasi_selesai]"
|
||||||
placeholder="24"
|
class="form-control"
|
||||||
min="1"
|
placeholder="1"
|
||||||
required
|
min="1"
|
||||||
>
|
required
|
||||||
|
>
|
||||||
|
<select name="layanan[0][estimasi_satuan]" class="form-select" style="max-width: 140px;" required>
|
||||||
|
<option value="jam" selected>Jam</option>
|
||||||
|
<option value="harian">Harian</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
|
|
@ -751,8 +769,8 @@ function updateJarakKampus(lat, lng) {
|
||||||
</label>
|
</label>
|
||||||
<select name="layanan[${layananCount}][jenis_layanan]" class="form-select" required>
|
<select name="layanan[${layananCount}][jenis_layanan]" class="form-select" required>
|
||||||
<option value="">-- Pilih Jenis --</option>
|
<option value="">-- Pilih Jenis --</option>
|
||||||
<option value="reguler">🕐 Reguler (Normal)</option>
|
<option value="harian">🕐 Harian</option>
|
||||||
<option value="express">⚡ Express (Cepat)</option>
|
<option value="jam">⚡ Jam</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -764,7 +782,7 @@ function updateJarakKampus(lat, lng) {
|
||||||
type="text"
|
type="text"
|
||||||
name="layanan[${layananCount}][nama_paket]"
|
name="layanan[${layananCount}][nama_paket]"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Paket Reguler"
|
placeholder="Paket Harian"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -788,16 +806,22 @@ class="form-control"
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-semibold">
|
<label class="form-label fw-semibold">
|
||||||
Estimasi Selesai (Jam) <span class="text-danger">*</span>
|
Estimasi Selesai <span class="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div class="input-group">
|
||||||
type="number"
|
<input
|
||||||
name="layanan[${layananCount}][estimasi_selesai]"
|
type="number"
|
||||||
class="form-control"
|
name="layanan[${layananCount}][estimasi_selesai]"
|
||||||
placeholder="24"
|
class="form-control"
|
||||||
min="1"
|
placeholder="1"
|
||||||
required
|
min="1"
|
||||||
>
|
required
|
||||||
|
>
|
||||||
|
<select name="layanan[${layananCount}][estimasi_satuan]" class="form-select" style="max-width: 140px;" required>
|
||||||
|
<option value="jam" selected>Jam</option>
|
||||||
|
<option value="harian">Harian</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
|
|
@ -1014,6 +1038,32 @@ function(error) {
|
||||||
alert('Browser Anda tidak mendukung Geolocation.');
|
alert('Browser Anda tidak mendukung Geolocation.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sinkronkan marker saat koordinat diubah manual
|
||||||
|
const latInput = document.getElementById('latitude');
|
||||||
|
const lngInput = document.getElementById('longitude');
|
||||||
|
|
||||||
|
function handleManualCoordinateInput() {
|
||||||
|
const lat = parseFloat(latInput.value);
|
||||||
|
const lng = parseFloat(lngInput.value);
|
||||||
|
|
||||||
|
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
marker.setLatLng([lat, lng]);
|
||||||
|
map.setView([lat, lng], map.getZoom());
|
||||||
|
updateJarakKampus(lat, lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
latInput.addEventListener('change', handleManualCoordinateInput);
|
||||||
|
lngInput.addEventListener('change', handleManualCoordinateInput);
|
||||||
|
latInput.addEventListener('blur', handleManualCoordinateInput);
|
||||||
|
lngInput.addEventListener('blur', handleManualCoordinateInput);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Modal functions
|
// Modal functions
|
||||||
|
|
|
||||||
|
|
@ -144,14 +144,11 @@ class="form-control"
|
||||||
</label>
|
</label>
|
||||||
<select name="jenis_layanan" class="form-select">
|
<select name="jenis_layanan" class="form-select">
|
||||||
<option value="">Semua Layanan</option>
|
<option value="">Semua Layanan</option>
|
||||||
<option value="express" {{ ($filters['jenis_layanan'] ?? '') == 'express' ? 'selected' : '' }}>
|
<option value="jam" {{ ($filters['jenis_layanan'] ?? '') == 'jam' ? 'selected' : '' }}>
|
||||||
⚡ Express
|
⚡ Jam
|
||||||
</option>
|
</option>
|
||||||
<option value="reguler" {{ ($filters['jenis_layanan'] ?? '') == 'reguler' ? 'selected' : '' }}>
|
<option value="harian" {{ ($filters['jenis_layanan'] ?? '') == 'harian' ? 'selected' : '' }}>
|
||||||
🕐 Reguler
|
🕐 Harian
|
||||||
</option>
|
|
||||||
<option value="kilat" {{ ($filters['jenis_layanan'] ?? '') == 'kilat' ? 'selected' : '' }}>
|
|
||||||
🚀 Kilat
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -323,9 +320,14 @@ function formatRupiah(number) {
|
||||||
if (location.layanan && location.layanan.length > 0) {
|
if (location.layanan && location.layanan.length > 0) {
|
||||||
layananHtml = location.layanan.map(function(layanan) {
|
layananHtml = location.layanan.map(function(layanan) {
|
||||||
const jenisIcon = {
|
const jenisIcon = {
|
||||||
|
'jam': '⚡',
|
||||||
|
'harian': '🕐',
|
||||||
|
// Backward compatibility for old values
|
||||||
'express': '⚡',
|
'express': '⚡',
|
||||||
|
'kilat': '⚡',
|
||||||
|
'satuan': '⚡',
|
||||||
'reguler': '🕐',
|
'reguler': '🕐',
|
||||||
'kilat': '🚀'
|
'kiloan': '🕐'
|
||||||
};
|
};
|
||||||
return `
|
return `
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 8px; background: #f8f9fa; border-radius: 5px; margin-bottom: 5px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; padding: 8px; background: #f8f9fa; border-radius: 5px; margin-bottom: 5px;">
|
||||||
|
|
|
||||||
|
|
@ -236,28 +236,20 @@
|
||||||
<i class="bi bi-speedometer2 me-2" style="color: #667eea;"></i>Pilih Jenis Layanan
|
<i class="bi bi-speedometer2 me-2" style="color: #667eea;"></i>Pilih Jenis Layanan
|
||||||
</h6>
|
</h6>
|
||||||
<div class="row g-2 g-md-3">
|
<div class="row g-2 g-md-3">
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-6">
|
||||||
<input type="radio" class="btn-check" name="jenis_layanan" id="layanan_reguler" value="reguler">
|
<input type="radio" class="btn-check" name="jenis_layanan" id="layanan_harian" value="harian">
|
||||||
<label class="btn btn-outline-warning w-100 py-2 py-md-3" for="layanan_reguler" style="border-color: #667eea; color: #667eea;">
|
<label class="btn btn-outline-warning w-100 py-2 py-md-3" for="layanan_harian" style="border-color: #667eea; color: #667eea;">
|
||||||
<i class="bi bi-clock fs-5 fs-md-4 d-block mb-2"></i>
|
<i class="bi bi-clock fs-5 fs-md-4 d-block mb-2"></i>
|
||||||
<strong class="small">Reguler</strong>
|
<strong class="small">Harian</strong>
|
||||||
<small class="d-block text-muted" style="font-size: 0.75rem;">Normal</small>
|
<small class="d-block text-muted" style="font-size: 0.75rem;">Selesai harian</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-4">
|
<div class="col-12 col-md-6">
|
||||||
<input type="radio" class="btn-check" name="jenis_layanan" id="layanan_express" value="express">
|
<input type="radio" class="btn-check" name="jenis_layanan" id="layanan_jam" value="jam">
|
||||||
<label class="btn btn-outline-warning w-100 py-2 py-md-3" for="layanan_express" style="border-color: #667eea; color: #667eea;">
|
<label class="btn btn-outline-warning w-100 py-2 py-md-3" for="layanan_jam" style="border-color: #667eea; color: #667eea;">
|
||||||
<i class="bi bi-lightning-charge fs-5 fs-md-4 d-block mb-2"></i>
|
<i class="bi bi-lightning-charge fs-5 fs-md-4 d-block mb-2"></i>
|
||||||
<strong class="small">Express</strong>
|
<strong class="small">Jam</strong>
|
||||||
<small class="d-block text-muted" style="font-size: 0.75rem;">Cepat</small>
|
<small class="d-block text-muted" style="font-size: 0.75rem;">Selesai per jam</small>
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-4">
|
|
||||||
<input type="radio" class="btn-check" name="jenis_layanan" id="layanan_kilat" value="kilat">
|
|
||||||
<label class="btn btn-outline-warning w-100 py-2 py-md-3" for="layanan_kilat" style="border-color: #667eea; color: #667eea;">
|
|
||||||
<i class="bi bi-rocket-takeoff fs-5 fs-md-4 d-block mb-2"></i>
|
|
||||||
<strong class="small">Kilat</strong>
|
|
||||||
<small class="d-block text-muted" style="font-size: 0.75rem;">Super Cepat</small>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@
|
||||||
<h6 class="mb-1 fw-semibold">{{ $backup['name'] }}</h6>
|
<h6 class="mb-1 fw-semibold">{{ $backup['name'] }}</h6>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
<i class="bi bi-calendar me-1"></i>
|
<i class="bi bi-calendar me-1"></i>
|
||||||
{{ date('d M Y H:i', $backup['date']) }}
|
{{ $backup['date_display'] }}
|
||||||
<span class="mx-2">•</span>
|
<span class="mx-2">•</span>
|
||||||
<i class="bi bi-file-size me-1"></i>
|
<i class="bi bi-file-size me-1"></i>
|
||||||
{{ number_format($backup['size'] / 1024 / 1024, 2) }} MB
|
{{ number_format($backup['size'] / 1024 / 1024, 2) }} MB
|
||||||
|
|
@ -145,7 +145,7 @@
|
||||||
</small>
|
</small>
|
||||||
@if(count($backups) > 0)
|
@if(count($backups) > 0)
|
||||||
<small class="text-muted d-block">
|
<small class="text-muted d-block">
|
||||||
<strong>Latest:</strong> {{ date('d M Y H:i', $backups[0]['date']) }}
|
<strong>Latest:</strong> {{ $backups[0]['date_display'] }}
|
||||||
</small>
|
</small>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -100,11 +100,34 @@
|
||||||
|
|
||||||
{{-- Table --}}
|
{{-- Table --}}
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-0 pb-0">
|
||||||
|
<form id="bulkDeleteForm" action="{{ route('admin.bookings.bulk-destroy') }}" method="POST" class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="action" id="bulkDeleteAction" value="selected">
|
||||||
|
<input type="hidden" name="status" value="{{ request('status') }}">
|
||||||
|
<input type="hidden" name="kontrakan_id" value="{{ request('kontrakan_id') }}">
|
||||||
|
|
||||||
|
<button type="button" id="btnDeleteSelected" class="btn btn-danger btn-sm" disabled>
|
||||||
|
<i class="bi bi-trash me-1"></i>Hapus Terpilih
|
||||||
|
</button>
|
||||||
|
@if(auth()->user()->role === 'super_admin')
|
||||||
|
<button type="button" id="btnDeleteAll" class="btn btn-outline-danger btn-sm">
|
||||||
|
<i class="bi bi-trash3 me-1"></i>Hapus Semua
|
||||||
|
</button>
|
||||||
|
<small class="text-muted">Hapus Semua akan mengikuti filter aktif.</small>
|
||||||
|
@else
|
||||||
|
<small class="text-muted">Admin hanya dapat menghapus booking status pending atau dibatalkan.</small>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<thead class="bg-light">
|
<thead class="bg-light">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="ps-3" style="width: 44px;">
|
||||||
|
<input type="checkbox" class="form-check-input" id="selectAllBookings" title="Pilih semua di halaman ini">
|
||||||
|
</th>
|
||||||
<th class="ps-3">ID</th>
|
<th class="ps-3">ID</th>
|
||||||
<th>Kontrakan</th>
|
<th>Kontrakan</th>
|
||||||
<th>Penyewa</th>
|
<th>Penyewa</th>
|
||||||
|
|
@ -117,7 +140,13 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@forelse($bookings as $booking)
|
@forelse($bookings as $booking)
|
||||||
|
@php
|
||||||
|
$canDeleteBooking = auth()->user()->role === 'super_admin' || in_array($booking->status, ['pending', 'cancelled']);
|
||||||
|
@endphp
|
||||||
<tr>
|
<tr>
|
||||||
|
<td class="ps-3">
|
||||||
|
<input type="checkbox" class="form-check-input booking-checkbox" name="booking_ids[]" value="{{ $booking->id }}" form="bulkDeleteForm" {{ $canDeleteBooking ? '' : 'disabled' }}>
|
||||||
|
</td>
|
||||||
<td class="ps-3">
|
<td class="ps-3">
|
||||||
<span class="badge bg-light text-dark">#{{ $booking->id }}</span>
|
<span class="badge bg-light text-dark">#{{ $booking->id }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -188,7 +217,7 @@
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
@if(auth()->user()->role == 'super_admin' || $booking->status == 'pending')
|
@if(auth()->user()->role == 'super_admin' || in_array($booking->status, ['pending', 'cancelled']))
|
||||||
<form action="{{ route('admin.bookings.destroy', $booking->id) }}" method="POST" class="d-inline">
|
<form action="{{ route('admin.bookings.destroy', $booking->id) }}" method="POST" class="d-inline">
|
||||||
@csrf
|
@csrf
|
||||||
@method('DELETE')
|
@method('DELETE')
|
||||||
|
|
@ -202,7 +231,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="text-center py-5 text-muted">
|
<td colspan="9" class="text-center py-5 text-muted">
|
||||||
<i class="bi bi-calendar-x fs-1 d-block mb-2"></i>
|
<i class="bi bi-calendar-x fs-1 d-block mb-2"></i>
|
||||||
Belum ada data booking
|
Belum ada data booking
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -220,3 +249,115 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
|
@section('scripts')
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const selectAll = document.getElementById('selectAllBookings');
|
||||||
|
const checkboxes = Array.from(document.querySelectorAll('.booking-checkbox'));
|
||||||
|
const selectableCheckboxes = checkboxes.filter(item => !item.disabled);
|
||||||
|
const btnDeleteSelected = document.getElementById('btnDeleteSelected');
|
||||||
|
const btnDeleteAll = document.getElementById('btnDeleteAll');
|
||||||
|
const bulkDeleteForm = document.getElementById('bulkDeleteForm');
|
||||||
|
const bulkDeleteAction = document.getElementById('bulkDeleteAction');
|
||||||
|
|
||||||
|
function showWarning(message) {
|
||||||
|
if (typeof Swal !== 'undefined') {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'Perhatian',
|
||||||
|
text: message,
|
||||||
|
confirmButtonColor: '#d33'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(message, onConfirm) {
|
||||||
|
if (typeof Swal !== 'undefined') {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'Konfirmasi Hapus',
|
||||||
|
text: message,
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: 'Ya, hapus',
|
||||||
|
cancelButtonText: 'Batal',
|
||||||
|
confirmButtonColor: '#d33',
|
||||||
|
cancelButtonColor: '#6c757d',
|
||||||
|
reverseButtons: true
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirm(message)) {
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkedCount() {
|
||||||
|
return selectableCheckboxes.filter(item => item.checked).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSelectedState() {
|
||||||
|
const count = checkedCount();
|
||||||
|
btnDeleteSelected.disabled = count === 0;
|
||||||
|
btnDeleteSelected.innerHTML = count > 0
|
||||||
|
? `<i class="bi bi-trash me-1"></i>Hapus Terpilih (${count})`
|
||||||
|
: '<i class="bi bi-trash me-1"></i>Hapus Terpilih';
|
||||||
|
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.checked = selectableCheckboxes.length > 0 && count === selectableCheckboxes.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.addEventListener('change', function () {
|
||||||
|
selectableCheckboxes.forEach(item => {
|
||||||
|
item.checked = selectAll.checked;
|
||||||
|
});
|
||||||
|
refreshSelectedState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checkboxes.forEach(item => {
|
||||||
|
item.addEventListener('change', refreshSelectedState);
|
||||||
|
});
|
||||||
|
|
||||||
|
btnDeleteSelected.addEventListener('click', function () {
|
||||||
|
const count = checkedCount();
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
showWarning('Pilih minimal 1 booking yang ingin dihapus.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDelete(`Yakin ingin menghapus ${count} booking terpilih? Tindakan ini tidak dapat dibatalkan.`, function () {
|
||||||
|
bulkDeleteAction.value = 'selected';
|
||||||
|
bulkDeleteForm.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (btnDeleteAll) {
|
||||||
|
btnDeleteAll.addEventListener('click', function () {
|
||||||
|
const filterActive = {!! (request('status') || request('kontrakan_id')) ? 'true' : 'false' !!};
|
||||||
|
const message = filterActive
|
||||||
|
? 'Yakin ingin menghapus semua booking sesuai filter saat ini? Tindakan ini tidak dapat dibatalkan.'
|
||||||
|
: 'Yakin ingin menghapus semua data booking? Tindakan ini tidak dapat dibatalkan.';
|
||||||
|
|
||||||
|
confirmDelete(message, function () {
|
||||||
|
bulkDeleteAction.value = 'all';
|
||||||
|
bulkDeleteForm.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSelectedState();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@
|
||||||
Route::get('/', [BookingController::class, 'index'])->name('index');
|
Route::get('/', [BookingController::class, 'index'])->name('index');
|
||||||
Route::get('/create', [BookingController::class, 'create'])->name('create');
|
Route::get('/create', [BookingController::class, 'create'])->name('create');
|
||||||
Route::post('/', [BookingController::class, 'store'])->name('store');
|
Route::post('/', [BookingController::class, 'store'])->name('store');
|
||||||
|
Route::post('/bulk-destroy', [BookingController::class, 'bulkDestroy'])->name('bulk-destroy');
|
||||||
Route::get('/{booking}', [BookingController::class, 'show'])->name('show');
|
Route::get('/{booking}', [BookingController::class, 'show'])->name('show');
|
||||||
Route::get('/{booking}/edit', [BookingController::class, 'edit'])->name('edit');
|
Route::get('/{booking}/edit', [BookingController::class, 'edit'])->name('edit');
|
||||||
Route::put('/{booking}', [BookingController::class, 'update'])->name('update');
|
Route::put('/{booking}', [BookingController::class, 'update'])->name('update');
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,7 @@ echo.
|
||||||
|
|
||||||
REM Dapatkan IP address komputer
|
REM Dapatkan IP address komputer
|
||||||
echo Mendeteksi IP Address komputer...
|
echo Mendeteksi IP Address komputer...
|
||||||
for /f "tokens=2 delims=:" %%A in ('ipconfig ^| findstr /i "IPv4" ^| findstr /i "192.168"') do set IP=%%A
|
for /f %%I in ('powershell -NoProfile -Command "(Get-NetIPAddress -AddressFamily IPv4 ^| Where-Object IPAddress -NotLike '127.*' ^| Where-Object IPAddress -NotLike '169.254.*' ^| Where-Object IPAddress -NotLike '192.168.137.*' ^| Select-Object -First 1 -ExpandProperty IPAddress)"') do set IP=%%I
|
||||||
set IP=%IP: =%
|
|
||||||
|
|
||||||
if "%IP%"=="" (
|
if "%IP%"=="" (
|
||||||
echo ❌ ERROR: Tidak bisa menemukan IP Address!
|
echo ❌ ERROR: Tidak bisa menemukan IP Address!
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
<?php $__env->startSection('title', 'Kelola Booking'); ?>
|
<?php $__env->startSection('title', 'Kelola Booking'); ?>
|
||||||
|
|
||||||
<?php $__env->startSection('content'); ?>
|
<?php $__env->startSection('content'); ?>
|
||||||
|
|
@ -103,11 +101,34 @@
|
||||||
|
|
||||||
|
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-0 pb-0">
|
||||||
|
<form id="bulkDeleteForm" action="<?php echo e(route('admin.bookings.bulk-destroy')); ?>" method="POST" class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<?php echo csrf_field(); ?>
|
||||||
|
<input type="hidden" name="action" id="bulkDeleteAction" value="selected">
|
||||||
|
<input type="hidden" name="status" value="<?php echo e(request('status')); ?>">
|
||||||
|
<input type="hidden" name="kontrakan_id" value="<?php echo e(request('kontrakan_id')); ?>">
|
||||||
|
|
||||||
|
<button type="button" id="btnDeleteSelected" class="btn btn-danger btn-sm" disabled>
|
||||||
|
<i class="bi bi-trash me-1"></i>Hapus Terpilih
|
||||||
|
</button>
|
||||||
|
<?php if(auth()->user()->role === 'super_admin'): ?>
|
||||||
|
<button type="button" id="btnDeleteAll" class="btn btn-outline-danger btn-sm">
|
||||||
|
<i class="bi bi-trash3 me-1"></i>Hapus Semua
|
||||||
|
</button>
|
||||||
|
<small class="text-muted">Hapus Semua akan mengikuti filter aktif.</small>
|
||||||
|
<?php else: ?>
|
||||||
|
<small class="text-muted">Admin hanya dapat menghapus booking status pending atau dibatalkan.</small>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<thead class="bg-light">
|
<thead class="bg-light">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="ps-3" style="width: 44px;">
|
||||||
|
<input type="checkbox" class="form-check-input" id="selectAllBookings" title="Pilih semua di halaman ini">
|
||||||
|
</th>
|
||||||
<th class="ps-3">ID</th>
|
<th class="ps-3">ID</th>
|
||||||
<th>Kontrakan</th>
|
<th>Kontrakan</th>
|
||||||
<th>Penyewa</th>
|
<th>Penyewa</th>
|
||||||
|
|
@ -120,7 +141,13 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php $__empty_1 = true; $__currentLoopData = $bookings; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $booking): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?>
|
<?php $__empty_1 = true; $__currentLoopData = $bookings; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $booking): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?>
|
||||||
|
<?php
|
||||||
|
$canDeleteBooking = auth()->user()->role === 'super_admin' || in_array($booking->status, ['pending', 'cancelled']);
|
||||||
|
?>
|
||||||
<tr>
|
<tr>
|
||||||
|
<td class="ps-3">
|
||||||
|
<input type="checkbox" class="form-check-input booking-checkbox" name="booking_ids[]" value="<?php echo e($booking->id); ?>" form="bulkDeleteForm" <?php echo e($canDeleteBooking ? '' : 'disabled'); ?>>
|
||||||
|
</td>
|
||||||
<td class="ps-3">
|
<td class="ps-3">
|
||||||
<span class="badge bg-light text-dark">#<?php echo e($booking->id); ?></span>
|
<span class="badge bg-light text-dark">#<?php echo e($booking->id); ?></span>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -194,7 +221,7 @@
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if(auth()->user()->role == 'super_admin' || $booking->status == 'pending'): ?>
|
<?php if(auth()->user()->role == 'super_admin' || in_array($booking->status, ['pending', 'cancelled'])): ?>
|
||||||
<form action="<?php echo e(route('admin.bookings.destroy', $booking->id)); ?>" method="POST" class="d-inline">
|
<form action="<?php echo e(route('admin.bookings.destroy', $booking->id)); ?>" method="POST" class="d-inline">
|
||||||
<?php echo csrf_field(); ?>
|
<?php echo csrf_field(); ?>
|
||||||
<?php echo method_field('DELETE'); ?>
|
<?php echo method_field('DELETE'); ?>
|
||||||
|
|
@ -208,7 +235,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?>
|
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="text-center py-5 text-muted">
|
<td colspan="9" class="text-center py-5 text-muted">
|
||||||
<i class="bi bi-calendar-x fs-1 d-block mb-2"></i>
|
<i class="bi bi-calendar-x fs-1 d-block mb-2"></i>
|
||||||
Belum ada data booking
|
Belum ada data booking
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -228,4 +255,116 @@
|
||||||
</div>
|
</div>
|
||||||
<?php $__env->stopSection(); ?>
|
<?php $__env->stopSection(); ?>
|
||||||
|
|
||||||
|
<?php $__env->startSection('scripts'); ?>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const selectAll = document.getElementById('selectAllBookings');
|
||||||
|
const checkboxes = Array.from(document.querySelectorAll('.booking-checkbox'));
|
||||||
|
const selectableCheckboxes = checkboxes.filter(item => !item.disabled);
|
||||||
|
const btnDeleteSelected = document.getElementById('btnDeleteSelected');
|
||||||
|
const btnDeleteAll = document.getElementById('btnDeleteAll');
|
||||||
|
const bulkDeleteForm = document.getElementById('bulkDeleteForm');
|
||||||
|
const bulkDeleteAction = document.getElementById('bulkDeleteAction');
|
||||||
|
|
||||||
|
function showWarning(message) {
|
||||||
|
if (typeof Swal !== 'undefined') {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'Perhatian',
|
||||||
|
text: message,
|
||||||
|
confirmButtonColor: '#d33'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(message, onConfirm) {
|
||||||
|
if (typeof Swal !== 'undefined') {
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'Konfirmasi Hapus',
|
||||||
|
text: message,
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: 'Ya, hapus',
|
||||||
|
cancelButtonText: 'Batal',
|
||||||
|
confirmButtonColor: '#d33',
|
||||||
|
cancelButtonColor: '#6c757d',
|
||||||
|
reverseButtons: true
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.isConfirmed) {
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirm(message)) {
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkedCount() {
|
||||||
|
return selectableCheckboxes.filter(item => item.checked).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSelectedState() {
|
||||||
|
const count = checkedCount();
|
||||||
|
btnDeleteSelected.disabled = count === 0;
|
||||||
|
btnDeleteSelected.innerHTML = count > 0
|
||||||
|
? `<i class="bi bi-trash me-1"></i>Hapus Terpilih (${count})`
|
||||||
|
: '<i class="bi bi-trash me-1"></i>Hapus Terpilih';
|
||||||
|
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.checked = selectableCheckboxes.length > 0 && count === selectableCheckboxes.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.addEventListener('change', function () {
|
||||||
|
selectableCheckboxes.forEach(item => {
|
||||||
|
item.checked = selectAll.checked;
|
||||||
|
});
|
||||||
|
refreshSelectedState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checkboxes.forEach(item => {
|
||||||
|
item.addEventListener('change', refreshSelectedState);
|
||||||
|
});
|
||||||
|
|
||||||
|
btnDeleteSelected.addEventListener('click', function () {
|
||||||
|
const count = checkedCount();
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
showWarning('Pilih minimal 1 booking yang ingin dihapus.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDelete(`Yakin ingin menghapus ${count} booking terpilih? Tindakan ini tidak dapat dibatalkan.`, function () {
|
||||||
|
bulkDeleteAction.value = 'selected';
|
||||||
|
bulkDeleteForm.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (btnDeleteAll) {
|
||||||
|
btnDeleteAll.addEventListener('click', function () {
|
||||||
|
const filterActive = <?php echo (request('status') || request('kontrakan_id')) ? 'true' : 'false'; ?>;
|
||||||
|
const message = filterActive
|
||||||
|
? 'Yakin ingin menghapus semua booking sesuai filter saat ini? Tindakan ini tidak dapat dibatalkan.'
|
||||||
|
: 'Yakin ingin menghapus semua data booking? Tindakan ini tidak dapat dibatalkan.';
|
||||||
|
|
||||||
|
confirmDelete(message, function () {
|
||||||
|
bulkDeleteAction.value = 'all';
|
||||||
|
bulkDeleteForm.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSelectedState();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php $__env->stopSection(); ?>
|
||||||
|
|
||||||
<?php echo $__env->make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?><?php /**PATH C:\laragon\www\TA\spk_kontrakan\resources\views/admin/bookings/index.blade.php ENDPATH**/ ?>
|
<?php echo $__env->make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?><?php /**PATH C:\laragon\www\TA\spk_kontrakan\resources\views/admin/bookings/index.blade.php ENDPATH**/ ?>
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
|
|
||||||
|
|
||||||
<?php $__env->startSection('title', 'Halaman Tidak Ditemukan'); ?>
|
|
||||||
|
|
||||||
<?php $__env->startSection('content'); ?>
|
|
||||||
<div class="container-fluid px-4">
|
|
||||||
<div class="row align-items-center justify-content-center min-vh-100">
|
|
||||||
<div class="col-md-6 col-lg-5">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="mb-4">
|
|
||||||
<h1 class="display-1 fw-bold" style="color: #f5576c;">404</h1>
|
|
||||||
<p class="fs-4 fw-semibold mb-2">Halaman Tidak Ditemukan</p>
|
|
||||||
<p class="text-muted mb-4">
|
|
||||||
Maaf, halaman yang Anda cari tidak ditemukan.
|
|
||||||
Mungkin URL sudah berubah atau halaman telah dihapus.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex gap-2 justify-content-center">
|
|
||||||
<a href="<?php echo e(route('dashboard')); ?>" class="btn btn-primary">
|
|
||||||
<i class="bi bi-house me-2"></i>Kembali ke Dashboard
|
|
||||||
</a>
|
|
||||||
<a href="javascript:history.back()" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-arrow-left me-2"></i>Halaman Sebelumnya
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5">
|
|
||||||
<small class="text-muted">
|
|
||||||
<i class="bi bi-search me-1"></i>
|
|
||||||
Path: <?php echo e(request()->getPathInfo()); ?>
|
|
||||||
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
background: linear-gradient(135deg, #f5576c 0%, #f093fb 100%);
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container-fluid {
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
|
|
||||||
margin: 2rem auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.display-1 {
|
|
||||||
font-size: 5rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<?php $__env->stopSection(); ?>
|
|
||||||
|
|
||||||
<?php echo $__env->make('layouts.app', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?><?php /**PATH C:\laragon\www\TA\spk_kontrakan\resources\views/errors/404.blade.php ENDPATH**/ ?>
|
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
|
||||||
|
|
||||||
|
<?php $__env->startSection('title', 'Backup & Restore Database'); ?>
|
||||||
|
|
||||||
|
<?php $__env->startSection('content'); ?>
|
||||||
|
<div class="container-fluid px-4">
|
||||||
|
<style>
|
||||||
|
.backup-card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.08);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; padding: 2rem; color: white; margin-bottom: 2rem;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-2">
|
||||||
|
<i class="bi bi-cloud-arrow-down me-3"></i>Backup & Restore Database
|
||||||
|
</h2>
|
||||||
|
<p class="mb-0 fs-6">Kelola backup database untuk keamanan data</p>
|
||||||
|
</div>
|
||||||
|
<form action="<?php echo e(route('admin.backup.create')); ?>" method="POST">
|
||||||
|
<?php echo csrf_field(); ?>
|
||||||
|
<button type="submit" class="btn btn-light fw-semibold" onclick="return confirm('Mulai backup database?')">
|
||||||
|
<i class="bi bi-cloud-check me-2"></i>Buat Backup Baru
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert -->
|
||||||
|
<?php if(session('success')): ?>
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-check-circle-fill me-2"></i><?php echo e(session('success')); ?>
|
||||||
|
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if(session('error')): ?>
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i><?php echo e(session('error')); ?>
|
||||||
|
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Info Card -->
|
||||||
|
<div class="alert alert-info border-0 rounded-3 mb-4">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
<strong>Tips:</strong> Lakukan backup secara berkala untuk mengamankan data. Backup dibuat dalam format ZIP dan dapat di-download.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backups List -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white border-0 py-3">
|
||||||
|
<h5 class="mb-0 fw-semibold">
|
||||||
|
<i class="bi bi-archive me-2"></i>Daftar Backup (<?php echo e(count($backups)); ?>)
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<?php if(empty($backups)): ?>
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
|
||||||
|
<h5 class="text-muted mt-3">Tidak ada backup ditemukan</h5>
|
||||||
|
<p class="text-muted mb-4">Klik tombol "Buat Backup Baru" untuk membuat backup pertama Anda</p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="row g-3 p-4">
|
||||||
|
<?php $__currentLoopData = $backups; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $backup): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="backup-card card">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); width: 50px; height: 50px; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: white;">
|
||||||
|
<i class="bi bi-file-zip" style="font-size: 1.5rem;"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1 fw-semibold"><?php echo e($backup['name']); ?></h6>
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-calendar me-1"></i>
|
||||||
|
<?php echo e($backup['date_display']); ?>
|
||||||
|
|
||||||
|
<span class="mx-2">•</span>
|
||||||
|
<i class="bi bi-file-size me-1"></i>
|
||||||
|
<?php echo e(number_format($backup['size'] / 1024 / 1024, 2)); ?> MB
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="d-flex gap-2 justify-content-md-end mt-3 mt-md-0">
|
||||||
|
<a href="<?php echo e(route('admin.backup.download', $backup['name'])); ?>" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-download me-1"></i>Download
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form action="<?php echo e(route('admin.backup.restore', $backup['name'])); ?>" method="POST" style="display: inline;" onsubmit="return confirm('Restore dari backup ini? Data saat ini akan ditimpa!');">
|
||||||
|
<?php echo csrf_field(); ?>
|
||||||
|
<button type="submit" class="btn btn-sm btn-warning">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-1"></i>Restore
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action="<?php echo e(route('admin.backup.delete', $backup['name'])); ?>" method="POST" style="display: inline;" onsubmit="return confirm('Hapus backup ini?');">
|
||||||
|
<?php echo csrf_field(); ?>
|
||||||
|
<?php echo method_field('DELETE'); ?>
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">
|
||||||
|
<i class="bi bi-trash me-1"></i>Hapus
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup Statistics -->
|
||||||
|
<div class="row g-3 mt-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h6 class="fw-semibold mb-3">
|
||||||
|
<i class="bi bi-shield-check me-2" style="color: #667eea;"></i>Backup Information
|
||||||
|
</h6>
|
||||||
|
<small class="text-muted d-block mb-2">
|
||||||
|
<strong>Total Backups:</strong> <?php echo e(count($backups)); ?>
|
||||||
|
|
||||||
|
</small>
|
||||||
|
<small class="text-muted d-block mb-2">
|
||||||
|
<strong>Total Size:</strong> <?php echo e(number_format(array_sum(array_column($backups, 'size')) / 1024 / 1024, 2)); ?> MB
|
||||||
|
</small>
|
||||||
|
<?php if(count($backups) > 0): ?>
|
||||||
|
<small class="text-muted d-block">
|
||||||
|
<strong>Latest:</strong> <?php echo e($backups[0]['date_display']); ?>
|
||||||
|
|
||||||
|
</small>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card border-0 shadow-sm border-warning">
|
||||||
|
<div class="card-body p-4 bg-warning bg-opacity-10">
|
||||||
|
<h6 class="fw-semibold mb-3">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>Reminder
|
||||||
|
</h6>
|
||||||
|
<small class="text-muted d-block">
|
||||||
|
⚠️ Backup database secara rutin (minimal 1x sehari) untuk mencegah kehilangan data yang tidak dapat dipulihkan.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php $__env->stopSection(); ?>
|
||||||
|
|
||||||
|
<?php echo $__env->make('layouts.admin', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?><?php /**PATH C:\laragon\www\TA\spk_kontrakan\resources\views/admin/backup/index.blade.php ENDPATH**/ ?>
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
<?php $__env->startSection('title', 'Kelola Kriteria SAW'); ?>
|
<?php $__env->startSection('title', 'Kelola Kriteria SAW'); ?>
|
||||||
|
|
||||||
<?php $__env->startSection('content'); ?>
|
<?php $__env->startSection('content'); ?>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
<?php $__env->startSection('title', 'Daftar Kontrakan'); ?>
|
<?php $__env->startSection('title', 'Daftar Kontrakan'); ?>
|
||||||
|
|
||||||
<?php $__env->startSection('content'); ?>
|
<?php $__env->startSection('content'); ?>
|
||||||
|
|
@ -1508,7 +1506,7 @@ class="btn btn-sm btn-outline-secondary"
|
||||||
const searchSuggestions = document.getElementById('searchSuggestions');
|
const searchSuggestions = document.getElementById('searchSuggestions');
|
||||||
|
|
||||||
if (searchInput && searchSuggestions) {
|
if (searchInput && searchSuggestions) {
|
||||||
const commonSearches = ['WiFi', 'AC', 'Dapur', 'Kamar Mandi Dalam', 'Parkir', 'Listrik', 'Air', 'Dekat Kampus'];
|
const commonSearches = ['WiFi', 'AC', 'Dapur', 'Tempat Cuci Piring', 'Kamar Mandi Dalam', 'Parkir', 'Listrik', 'Air', 'Dekat Kampus'];
|
||||||
|
|
||||||
searchInput.addEventListener('input', function() {
|
searchInput.addEventListener('input', function() {
|
||||||
const value = this.value.toLowerCase();
|
const value = this.value.toLowerCase();
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
<?php $__env->startSection('title', 'Kelola User'); ?>
|
<?php $__env->startSection('title', 'Kelola User'); ?>
|
||||||
|
|
||||||
<?php $__env->startSection('content'); ?>
|
<?php $__env->startSection('content'); ?>
|
||||||
|
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
<!-- Toast Notification Component -->
|
|
||||||
<div id="toastContainer" class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 9999;">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.toast-container {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
pointer-events: auto;
|
|
||||||
min-width: 300px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
|
|
||||||
animation: slideIn 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
transform: translateX(400px);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateX(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.hide {
|
|
||||||
animation: slideOut 0.3s ease-in forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideOut {
|
|
||||||
to {
|
|
||||||
transform: translateX(400px);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-success {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-error {
|
|
||||||
background: linear-gradient(135deg, #f5576c 0%, #f093fb 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-warning {
|
|
||||||
background: linear-gradient(135deg, #ffa502 0%, #ffb84d 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-info {
|
|
||||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast .toast-header {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast .toast-body {
|
|
||||||
padding: 0;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function showToast(message, type = 'success', duration = 4000) {
|
|
||||||
const container = document.getElementById('toastContainer');
|
|
||||||
const toastId = 'toast-' + Date.now();
|
|
||||||
|
|
||||||
const icons = {
|
|
||||||
'success': 'bi-check-circle-fill',
|
|
||||||
'error': 'bi-exclamation-triangle-fill',
|
|
||||||
'warning': 'bi-exclamation-circle-fill',
|
|
||||||
'info': 'bi-info-circle-fill'
|
|
||||||
};
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<div id="${toastId}" class="toast show toast-${type}" role="alert">
|
|
||||||
<div class="d-flex align-items-center p-3">
|
|
||||||
<i class="bi ${icons[type] || icons.info} me-2"></i>
|
|
||||||
<span class="flex-grow-1">${message}</span>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
container.insertAdjacentHTML('beforeend', html);
|
|
||||||
const toastElement = document.getElementById(toastId);
|
|
||||||
|
|
||||||
if (duration > 0) {
|
|
||||||
setTimeout(() => {
|
|
||||||
toastElement.classList.add('hide');
|
|
||||||
setTimeout(() => toastElement.remove(), 300);
|
|
||||||
}, duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close button
|
|
||||||
toastElement.querySelector('.btn-close').addEventListener('click', () => {
|
|
||||||
toastElement.classList.add('hide');
|
|
||||||
setTimeout(() => toastElement.remove(), 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-show session toasts
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
<?php if(session('success')): ?>
|
|
||||||
showToast("<?php echo e(session('success')); ?>", 'success');
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if(session('error')): ?>
|
|
||||||
showToast("<?php echo e(session('error')); ?>", 'error');
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if(session('warning')): ?>
|
|
||||||
showToast("<?php echo e(session('warning')); ?>", 'warning');
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if(session('info')): ?>
|
|
||||||
showToast("<?php echo e(session('info')); ?>", 'info');
|
|
||||||
<?php endif; ?>
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<?php /**PATH C:\laragon\www\TA\spk_kontrakan\resources\views/components/toast-notification.blade.php ENDPATH**/ ?>
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
<?php $__env->startSection('title', 'Daftar Laundry'); ?>
|
<?php $__env->startSection('title', 'Daftar Laundry'); ?>
|
||||||
|
|
||||||
<?php $__env->startSection('content'); ?>
|
<?php $__env->startSection('content'); ?>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -21,6 +21,8 @@ class MyApp extends StatelessWidget {
|
||||||
|
|
||||||
static const _primary = Color(0xFF1565C0);
|
static const _primary = Color(0xFF1565C0);
|
||||||
static const _primaryDark = Color(0xFF0D47A1);
|
static const _primaryDark = Color(0xFF0D47A1);
|
||||||
|
static const _accent = Color(0xFF00897B);
|
||||||
|
static const _surfaceTint = Color(0xFFF3F7FB);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -30,12 +32,31 @@ class MyApp extends StatelessWidget {
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: _primary,
|
seedColor: _primary,
|
||||||
primary: _primary,
|
primary: _primary,
|
||||||
secondary: _primaryDark,
|
secondary: _accent,
|
||||||
surface: Colors.white,
|
surface: Colors.white,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
),
|
),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
scaffoldBackgroundColor: const Color(0xFFF7F8FC),
|
scaffoldBackgroundColor: _surfaceTint,
|
||||||
|
canvasColor: _surfaceTint,
|
||||||
|
splashFactory: InkSparkle.splashFactory,
|
||||||
|
textTheme: const TextTheme(
|
||||||
|
titleLarge: TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
color: Color(0xFF1B2A41),
|
||||||
|
),
|
||||||
|
titleMedium: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.15,
|
||||||
|
color: Color(0xFF23324D),
|
||||||
|
),
|
||||||
|
bodyLarge: TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF2B3A55),
|
||||||
|
),
|
||||||
|
bodyMedium: TextStyle(color: Color(0xFF3B4A64)),
|
||||||
|
),
|
||||||
appBarTheme: const AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
backgroundColor: _primary,
|
backgroundColor: _primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
|
@ -53,10 +74,14 @@ class MyApp extends StatelessWidget {
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
shadowColor: Colors.black.withOpacity(0.05),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
|
||||||
),
|
),
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
backgroundColor: _primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 24),
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 24),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
@ -68,31 +93,151 @@ class MyApp extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: _primary,
|
||||||
|
side: const BorderSide(color: Color(0xFFD2DFEF)),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 18),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textButtonTheme: TextButtonThemeData(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: _primary,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
||||||
|
color: _primary,
|
||||||
|
linearTrackColor: Color(0xFFE2EAF3),
|
||||||
|
),
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFFF7F8FC),
|
fillColor: const Color(0xFFFAFCFF),
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 14,
|
vertical: 14,
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
|
borderSide: const BorderSide(color: Color(0xFFE2EAF3)),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
|
borderSide: const BorderSide(color: Color(0xFFE2EAF3)),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: const BorderSide(color: _primary, width: 1.5),
|
borderSide: const BorderSide(color: _primary, width: 1.5),
|
||||||
),
|
),
|
||||||
|
hintStyle: const TextStyle(
|
||||||
|
color: Color(0xFF8D9BB0),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
chipTheme: ChipThemeData(
|
||||||
|
backgroundColor: const Color(0xFFE9F1FB),
|
||||||
|
selectedColor: const Color(0xFF1565C0),
|
||||||
|
secondarySelectedColor: const Color(0xFF1565C0),
|
||||||
|
disabledColor: const Color(0xFFE2E8F0),
|
||||||
|
labelStyle: const TextStyle(
|
||||||
|
color: Color(0xFF34435E),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
secondaryLabelStyle: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
side: const BorderSide(color: Color(0xFFD6E2F1)),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
snackBarTheme: SnackBarThemeData(
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
backgroundColor: const Color(0xFF1C2A44),
|
||||||
|
contentTextStyle: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dialogTheme: DialogThemeData(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomSheetTheme: const BottomSheetThemeData(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
modalBackgroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
selectedItemColor: _primary,
|
||||||
|
unselectedItemColor: Color(0xFF94A0B2),
|
||||||
|
type: BottomNavigationBarType.fixed,
|
||||||
|
elevation: 8,
|
||||||
|
),
|
||||||
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
indicatorColor: _primary.withOpacity(0.12),
|
||||||
|
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||||
|
final selected = states.contains(WidgetState.selected);
|
||||||
|
return TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||||
|
color: selected ? _primary : const Color(0xFF94A0B2),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
iconTheme: WidgetStateProperty.resolveWith((states) {
|
||||||
|
final selected = states.contains(WidgetState.selected);
|
||||||
|
return IconThemeData(
|
||||||
|
color: selected ? _primary : const Color(0xFF94A0B2),
|
||||||
|
size: 24,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
listTileTheme: const ListTileThemeData(
|
||||||
|
iconColor: Color(0xFF5A6B85),
|
||||||
|
textColor: Color(0xFF24344D),
|
||||||
|
tileColor: Colors.transparent,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
dividerTheme: const DividerThemeData(
|
dividerTheme: const DividerThemeData(
|
||||||
color: Color(0xFFEEEEEE),
|
color: Color(0xFFE8EDF4),
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
space: 0,
|
space: 0,
|
||||||
),
|
),
|
||||||
|
pageTransitionsTheme: const PageTransitionsTheme(
|
||||||
|
builders: {
|
||||||
|
TargetPlatform.android: FadeForwardsPageTransitionsBuilder(),
|
||||||
|
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||||
|
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
|
||||||
|
TargetPlatform.macOS: FadeUpwardsPageTransitionsBuilder(),
|
||||||
|
TargetPlatform.linux: FadeUpwardsPageTransitionsBuilder(),
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
home: const SplashScreen(),
|
home: const SplashScreen(),
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
final _bookingService = BookingService();
|
final _bookingService = BookingService();
|
||||||
final _imagePicker = ImagePicker();
|
final _imagePicker = ImagePicker();
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
|
int _currentTabIndex = 0;
|
||||||
|
|
||||||
List<Booking> _activeBookings = [];
|
List<Booking> _activeBookings = [];
|
||||||
List<Booking> _pastBookings = [];
|
List<Booking> _pastBookings = [];
|
||||||
|
|
@ -27,11 +28,20 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 2, vsync: this);
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
_tabController.addListener(_handleTabChange);
|
||||||
_loadBookings();
|
_loadBookings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleTabChange() {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_currentTabIndex != _tabController.index) {
|
||||||
|
setState(() => _currentTabIndex = _tabController.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_tabController.removeListener(_handleTabChange);
|
||||||
_tabController.dispose();
|
_tabController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -333,13 +343,28 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
const Text(
|
const Expanded(
|
||||||
'Booking Saya',
|
child: Column(
|
||||||
style: TextStyle(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
fontSize: 22,
|
children: [
|
||||||
fontWeight: FontWeight.w700,
|
Text(
|
||||||
color: Colors.white,
|
'Booking Saya',
|
||||||
letterSpacing: 0.3,
|
style: TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Pantau status aktif dan riwayat booking',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -353,35 +378,7 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
child: TabBar(
|
child: _buildSegmentedTabs(),
|
||||||
controller: _tabController,
|
|
||||||
indicator: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(11),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.08),
|
|
||||||
blurRadius: 4,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
dividerColor: Colors.transparent,
|
|
||||||
labelColor: const Color(0xFF1565C0),
|
|
||||||
unselectedLabelColor: Colors.white.withOpacity(0.85),
|
|
||||||
labelStyle: const TextStyle(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
unselectedLabelStyle: const TextStyle(
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
tabs: const [
|
|
||||||
Tab(text: 'Aktif'),
|
|
||||||
Tab(text: 'Riwayat'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -953,4 +950,90 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
(Match m) => '${m[1]}.',
|
(Match m) => '${m[1]}.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSegmentedTabs() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildSegmentItem(
|
||||||
|
index: 0,
|
||||||
|
icon: Icons.bolt_rounded,
|
||||||
|
label: 'Aktif',
|
||||||
|
count: _activeBookings.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _buildSegmentItem(
|
||||||
|
index: 1,
|
||||||
|
icon: Icons.history_rounded,
|
||||||
|
label: 'Riwayat',
|
||||||
|
count: _pastBookings.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSegmentItem({
|
||||||
|
required int index,
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required int count,
|
||||||
|
}) {
|
||||||
|
final selected = _currentTabIndex == index;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(11),
|
||||||
|
onTap: () {
|
||||||
|
_tabController.animateTo(index);
|
||||||
|
setState(() => _currentTabIndex = index);
|
||||||
|
},
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? Colors.white : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(11),
|
||||||
|
boxShadow: selected
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.08),
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 17,
|
||||||
|
color: selected
|
||||||
|
? const Color(0xFF1565C0)
|
||||||
|
: Colors.white.withOpacity(0.9),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'$label ($count)',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||||
|
fontSize: 13,
|
||||||
|
color: selected
|
||||||
|
? const Color(0xFF1565C0)
|
||||||
|
: Colors.white.withOpacity(0.9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
final _favoriteService = FavoriteService();
|
final _favoriteService = FavoriteService();
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
|
int _currentTabIndex = 0;
|
||||||
|
|
||||||
List<Kontrakan> _kontrakanFavorites = [];
|
List<Kontrakan> _kontrakanFavorites = [];
|
||||||
List<Laundry> _laundryFavorites = [];
|
List<Laundry> _laundryFavorites = [];
|
||||||
|
|
@ -27,11 +28,20 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 2, vsync: this);
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
_tabController.addListener(_handleTabChange);
|
||||||
_loadFavorites();
|
_loadFavorites();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleTabChange() {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_currentTabIndex != _tabController.index) {
|
||||||
|
setState(() => _currentTabIndex = _tabController.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_tabController.removeListener(_handleTabChange);
|
||||||
_tabController.dispose();
|
_tabController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -242,56 +252,7 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
child: TabBar(
|
child: _buildSegmentedTabs(),
|
||||||
controller: _tabController,
|
|
||||||
indicator: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(11),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.08),
|
|
||||||
blurRadius: 4,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
dividerColor: Colors.transparent,
|
|
||||||
labelColor: const Color(0xFF1565C0),
|
|
||||||
unselectedLabelColor: Colors.white.withOpacity(0.85),
|
|
||||||
labelStyle: const TextStyle(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
unselectedLabelStyle: const TextStyle(
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
tabs: [
|
|
||||||
Tab(
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.home_work_rounded, size: 18),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text('Kontrakan (${_kontrakanFavorites.length})'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Tab(
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.local_laundry_service_rounded,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text('Laundry (${_laundryFavorites.length})'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -786,6 +747,92 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSegmentedTabs() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildSegmentItem(
|
||||||
|
index: 0,
|
||||||
|
icon: Icons.home_work_rounded,
|
||||||
|
label: 'Kontrakan',
|
||||||
|
count: _kontrakanFavorites.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _buildSegmentItem(
|
||||||
|
index: 1,
|
||||||
|
icon: Icons.local_laundry_service_rounded,
|
||||||
|
label: 'Laundry',
|
||||||
|
count: _laundryFavorites.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSegmentItem({
|
||||||
|
required int index,
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required int count,
|
||||||
|
}) {
|
||||||
|
final selected = _currentTabIndex == index;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(11),
|
||||||
|
onTap: () {
|
||||||
|
_tabController.animateTo(index);
|
||||||
|
setState(() => _currentTabIndex = index);
|
||||||
|
},
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? Colors.white : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(11),
|
||||||
|
boxShadow: selected
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.08),
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 17,
|
||||||
|
color: selected
|
||||||
|
? const Color(0xFF1565C0)
|
||||||
|
: Colors.white.withOpacity(0.9),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'$label ($count)',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||||
|
fontSize: 13,
|
||||||
|
color: selected
|
||||||
|
? const Color(0xFF1565C0)
|
||||||
|
: Colors.white.withOpacity(0.9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState({
|
Widget _buildEmptyState({
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required String title,
|
required String title,
|
||||||
|
|
|
||||||
|
|
@ -233,133 +233,179 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
||||||
slivers: [
|
slivers: [
|
||||||
// ── Gradient Header ──
|
// ── Gradient Header ──
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 190,
|
expandedHeight: 210,
|
||||||
floating: false,
|
floating: false,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: const Color(0xFF1565C0),
|
backgroundColor: const Color(0xFF1565C0),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
background: Container(
|
background: Stack(
|
||||||
decoration: const BoxDecoration(
|
children: [
|
||||||
gradient: LinearGradient(
|
Container(
|
||||||
begin: Alignment.topLeft,
|
decoration: const BoxDecoration(
|
||||||
end: Alignment.bottomRight,
|
gradient: LinearGradient(
|
||||||
colors: [
|
begin: Alignment.topLeft,
|
||||||
Color(0xFF1565C0),
|
end: Alignment.bottomRight,
|
||||||
Color(0xFF0D47A1),
|
colors: [
|
||||||
Color(0xFF1A237E),
|
Color(0xFF1565C0),
|
||||||
],
|
Color(0xFF0D47A1),
|
||||||
|
Color(0xFF0A2E73),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Positioned(
|
||||||
child: SafeArea(
|
top: -30,
|
||||||
child: Padding(
|
right: -20,
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
child: Container(
|
||||||
child: Column(
|
width: 140,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
height: 140,
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
decoration: BoxDecoration(
|
||||||
children: [
|
color: Colors.white.withOpacity(0.09),
|
||||||
Row(
|
shape: BoxShape.circle,
|
||||||
children: [
|
),
|
||||||
Container(
|
),
|
||||||
padding: const EdgeInsets.all(10),
|
),
|
||||||
decoration: BoxDecoration(
|
Positioned(
|
||||||
color: Colors.white.withOpacity(0.15),
|
bottom: 30,
|
||||||
borderRadius: BorderRadius.circular(14),
|
left: -26,
|
||||||
border: Border.all(
|
child: Container(
|
||||||
color: Colors.white.withOpacity(0.2),
|
width: 90,
|
||||||
|
height: 90,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.07),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withOpacity(0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.school_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 26,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
const SizedBox(width: 14),
|
||||||
Icons.school_rounded,
|
Expanded(
|
||||||
color: Colors.white,
|
child: Column(
|
||||||
size: 26,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Kontrak Kampus',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
'Halo, ${_authService.currentUser?.name ?? "User"} 👋',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withOpacity(0.85),
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Container(
|
||||||
const SizedBox(width: 14),
|
decoration: BoxDecoration(
|
||||||
Expanded(
|
color: Colors.white.withOpacity(0.12),
|
||||||
child: Column(
|
borderRadius: BorderRadius.circular(12),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
border: Border.all(
|
||||||
|
color: Colors.white.withOpacity(0.18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.logout_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
onPressed: _handleLogout,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() => _selectedIndex = 1),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 13,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.94),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: Colors.white),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.08),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Icon(
|
||||||
'Kontrak Kampus',
|
Icons.search_rounded,
|
||||||
style: TextStyle(
|
color: Colors.grey[500],
|
||||||
color: Colors.white,
|
size: 22,
|
||||||
fontSize: 22,
|
),
|
||||||
fontWeight: FontWeight.w800,
|
const SizedBox(width: 12),
|
||||||
letterSpacing: 0.3,
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Cari kontrakan atau laundry...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey[500],
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
Container(
|
||||||
Text(
|
padding: const EdgeInsets.all(7),
|
||||||
'Halo, ${_authService.currentUser?.name ?? "User"} 👋',
|
decoration: BoxDecoration(
|
||||||
style: TextStyle(
|
color: const Color(0xFFE3F2FD),
|
||||||
color: Colors.white.withOpacity(0.85),
|
borderRadius: BorderRadius.circular(9),
|
||||||
fontSize: 14,
|
),
|
||||||
fontWeight: FontWeight.w400,
|
child: const Icon(
|
||||||
|
Icons.north_east_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: Color(0xFF1565C0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withOpacity(0.12),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.logout_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 22,
|
|
||||||
),
|
|
||||||
onPressed: _handleLogout,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => setState(() => _selectedIndex = 1),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 13,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.08),
|
|
||||||
blurRadius: 12,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.search_rounded,
|
|
||||||
color: Colors.grey[400],
|
|
||||||
size: 22,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
'Cari kontrakan atau laundry...',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.grey[400],
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -373,12 +419,12 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
||||||
gradient: const LinearGradient(
|
gradient: const LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF667EEA), Color(0xFF764BA2)],
|
colors: [Color(0xFF0F9D8A), Color(0xFF0A7F8C)],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF667EEA).withOpacity(0.3),
|
color: const Color(0xFF0F9D8A).withOpacity(0.32),
|
||||||
blurRadius: 16,
|
blurRadius: 16,
|
||||||
offset: const Offset(0, 6),
|
offset: const Offset(0, 6),
|
||||||
),
|
),
|
||||||
|
|
@ -1399,7 +1445,14 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(color: const Color(0xFFEEEEEE)),
|
border: Border.all(color: const Color(0xFFEDF2F8)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.035),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,13 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatDistanceValue(double km) {
|
||||||
|
if (km < 1) {
|
||||||
|
return '${(km * 1000).toStringAsFixed(0)} m';
|
||||||
|
}
|
||||||
|
return '${km.toStringAsFixed(2)} km';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -207,6 +214,11 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
color: Color(0xFF1A1A2E),
|
color: Color(0xFF1A1A2E),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Hunian nyaman dekat kampus untuk kebutuhan harian Anda',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
|
|
@ -229,7 +241,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
' /tahun',
|
' /thn',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
|
|
@ -241,11 +253,43 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
_buildSummaryChip(
|
||||||
|
icon: widget.kontrakan.isAvailable
|
||||||
|
? Icons.check_circle_rounded
|
||||||
|
: Icons.do_not_disturb_on_rounded,
|
||||||
|
label: widget.kontrakan.isAvailable
|
||||||
|
? 'Tersedia'
|
||||||
|
: 'Penuh',
|
||||||
|
color: widget.kontrakan.isAvailable
|
||||||
|
? const Color(0xFF2E7D32)
|
||||||
|
: const Color(0xFFE65100),
|
||||||
|
),
|
||||||
|
_buildSummaryChip(
|
||||||
|
icon: Icons.bed_rounded,
|
||||||
|
label: '${widget.kontrakan.jumlahKamar} kamar',
|
||||||
|
color: const Color(0xFF1565C0),
|
||||||
|
),
|
||||||
|
_buildSummaryChip(
|
||||||
|
icon: Icons.near_me_rounded,
|
||||||
|
label: _formatDistanceValue(
|
||||||
|
widget.kontrakan.jarakKampus,
|
||||||
|
),
|
||||||
|
color: const Color(0xFF00897B),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Location
|
// Location
|
||||||
_buildInfoRow(Icons.location_on, widget.kontrakan.alamat),
|
_buildInfoRow(Icons.location_on, widget.kontrakan.alamat),
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
Icons.directions_walk,
|
Icons.directions_walk,
|
||||||
'${widget.kontrakan.jarakKampus} km dari kampus',
|
'${_formatDistanceValue(widget.kontrakan.jarakKampus)} dari kampus',
|
||||||
),
|
),
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
Icons.bed,
|
Icons.bed,
|
||||||
|
|
@ -274,29 +318,54 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: widget.kontrakan.fasilitasList.map((f) {
|
children: widget.kontrakan.fasilitasList.isEmpty
|
||||||
return Container(
|
? [
|
||||||
padding: const EdgeInsets.symmetric(
|
Container(
|
||||||
horizontal: 12,
|
padding: const EdgeInsets.symmetric(
|
||||||
vertical: 8,
|
horizontal: 12,
|
||||||
),
|
vertical: 10,
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: const Color(0xFFE3F2FD),
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(10),
|
color: const Color(0xFFF3F7FB),
|
||||||
border: Border.all(
|
borderRadius: BorderRadius.circular(10),
|
||||||
color: const Color(0xFF1565C0).withOpacity(0.15),
|
border: Border.all(
|
||||||
),
|
color: const Color(0xFFDDE7F3),
|
||||||
),
|
),
|
||||||
child: Text(
|
),
|
||||||
f,
|
child: const Text(
|
||||||
style: const TextStyle(
|
'Fasilitas belum tersedia',
|
||||||
fontSize: 13,
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w500,
|
fontSize: 13,
|
||||||
color: Color(0xFF1565C0),
|
color: Color(0xFF5A6B85),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}).toList(),
|
]
|
||||||
|
: widget.kontrakan.fasilitasList.map((f) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFE3F2FD),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(
|
||||||
|
color: const Color(
|
||||||
|
0xFF1565C0,
|
||||||
|
).withOpacity(0.15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
f,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF1565C0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (widget.kontrakan.deskripsi != null) ...[
|
if (widget.kontrakan.deskripsi != null) ...[
|
||||||
|
|
@ -368,7 +437,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
Icon(Icons.chat_rounded, size: 20),
|
Icon(Icons.chat_rounded, size: 20),
|
||||||
SizedBox(width: 6),
|
SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'WhatsApp',
|
'Chat Pemilik',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|
@ -420,8 +489,8 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.kontrakan.isAvailable
|
widget.kontrakan.isAvailable
|
||||||
? 'Booking Sekarang'
|
? 'Ajukan Booking'
|
||||||
: 'Tidak Tersedia',
|
: 'Sedang Penuh',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|
@ -465,6 +534,36 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSummaryChip({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required Color color,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: color.withOpacity(0.22)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14, color: color),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildLocationCard() {
|
Widget _buildLocationCard() {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../models/laundry.dart';
|
import '../models/laundry.dart';
|
||||||
import '../services/location_service.dart';
|
import '../services/location_service.dart';
|
||||||
|
|
@ -145,87 +146,143 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
background: Container(
|
background: Stack(
|
||||||
decoration: const BoxDecoration(
|
fit: StackFit.expand,
|
||||||
gradient: LinearGradient(
|
children: [
|
||||||
begin: Alignment.topLeft,
|
CachedNetworkImage(
|
||||||
end: Alignment.bottomRight,
|
imageUrl: widget.laundry.primaryPhoto,
|
||||||
colors: [Color(0xFF00897B), Color(0xFF00695C)],
|
fit: BoxFit.cover,
|
||||||
),
|
placeholder: (context, url) => Container(
|
||||||
),
|
decoration: const BoxDecoration(
|
||||||
child: SafeArea(
|
gradient: LinearGradient(
|
||||||
child: Padding(
|
begin: Alignment.topLeft,
|
||||||
padding: const EdgeInsets.all(20),
|
end: Alignment.bottomRight,
|
||||||
child: Column(
|
colors: [Color(0xFF00897B), Color(0xFF00695C)],
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.2),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.local_laundry_service,
|
|
||||||
size: 40,
|
|
||||||
color: Color(0xFF00897B),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
widget.laundry.nama,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.star,
|
|
||||||
size: 20,
|
|
||||||
color: Colors.amber[300],
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
widget.laundry.rating.toStringAsFixed(
|
|
||||||
1,
|
|
||||||
),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.white.withValues(
|
|
||||||
alpha: 0.95,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) => Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF00897B), Color(0xFF00695C)],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.black.withOpacity(0.2),
|
||||||
|
Colors.black.withOpacity(0.55),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(
|
||||||
|
alpha: 0.2,
|
||||||
|
),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.local_laundry_service,
|
||||||
|
size: 40,
|
||||||
|
color: Color(0xFF00897B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.laundry.nama,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Layanan laundry cepat dan praktis',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white.withOpacity(0.9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.star,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.amber[300],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
widget.laundry.rating.toStringAsFixed(
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white.withValues(
|
||||||
|
alpha: 0.95,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
_buildHeaderChip(
|
||||||
|
icon: Icons.payments_rounded,
|
||||||
|
label:
|
||||||
|
'${widget.laundry.formattedHargaKiloan}/kg',
|
||||||
|
),
|
||||||
|
_buildHeaderChip(
|
||||||
|
icon: Icons.schedule_rounded,
|
||||||
|
label: widget.laundry.estimasiSelesai,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -289,7 +346,8 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Action Buttons
|
// Action Buttons
|
||||||
if (widget.laundry.noWhatsapp != null) ...[
|
if (widget.laundry.noWhatsapp != null &&
|
||||||
|
widget.laundry.noWhatsapp!.isNotEmpty) ...[
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
|
|
@ -298,7 +356,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
_launchWhatsApp(widget.laundry.noWhatsapp!),
|
_launchWhatsApp(widget.laundry.noWhatsapp!),
|
||||||
icon: const Icon(Icons.message, size: 24),
|
icon: const Icon(Icons.message, size: 24),
|
||||||
label: const Text(
|
label: const Text(
|
||||||
'Hubungi via WhatsApp',
|
'Chat Laundry',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -328,7 +386,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
),
|
),
|
||||||
icon: const Icon(Icons.map, size: 24),
|
icon: const Icon(Icons.map, size: 24),
|
||||||
label: const Text(
|
label: const Text(
|
||||||
'Lihat di Maps',
|
'Buka Maps',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -411,10 +469,10 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.purple.withValues(alpha: 0.1),
|
color: const Color(0xFF00897B).withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Colors.purple.withValues(alpha: 0.3),
|
color: const Color(0xFF00897B).withValues(alpha: 0.26),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -426,14 +484,18 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.location_on, color: Colors.purple, size: 24),
|
const Icon(
|
||||||
|
Icons.location_on,
|
||||||
|
color: Color(0xFF00897B),
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
const Text(
|
||||||
'Deteksi Lokasi Saya',
|
'Deteksi Lokasi Saya',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.black87,
|
color: Color(0xFF1A1A2E),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -444,7 +506,9 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
height: 20,
|
height: 20,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.purple),
|
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||||
|
Color(0xFF00897B),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -522,7 +586,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.purple,
|
backgroundColor: const Color(0xFF00897B),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
disabledBackgroundColor: Colors.grey,
|
disabledBackgroundColor: Colors.grey,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
|
|
@ -536,6 +600,33 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildHeaderChip({required IconData icon, required String label}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: Colors.white.withValues(alpha: 0.28)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 1),
|
||||||
|
Icon(icon, size: 13, color: Colors.white),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _detectLocation() async {
|
Future<void> _detectLocation() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
isLoadingLocation = true;
|
isLoadingLocation = true;
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import '../models/kontrakan.dart';
|
import '../models/kontrakan.dart';
|
||||||
import '../models/laundry.dart';
|
import '../models/laundry.dart';
|
||||||
import '../models/user.dart';
|
import '../models/user.dart';
|
||||||
import '../services/auth_service.dart';
|
import '../services/auth_service.dart';
|
||||||
|
import '../services/server_discovery_service.dart';
|
||||||
import '../widgets/kontrakan_card.dart';
|
import '../widgets/kontrakan_card.dart';
|
||||||
import '../widgets/laundry_card.dart';
|
import '../widgets/laundry_card.dart';
|
||||||
|
|
||||||
|
|
@ -41,7 +43,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
int _bobotKriteria4 = 15; // fasilitas (kontrakan) or layanan (laundry)
|
int _bobotKriteria4 = 15; // fasilitas (kontrakan) or layanan (laundry)
|
||||||
|
|
||||||
// Jenis layanan selection for laundry
|
// Jenis layanan selection for laundry
|
||||||
String _selectedJenisLayanan = 'reguler';
|
String _selectedJenisLayanan = 'harian';
|
||||||
|
|
||||||
// Location values untuk referensi jarak (deteksi lokasi user)
|
// Location values untuk referensi jarak (deteksi lokasi user)
|
||||||
double? _userLatitude;
|
double? _userLatitude;
|
||||||
|
|
@ -57,8 +59,8 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
_bobotHarga + _bobotJarak + _bobotKriteria3 + _bobotKriteria4;
|
_bobotHarga + _bobotJarak + _bobotKriteria3 + _bobotKriteria4;
|
||||||
|
|
||||||
Color get _categoryColor => widget.category == 'kontrakan'
|
Color get _categoryColor => widget.category == 'kontrakan'
|
||||||
? const Color(0xFF667eea)
|
? const Color(0xFF1565C0)
|
||||||
: const Color(0xFF764ba2);
|
: const Color(0xFF00897B);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -180,6 +182,69 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
return List.generate(((max - 10) ~/ 5) + 1, (i) => 10 + i * 5);
|
return List.generate(((max - 10) ~/ 5) + 1, (i) => 10 + i * 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _closestOption(List<int> options, int target) {
|
||||||
|
return options.reduce(
|
||||||
|
(a, b) => (a - target).abs() <= (b - target).abs() ? a : b,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getPriorityLabel(int value) {
|
||||||
|
if (value <= 20) return 'Rendah';
|
||||||
|
if (value <= 35) return 'Sedang';
|
||||||
|
if (value <= 50) return 'Tinggi';
|
||||||
|
return 'Prioritas';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getPriorityColor(int value) {
|
||||||
|
if (value <= 20) return const Color(0xFF8E8E93);
|
||||||
|
if (value <= 35) return const Color(0xFF00A389);
|
||||||
|
if (value <= 50) return const Color(0xFF2F80ED);
|
||||||
|
return const Color(0xFF7B61FF);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isConnectivityError(Object error) {
|
||||||
|
final msg = error.toString().toLowerCase();
|
||||||
|
return error is TimeoutException ||
|
||||||
|
error is SocketException ||
|
||||||
|
error is http.ClientException ||
|
||||||
|
msg.contains('future not completed') ||
|
||||||
|
msg.contains('connection') ||
|
||||||
|
msg.contains('timed out');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resetConnectionAndRetry() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
_noData = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ServerDiscoveryService.resetCache();
|
||||||
|
final found = await ServerDiscoveryService.discover();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
await _calculateSAW();
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_hasCalculated = true;
|
||||||
|
_errorMessage =
|
||||||
|
'Reset koneksi selesai, tetapi server belum ditemukan. Pastikan backend Laravel aktif di jaringan yang sama.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_hasCalculated = true;
|
||||||
|
_errorMessage = 'Gagal reset koneksi: $e';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _calculateSAW() async {
|
Future<void> _calculateSAW() async {
|
||||||
if (_totalBobot != 100) {
|
if (_totalBobot != 100) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|
@ -285,9 +350,11 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
debugPrint('SAW API Error: $e');
|
debugPrint('SAW API Error: $e');
|
||||||
debugPrint('URL: ${AppConfig.baseUrl}$endpoint');
|
debugPrint('URL: ${AppConfig.baseUrl}$endpoint');
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
final canReset = _isConnectivityError(e);
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage =
|
_errorMessage = canReset
|
||||||
'Tidak dapat terhubung ke server (${AppConfig.baseUrl}). Periksa koneksi internet Anda dan coba lagi.\n\nDetail: $e';
|
? 'Tidak dapat terhubung ke server (${AppConfig.baseUrl}). Silakan tekan tombol Reset Koneksi.\n\nDetail: $e'
|
||||||
|
: 'Tidak dapat terhubung ke server (${AppConfig.baseUrl}). Periksa koneksi internet Anda dan coba lagi.\n\nDetail: $e';
|
||||||
_hasCalculated = true;
|
_hasCalculated = true;
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -385,11 +452,11 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final categoryTitle = widget.category == 'kontrakan'
|
final categoryTitle = widget.category == 'kontrakan'
|
||||||
? 'Kontrak Kampus - Rekomendasi Kontrakan'
|
? 'Rekomendasi Kontrakan'
|
||||||
: 'Kontrak Kampus - Rekomendasi Laundry';
|
: 'Rekomendasi Laundry';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF5F5F5),
|
backgroundColor: const Color(0xFFF3F7FB),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: _categoryColor,
|
backgroundColor: _categoryColor,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
|
@ -425,6 +492,8 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
children: [
|
children: [
|
||||||
_buildUserInfoCard(),
|
_buildUserInfoCard(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
_buildSimpleStepCard(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
_buildMethodInfoCard(),
|
_buildMethodInfoCard(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (widget.category == 'laundry') ...[
|
if (widget.category == 'laundry') ...[
|
||||||
|
|
@ -444,6 +513,78 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSimpleStepCard() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: const Color(0xFFE4EDF7)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.route_rounded, size: 18, color: _categoryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Langkah Cepat',
|
||||||
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_buildStepLine(
|
||||||
|
'1',
|
||||||
|
'Pilih prioritas yang paling penting untuk Anda.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildStepLine('2', 'Pastikan total prioritas 100%.'),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildStepLine('3', 'Tekan Hitung, lalu pilih hasil teratas.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStepLine(String number, String text) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _categoryColor.withOpacity(0.12),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
number,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: _categoryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildUserInfoCard() {
|
Widget _buildUserInfoCard() {
|
||||||
final name = _currentUser?.name ?? '';
|
final name = _currentUser?.name ?? '';
|
||||||
final email = _currentUser?.email ?? '';
|
final email = _currentUser?.email ?? '';
|
||||||
|
|
@ -584,6 +725,13 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMethodInfoCard() {
|
Widget _buildMethodInfoCard() {
|
||||||
|
final kriteria3 = widget.category == 'kontrakan'
|
||||||
|
? 'jumlah kamar'
|
||||||
|
: 'kecepatan layanan';
|
||||||
|
final kriteria4 = widget.category == 'kontrakan'
|
||||||
|
? 'fasilitas'
|
||||||
|
: 'variasi layanan';
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -607,7 +755,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
Icon(Icons.analytics, color: Colors.white, size: 24),
|
Icon(Icons.analytics, color: Colors.white, size: 24),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Metode SAW',
|
'Cara Rekomendasi Bekerja',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
|
|
@ -618,7 +766,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Simple Additive Weighting (SAW) menghitung skor rekomendasi berdasarkan bobot kriteria yang Anda tentukan.',
|
'Pilih mana yang paling penting untuk Anda. Sistem akan mengurutkan hasil dari yang paling cocok.',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white.withOpacity(0.9),
|
color: Colors.white.withOpacity(0.9),
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|
@ -626,19 +774,74 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.2),
|
color: Colors.white.withOpacity(0.2),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Column(
|
||||||
'Vi = Σ(Wj × Rij)',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(
|
children: [
|
||||||
color: Colors.white.withOpacity(0.95),
|
Row(
|
||||||
fontSize: 14,
|
children: [
|
||||||
fontWeight: FontWeight.w600,
|
const Icon(Icons.adjust, size: 14, color: Colors.white),
|
||||||
fontFamily: 'monospace',
|
const SizedBox(width: 6),
|
||||||
),
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'1. Atur prioritas harga, jarak, $kriteria3, dan $kriteria4.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withOpacity(0.95),
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.adjust, size: 14, color: Colors.white),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'2. Total bobot harus 100% (otomatis diseimbangkan).',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withOpacity(0.95),
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.adjust, size: 14, color: Colors.white),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'3. Lihat urutan hasil, lalu pilih yang paling sesuai.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withOpacity(0.95),
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Tips: Fokuskan 1-2 prioritas utama agar hasil lebih akurat dan mudah dipilih.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white.withOpacity(0.9),
|
||||||
|
fontSize: 12,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -649,16 +852,16 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
Widget _buildJenisLayananSection() {
|
Widget _buildJenisLayananSection() {
|
||||||
final jenisOptions = [
|
final jenisOptions = [
|
||||||
{
|
{
|
||||||
'value': 'reguler',
|
'value': 'harian',
|
||||||
'label': 'Reguler',
|
'label': 'Harian',
|
||||||
'icon': Icons.schedule,
|
'icon': Icons.today,
|
||||||
'desc': 'Layanan standar dengan harga terjangkau',
|
'desc': 'Paket selesai harian dengan biaya lebih hemat',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'value': 'express',
|
'value': 'jam',
|
||||||
'label': 'Express',
|
'label': 'Jam',
|
||||||
'icon': Icons.flash_on,
|
'icon': Icons.schedule,
|
||||||
'desc': 'Layanan cepat dengan waktu lebih singkat',
|
'desc': 'Paket selesai dalam hitungan jam (lebih cepat)',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -852,6 +1055,8 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
'Bobot otomatis disesuaikan agar total selalu 100%',
|
'Bobot otomatis disesuaikan agar total selalu 100%',
|
||||||
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
|
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_buildPriorityGuide(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildBobotDropdown(
|
_buildBobotDropdown(
|
||||||
label: 'Harga',
|
label: 'Harga',
|
||||||
|
|
@ -930,6 +1135,93 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildPriorityGuide() {
|
||||||
|
final items = [
|
||||||
|
{
|
||||||
|
'label': 'Rendah',
|
||||||
|
'range': '10-20%',
|
||||||
|
'desc': 'Pengaruh kecil pada hasil.',
|
||||||
|
'color': const Color(0xFF8E8E93),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': 'Sedang',
|
||||||
|
'range': '25-35%',
|
||||||
|
'desc': 'Cukup penting.',
|
||||||
|
'color': const Color(0xFF00A389),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': 'Tinggi',
|
||||||
|
'range': '40-50%',
|
||||||
|
'desc': 'Sangat berpengaruh pada hasil.',
|
||||||
|
'color': const Color(0xFF2F80ED),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'label': 'Prioritas',
|
||||||
|
'range': '55-70%',
|
||||||
|
'desc': 'Faktor utama penentu hasil.',
|
||||||
|
'color': const Color(0xFF7B61FF),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blueGrey.withOpacity(0.06),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: Colors.blueGrey.withOpacity(0.16)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Arti level prioritas',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.grey[800],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...items.map((item) {
|
||||||
|
final color = item['color'] as Color;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(top: 2),
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey[700]),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '${item['label']} (${item['range']}): ',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
TextSpan(text: item['desc'] as String),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildBobotDropdown({
|
Widget _buildBobotDropdown({
|
||||||
required String label,
|
required String label,
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
|
|
@ -940,12 +1232,26 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
required ValueChanged<int?> onChanged,
|
required ValueChanged<int?> onChanged,
|
||||||
}) {
|
}) {
|
||||||
final isCost = tipe.toLowerCase() == 'cost';
|
final isCost = tipe.toLowerCase() == 'cost';
|
||||||
// Ensure current value is in options list
|
|
||||||
final safeValue = options.contains(value)
|
final safeValue = options.contains(value)
|
||||||
? value
|
? value
|
||||||
: options.reduce(
|
: _closestOption(options, value);
|
||||||
(a, b) => (a - value).abs() < (b - value).abs() ? a : b,
|
final priorityColor = _getPriorityColor(safeValue);
|
||||||
);
|
|
||||||
|
final quickTargets = [
|
||||||
|
{'label': 'Rendah', 'value': 15},
|
||||||
|
{'label': 'Sedang', 'value': 25},
|
||||||
|
{'label': 'Tinggi', 'value': 40},
|
||||||
|
{'label': 'Prioritas', 'value': 55},
|
||||||
|
];
|
||||||
|
final quickPresets = <Map<String, dynamic>>[];
|
||||||
|
final seenValues = <int>{};
|
||||||
|
for (final item in quickTargets) {
|
||||||
|
final mapped = _closestOption(options, item['value'] as int);
|
||||||
|
if (seenValues.add(mapped)) {
|
||||||
|
quickPresets.add({'label': item['label'], 'value': mapped});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -953,96 +1259,162 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: Colors.grey[200]!),
|
border: Border.all(color: Colors.grey[200]!),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Row(
|
||||||
padding: const EdgeInsets.all(8),
|
children: [
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
color: _categoryColor.withOpacity(0.1),
|
padding: const EdgeInsets.all(8),
|
||||||
borderRadius: BorderRadius.circular(8),
|
decoration: BoxDecoration(
|
||||||
),
|
color: _categoryColor.withOpacity(0.1),
|
||||||
child: Icon(icon, size: 20, color: _categoryColor),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.grey[800],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
child: Icon(icon, size: 20, color: _categoryColor),
|
||||||
Row(
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Text(
|
||||||
padding: const EdgeInsets.symmetric(
|
label,
|
||||||
horizontal: 6,
|
style: TextStyle(
|
||||||
vertical: 1,
|
fontSize: 14,
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
decoration: BoxDecoration(
|
color: Colors.grey[800],
|
||||||
color: isCost
|
|
||||||
? Colors.orange.withOpacity(0.15)
|
|
||||||
: Colors.green.withOpacity(0.15),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
tipe,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: isCost
|
|
||||||
? Colors.orange[800]
|
|
||||||
: Colors.green[800],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(height: 2),
|
||||||
Expanded(
|
Row(
|
||||||
child: Text(
|
children: [
|
||||||
tipeDesc,
|
Container(
|
||||||
style: TextStyle(fontSize: 10, color: Colors.grey[500]),
|
padding: const EdgeInsets.symmetric(
|
||||||
overflow: TextOverflow.ellipsis,
|
horizontal: 6,
|
||||||
),
|
vertical: 1,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isCost
|
||||||
|
? Colors.orange.withOpacity(0.15)
|
||||||
|
: Colors.green.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isCost ? 'Minimalkan' : 'Utamakan',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isCost
|
||||||
|
? Colors.orange[800]
|
||||||
|
: Colors.green[800],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
tipeDesc,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.grey[500],
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: _categoryColor.withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$safeValue%',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: _categoryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: priorityColor.withOpacity(0.12),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Prioritas ${_getPriorityLabel(safeValue)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: priorityColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'${options.first}% - ${options.last}%',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SliderTheme(
|
||||||
|
data: SliderTheme.of(context).copyWith(
|
||||||
|
activeTrackColor: _categoryColor,
|
||||||
|
inactiveTrackColor: _categoryColor.withOpacity(0.2),
|
||||||
|
thumbColor: _categoryColor,
|
||||||
|
overlayColor: _categoryColor.withOpacity(0.15),
|
||||||
|
trackHeight: 4,
|
||||||
|
),
|
||||||
|
child: Slider(
|
||||||
|
min: options.first.toDouble(),
|
||||||
|
max: options.last.toDouble(),
|
||||||
|
divisions: options.length > 1 ? options.length - 1 : null,
|
||||||
|
value: safeValue.toDouble(),
|
||||||
|
label: '$safeValue%',
|
||||||
|
onChanged: (double newValue) {
|
||||||
|
onChanged(_closestOption(options, newValue.round()));
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
Wrap(
|
||||||
Container(
|
spacing: 8,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
runSpacing: 8,
|
||||||
decoration: BoxDecoration(
|
children: quickPresets.map((preset) {
|
||||||
color: Colors.white,
|
final presetValue = preset['value'] as int;
|
||||||
borderRadius: BorderRadius.circular(8),
|
final selected = presetValue == safeValue;
|
||||||
border: Border.all(color: _categoryColor.withOpacity(0.3)),
|
return ChoiceChip(
|
||||||
),
|
label: Text('${preset['label']} ($presetValue%)'),
|
||||||
child: DropdownButtonHideUnderline(
|
selected: selected,
|
||||||
child: DropdownButton<int>(
|
onSelected: (_) => onChanged(presetValue),
|
||||||
value: safeValue,
|
selectedColor: _categoryColor.withOpacity(0.15),
|
||||||
isDense: true,
|
side: BorderSide(
|
||||||
icon: Icon(Icons.arrow_drop_down, color: _categoryColor),
|
color: selected
|
||||||
style: TextStyle(
|
? _categoryColor.withOpacity(0.5)
|
||||||
fontSize: 14,
|
: Colors.grey[300]!,
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: _categoryColor,
|
|
||||||
),
|
),
|
||||||
items: options
|
labelStyle: TextStyle(
|
||||||
.map(
|
fontSize: 11,
|
||||||
(int val) => DropdownMenuItem<int>(
|
color: selected ? _categoryColor : Colors.grey[700],
|
||||||
value: val,
|
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||||
child: Text('$val%'),
|
),
|
||||||
),
|
);
|
||||||
)
|
}).toList(),
|
||||||
.toList(),
|
|
||||||
onChanged: onChanged,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -1199,7 +1571,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
),
|
),
|
||||||
SizedBox(width: 12),
|
SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
'Menghitung SAW...',
|
'Menyiapkan rekomendasi...',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
|
|
@ -1325,13 +1697,13 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
_buildBobotMiniChip('Harga', _bobotHarga, 'Cost'),
|
_buildBobotMiniChip('Harga', _bobotHarga, true),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
_buildBobotMiniChip('Jarak', _bobotJarak, 'Cost'),
|
_buildBobotMiniChip('Jarak', _bobotJarak, true),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
_buildBobotMiniChip(_kriteria3Label, _bobotKriteria3, 'Benefit'),
|
_buildBobotMiniChip(_kriteria3Label, _bobotKriteria3, false),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
_buildBobotMiniChip(_kriteria4Label, _bobotKriteria4, 'Benefit'),
|
_buildBobotMiniChip(_kriteria4Label, _bobotKriteria4, false),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -1339,8 +1711,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBobotMiniChip(String label, int value, String tipe) {
|
Widget _buildBobotMiniChip(String label, int value, bool isCost) {
|
||||||
final isCost = tipe == 'Cost';
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
||||||
|
|
@ -1380,10 +1751,14 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
|
|
||||||
String _getJenisLayananLabel(String key) {
|
String _getJenisLayananLabel(String key) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
|
case 'harian':
|
||||||
|
return 'Harian';
|
||||||
|
case 'jam':
|
||||||
|
return 'Jam';
|
||||||
case 'reguler':
|
case 'reguler':
|
||||||
return 'Reguler';
|
return 'Harian';
|
||||||
case 'express':
|
case 'express':
|
||||||
return 'Express';
|
return 'Jam';
|
||||||
default:
|
default:
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
@ -1397,7 +1772,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
children: [
|
children: [
|
||||||
CircularProgressIndicator(),
|
CircularProgressIndicator(),
|
||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text('Menghitung rekomendasi SAW...'),
|
Text('Sedang menyiapkan rekomendasi terbaik...'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -1425,7 +1800,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
? (widget.category == 'kontrakan'
|
? (widget.category == 'kontrakan'
|
||||||
? 'Belum Ada Kontrakan Tersedia'
|
? 'Belum Ada Kontrakan Tersedia'
|
||||||
: 'Belum Ada Laundry Tersedia')
|
: 'Belum Ada Laundry Tersedia')
|
||||||
: 'Tidak Ada Hasil',
|
: 'Belum Ada Hasil yang Cocok',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -1440,18 +1815,40 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
if (!_noData)
|
if (!_noData)
|
||||||
ElevatedButton.icon(
|
Column(
|
||||||
onPressed: () => setState(() {
|
mainAxisSize: MainAxisSize.min,
|
||||||
_hasCalculated = false;
|
children: [
|
||||||
_errorMessage = null;
|
ElevatedButton.icon(
|
||||||
_noData = false;
|
onPressed: () => setState(() {
|
||||||
}),
|
_hasCalculated = false;
|
||||||
icon: const Icon(Icons.tune),
|
_errorMessage = null;
|
||||||
label: const Text('Ubah Bobot Kriteria'),
|
_noData = false;
|
||||||
style: ElevatedButton.styleFrom(
|
}),
|
||||||
backgroundColor: _categoryColor,
|
icon: const Icon(Icons.tune),
|
||||||
foregroundColor: Colors.white,
|
label: const Text('Atur Prioritas Lagi'),
|
||||||
),
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: _categoryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_errorMessage != null &&
|
||||||
|
(_errorMessage!.toLowerCase().contains(
|
||||||
|
'tidak dapat terhubung ke server',
|
||||||
|
) ||
|
||||||
|
_errorMessage!.toLowerCase().contains('timeout')))
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10),
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _resetConnectionAndRetry,
|
||||||
|
icon: const Icon(Icons.restart_alt),
|
||||||
|
label: const Text('Reset Koneksi'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: _categoryColor,
|
||||||
|
side: BorderSide(color: _categoryColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,11 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [Color(0xFF1565C0), Color(0xFF0D47A1)],
|
colors: [
|
||||||
|
Color(0xFF1565C0),
|
||||||
|
Color(0xFF0D47A1),
|
||||||
|
Color(0xFF0A2E73),
|
||||||
|
],
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
|
|
@ -276,20 +280,36 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
const Text(
|
Expanded(
|
||||||
'Cari & Jelajahi',
|
child: Column(
|
||||||
style: TextStyle(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
fontSize: 22,
|
children: [
|
||||||
fontWeight: FontWeight.w700,
|
const Text(
|
||||||
color: Colors.white,
|
'Cari & Jelajahi',
|
||||||
letterSpacing: 0.3,
|
style: TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Temukan kontrakan dan laundry lebih cepat',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white.withOpacity(0.85),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.12),
|
color: Colors.white.withOpacity(0.12),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withOpacity(0.2),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
|
|
@ -314,104 +334,30 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: GestureDetector(
|
child: _buildCategorySegment(
|
||||||
|
label: 'Kontrakan',
|
||||||
|
icon: Icons.home_work_rounded,
|
||||||
|
selected: _selectedCategory == 'Kontrakan',
|
||||||
|
activeColor: const Color(0xFF1565C0),
|
||||||
|
count: _filteredKontrakan.length,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() => _selectedCategory = 'Kontrakan');
|
setState(() => _selectedCategory = 'Kontrakan');
|
||||||
_applyFilters();
|
_applyFilters();
|
||||||
},
|
},
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _selectedCategory == 'Kontrakan'
|
|
||||||
? Colors.white
|
|
||||||
: Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(11),
|
|
||||||
boxShadow: _selectedCategory == 'Kontrakan'
|
|
||||||
? [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.08),
|
|
||||||
blurRadius: 4,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.home_work_rounded,
|
|
||||||
color: _selectedCategory == 'Kontrakan'
|
|
||||||
? const Color(0xFF1565C0)
|
|
||||||
: Colors.white.withOpacity(0.85),
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'Kontrakan',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: _selectedCategory == 'Kontrakan'
|
|
||||||
? const Color(0xFF1565C0)
|
|
||||||
: Colors.white.withOpacity(0.85),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: GestureDetector(
|
child: _buildCategorySegment(
|
||||||
|
label: 'Laundry',
|
||||||
|
icon: Icons.local_laundry_service_rounded,
|
||||||
|
selected: _selectedCategory == 'Laundry',
|
||||||
|
activeColor: const Color(0xFF00897B),
|
||||||
|
count: _filteredLaundry.length,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() => _selectedCategory = 'Laundry');
|
setState(() => _selectedCategory = 'Laundry');
|
||||||
_applyLaundryFilters();
|
_applyLaundryFilters();
|
||||||
},
|
},
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _selectedCategory == 'Laundry'
|
|
||||||
? Colors.white
|
|
||||||
: Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(11),
|
|
||||||
boxShadow: _selectedCategory == 'Laundry'
|
|
||||||
? [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.08),
|
|
||||||
blurRadius: 4,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.local_laundry_service_rounded,
|
|
||||||
color: _selectedCategory == 'Laundry'
|
|
||||||
? const Color(0xFF00897B)
|
|
||||||
: Colors.white.withOpacity(0.85),
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'Laundry',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: _selectedCategory == 'Laundry'
|
|
||||||
? const Color(0xFF00897B)
|
|
||||||
: Colors.white.withOpacity(0.85),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -482,27 +428,72 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||||
|
|
||||||
// Results Count
|
// Results Count
|
||||||
if (!_isLoading)
|
if (!_isLoading)
|
||||||
Container(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 6),
|
||||||
horizontal: 16,
|
child: Container(
|
||||||
vertical: 12,
|
padding: const EdgeInsets.symmetric(
|
||||||
),
|
horizontal: 14,
|
||||||
color: Colors.white,
|
vertical: 12,
|
||||||
child: Row(
|
),
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
Icon(Icons.filter_list, size: 18, color: Colors.grey[600]),
|
color: Colors.white,
|
||||||
const SizedBox(width: 8),
|
borderRadius: BorderRadius.circular(14),
|
||||||
Text(
|
border: Border.all(color: const Color(0xFFEAF0F6)),
|
||||||
_selectedCategory == 'Kontrakan'
|
boxShadow: [
|
||||||
? 'Ditemukan ${_filteredKontrakan.length} kontrakan'
|
BoxShadow(
|
||||||
: 'Ditemukan ${_filteredLaundry.length} laundry',
|
color: Colors.black.withOpacity(0.03),
|
||||||
style: TextStyle(
|
blurRadius: 8,
|
||||||
fontSize: 14,
|
offset: const Offset(0, 3),
|
||||||
color: Colors.grey[700],
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(7),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF1565C0).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.filter_alt_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: Color(0xFF1565C0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_selectedCategory == 'Kontrakan'
|
||||||
|
? 'Ditemukan ${_filteredKontrakan.length} kontrakan'
|
||||||
|
: 'Ditemukan ${_filteredLaundry.length} laundry',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 5,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF1F5FB),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_selectedFilter,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF4B5A70),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
@ -643,6 +634,62 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildCategorySegment({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
required bool selected,
|
||||||
|
required Color activeColor,
|
||||||
|
required int count,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? Colors.white : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(11),
|
||||||
|
boxShadow: selected
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.08),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
color: selected ? activeColor : Colors.white.withOpacity(0.85),
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'$label ($count)',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
|
||||||
|
color: selected
|
||||||
|
? activeColor
|
||||||
|
: Colors.white.withOpacity(0.85),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildKontrakanItem(Kontrakan kontrakan) {
|
Widget _buildKontrakanItem(Kontrakan kontrakan) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
|
@ -901,20 +948,43 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||||
topLeft: Radius.circular(16),
|
topLeft: Radius.circular(16),
|
||||||
bottomLeft: Radius.circular(16),
|
bottomLeft: Radius.circular(16),
|
||||||
),
|
),
|
||||||
child: Container(
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: laundry.primaryPhoto,
|
||||||
width: 120,
|
width: 120,
|
||||||
height: 140,
|
height: 140,
|
||||||
decoration: BoxDecoration(
|
fit: BoxFit.cover,
|
||||||
gradient: LinearGradient(
|
placeholder: (context, url) => Container(
|
||||||
begin: Alignment.topLeft,
|
width: 120,
|
||||||
end: Alignment.bottomRight,
|
height: 140,
|
||||||
colors: [Colors.cyan[400]!, Colors.cyan[600]!],
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Colors.cyan[400]!, Colors.cyan[600]!],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
errorWidget: (context, url, error) => Container(
|
||||||
Icons.local_laundry_service,
|
width: 120,
|
||||||
size: 50,
|
height: 140,
|
||||||
color: Colors.white,
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Colors.cyan[400]!, Colors.cyan[600]!],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.local_laundry_service,
|
||||||
|
size: 50,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,16 @@ class AuthService {
|
||||||
_currentUser = null;
|
_currentUser = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tangani response 401 secara terpusat.
|
||||||
|
/// Token lokal dibersihkan agar app tidak terus memakai token invalid.
|
||||||
|
Future<bool> handleUnauthorized(int statusCode) async {
|
||||||
|
if (statusCode == 401) {
|
||||||
|
await clearToken();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
Future<Map<String, dynamic>> register({
|
Future<Map<String, dynamic>> register({
|
||||||
required String name,
|
required String name,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@ class BookingService {
|
||||||
headers: _headers,
|
headers: _headers,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
await _authService.handleUnauthorized(response.statusCode);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
|
|
@ -83,6 +88,11 @@ class BookingService {
|
||||||
final response = await http.Response.fromStream(streamedResponse);
|
final response = await http.Response.fromStream(streamedResponse);
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
await _authService.handleUnauthorized(response.statusCode);
|
||||||
|
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
|
||||||
|
}
|
||||||
|
|
||||||
if (response.statusCode == 201 && data['success'] == true) {
|
if (response.statusCode == 201 && data['success'] == true) {
|
||||||
return {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
|
|
@ -111,6 +121,11 @@ class BookingService {
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
await _authService.handleUnauthorized(response.statusCode);
|
||||||
|
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
|
||||||
|
}
|
||||||
|
|
||||||
if (response.statusCode == 200 && data['success'] == true) {
|
if (response.statusCode == 200 && data['success'] == true) {
|
||||||
return {'success': true, 'message': data['message']};
|
return {'success': true, 'message': data['message']};
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -132,6 +147,11 @@ class BookingService {
|
||||||
headers: _headers,
|
headers: _headers,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
await _authService.handleUnauthorized(response.statusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
|
|
@ -173,6 +193,11 @@ class BookingService {
|
||||||
final response = await http.Response.fromStream(streamedResponse);
|
final response = await http.Response.fromStream(streamedResponse);
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
await _authService.handleUnauthorized(response.statusCode);
|
||||||
|
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
|
||||||
|
}
|
||||||
|
|
||||||
if (response.statusCode == 200 && data['success'] == true) {
|
if (response.statusCode == 200 && data['success'] == true) {
|
||||||
return {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ class FavoriteService {
|
||||||
debugPrint('[FAV] GET Body: ${response.body.length > 300 ? response.body.substring(0, 300) : response.body}');
|
debugPrint('[FAV] GET Body: ${response.body.length > 300 ? response.body.substring(0, 300) : response.body}');
|
||||||
|
|
||||||
if (response.statusCode == 401) {
|
if (response.statusCode == 401) {
|
||||||
|
await _authService.handleUnauthorized(response.statusCode);
|
||||||
debugPrint('[FAV] ❌ 401 Unauthorized — token expired/invalid');
|
debugPrint('[FAV] ❌ 401 Unauthorized — token expired/invalid');
|
||||||
return {'success': false, 'message': 'Sesi expired, silakan login ulang', 'kontrakan': [], 'laundry': []};
|
return {'success': false, 'message': 'Sesi expired, silakan login ulang', 'kontrakan': [], 'laundry': []};
|
||||||
}
|
}
|
||||||
|
|
@ -185,6 +186,7 @@ class FavoriteService {
|
||||||
debugPrint('[FAV] Toggle kontrakan → ${response.statusCode}: ${response.body}');
|
debugPrint('[FAV] Toggle kontrakan → ${response.statusCode}: ${response.body}');
|
||||||
|
|
||||||
if (response.statusCode == 401) {
|
if (response.statusCode == 401) {
|
||||||
|
await _authService.handleUnauthorized(response.statusCode);
|
||||||
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
|
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,6 +228,7 @@ class FavoriteService {
|
||||||
debugPrint('[FAV] Toggle laundry → ${response.statusCode}: ${response.body}');
|
debugPrint('[FAV] Toggle laundry → ${response.statusCode}: ${response.body}');
|
||||||
|
|
||||||
if (response.statusCode == 401) {
|
if (response.statusCode == 401) {
|
||||||
|
await _authService.handleUnauthorized(response.statusCode);
|
||||||
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
|
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,6 +269,11 @@ class FavoriteService {
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
await _authService.handleUnauthorized(response.statusCode);
|
||||||
|
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
|
||||||
|
}
|
||||||
|
|
||||||
if (response.statusCode == 200 && data['success'] == true) {
|
if (response.statusCode == 200 && data['success'] == true) {
|
||||||
return {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,11 @@ class ReviewService {
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
await _authService.handleUnauthorized(response.statusCode);
|
||||||
|
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
|
||||||
|
}
|
||||||
|
|
||||||
if (response.statusCode == 201 && data['success'] == true) {
|
if (response.statusCode == 201 && data['success'] == true) {
|
||||||
return {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
|
|
@ -73,6 +78,11 @@ class ReviewService {
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
await _authService.handleUnauthorized(response.statusCode);
|
||||||
|
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
|
||||||
|
}
|
||||||
|
|
||||||
if (response.statusCode == 201 && data['success'] == true) {
|
if (response.statusCode == 201 && data['success'] == true) {
|
||||||
return {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
|
|
@ -109,6 +119,11 @@ class ReviewService {
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
await _authService.handleUnauthorized(response.statusCode);
|
||||||
|
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
|
||||||
|
}
|
||||||
|
|
||||||
if (response.statusCode == 200 && data['success'] == true) {
|
if (response.statusCode == 200 && data['success'] == true) {
|
||||||
return {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
|
|
@ -137,6 +152,11 @@ class ReviewService {
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
await _authService.handleUnauthorized(response.statusCode);
|
||||||
|
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
|
||||||
|
}
|
||||||
|
|
||||||
if (response.statusCode == 200 && data['success'] == true) {
|
if (response.statusCode == 200 && data['success'] == true) {
|
||||||
return {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
|
|
|
||||||
|
|
@ -147,4 +147,10 @@ class ServerDiscoveryService {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_cacheKey, serverUrl);
|
await prefs.setString(_cacheKey, serverUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hapus cache URL server agar discovery dimulai dari nol.
|
||||||
|
static Future<void> resetCache() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_cacheKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue