Fix favorites system: improve error handling, type safety, and UI feedback

- Rewrite favorite_service.dart with proper error handling, auth checks, and timeouts
- Add error state UI to favorites_screen.dart with retry button
- Add SnackBar feedback on toggle failures in home/search/detail screens
- Type-safe parsing with per-item error handling to prevent crashes
- Clean orphan favorite records from database
- Add debugPrint logging for troubleshooting
- All changes maintain backward compatibility
This commit is contained in:
micko samawa 2026-03-07 00:45:30 +07:00
parent 73e8f197f2
commit 1a277067f4
74 changed files with 2825 additions and 14600 deletions

View File

@ -0,0 +1,107 @@
## 📋 FLUTTER DEVELOPMENT SETUP CHECKLIST
Sebelum menjalankan `flutter run`, pastikan semua ini sudah dilakukan:
### ✅ BACKEND SETUP
- [ ] **Jalankan Backend dengan Network Access**
```bash
# Pilih salah satu:
# Option 1: Pakai script (RECOMMENDED)
cd c:\laragon\www\TA\spk_kontrakan
start start-backend-network.bat
# Option 2: Manual command
php artisan serve --host=0.0.0.0 --port=8000
```
Expected output:
```
Starting Laravel development server: http://0.0.0.0:8000
```
- [ ] **Verifikasi Port 8000 Running**
```bash
netstat -ano | findstr :8000
# Harusnya ada: TCP 0.0.0.0:8000 LISTENING
```
### 🛡️ FIREWALL SETUP
- [ ] **Allow Port 8000 di Windows Firewall**
1. Buka "Windows Defender Firewall with Advanced Security"
2. Klik "Inbound Rules" → "New Rule"
3. Port → Specific port: 8000 → Allow
4. Atau: Allow app "php.exe" melalui firewall
### 📱 DEVICE SETUP
- [ ] **Device Terhubung ke Network Yang Sama**
- Device USB atau Emulator terhubung ke WiFi/network sama dengan komputer
- [ ] **Cek IP Komputer**
```bash
ipconfig | findstr IPv4
# Catat IP yang dimulai dengan 192.168.x.x
```
### 🔧 FLUTTER CONFIG
- [ ] **Update app_config.dart dengan IP yang Benar**
```dart
// File: lib/config/app_config.dart
static const String baseUrl = 'http://[YOUR-IP]:8000/api';
static const String storageUrl = 'http://[YOUR-IP]:8000/storage';
```
### 🚀 FLUTTER RUN
- [ ] **Clean & Run**
```bash
cd c:\laragon\www\TA\spk_mobile
flutter clean
flutter run
```
---
## 🆘 TROUBLESHOOTING
### ❌ "TimeoutException" - Tidak Bisa Terhubung ke Backend
**Checklist:**
1. ✅ Apakah backend running? `netstat -ano | findstr :8000`
2. ✅ Apakah firewall allow port 8000?
3. ✅ Apakah device satu network? Ping ke IP:
```bash
ping 192.168.18.16
```
4. ✅ Apakah IP di app_config.dart sesuai?
5. ✅ Coba akses via browser dulu: `http://192.168.18.16:8000`
### ✅ Akses OK di Browser tapi Flutter Gagal
- Kemungkinan: CORS issu atau Android Network Security
- Solusi: Cek di backend apakah CORS sudah enabled
### 🚫 "Connection Refused"
- Backend tidak running
- Solusi: Jalankan `start-backend-network.bat` terlebih dahulu
---
## 💡 BEST PRACTICES
1. **Selalu Jalankan Backend Terlebih Dahulu**
- Backend harus ready sebelum Flutter app Connect
2. **Simpan IP di Environment Variable (tidak hardcode)**
- Sudah tersedia di `environment.dart`
3. **Gunakan Script start-backend-network.bat**
- Lebih mudah dari mengetik command panjang
4. **Test dengan Browser Dulu**
- Akses `http://192.168.18.16:8000/api/kontrakan`
- Harus return JSON jika API berjalan

View File

@ -25,6 +25,14 @@ public function login(Request $request)
// FIXED: Hapus guard('admin'), pakai default Auth
if (Auth::attempt($credentials)) {
// Cek role - hanya admin dan super_admin yang boleh login
$user = Auth::user();
if (!in_array($user->role, ['admin', 'super_admin'])) {
Auth::logout();
$request->session()->invalidate();
return back()->with('error', 'Akses ditolak. Halaman ini khusus untuk pemilik bisnis.');
}
$request->session()->regenerate();
return redirect()->intended(route('dashboard'));
}

View File

@ -11,18 +11,31 @@
class FavoriteController extends Controller
{
/**
* List favorites user
* List favorites user separated by type
*/
public function index(Request $request)
{
$favorites = Favorite::with(['favoritable'])
->where('user_id', $request->user()->id)
->orderBy('created_at', 'desc')
->get();
$userId = $request->user()->id;
$kontrakanIds = Favorite::where('user_id', $userId)
->where('type', 'kontrakan')
->pluck('item_id')
->toArray();
$laundryIds = Favorite::where('user_id', $userId)
->where('type', 'laundry')
->pluck('item_id')
->toArray();
$kontrakanList = Kontrakan::whereIn('id', $kontrakanIds)->get();
$laundryList = Laundry::whereIn('id', $laundryIds)->get();
return response()->json([
'success' => true,
'data' => $favorites
'data' => [
'kontrakan' => $kontrakanList,
'laundry' => $laundryList,
],
], 200);
}
@ -41,8 +54,8 @@ public function toggleKontrakan(Request $request, $id)
}
$favorite = Favorite::where('user_id', $request->user()->id)
->where('favoritable_type', Kontrakan::class)
->where('favoritable_id', $id)
->where('type', 'kontrakan')
->where('item_id', $id)
->first();
if ($favorite) {
@ -56,17 +69,16 @@ public function toggleKontrakan(Request $request, $id)
], 200);
} else {
// Add to favorites
$favorite = Favorite::create([
Favorite::create([
'user_id' => $request->user()->id,
'favoritable_type' => Kontrakan::class,
'favoritable_id' => $id,
'type' => 'kontrakan',
'item_id' => $id,
]);
return response()->json([
'success' => true,
'message' => 'Kontrakan ditambahkan ke favorit',
'is_favorited' => true,
'data' => $favorite
], 201);
}
}
@ -86,8 +98,8 @@ public function toggleLaundry(Request $request, $id)
}
$favorite = Favorite::where('user_id', $request->user()->id)
->where('favoritable_type', Laundry::class)
->where('favoritable_id', $id)
->where('type', 'laundry')
->where('item_id', $id)
->first();
if ($favorite) {
@ -101,17 +113,16 @@ public function toggleLaundry(Request $request, $id)
], 200);
} else {
// Add to favorites
$favorite = Favorite::create([
Favorite::create([
'user_id' => $request->user()->id,
'favoritable_type' => Laundry::class,
'favoritable_id' => $id,
'type' => 'laundry',
'item_id' => $id,
]);
return response()->json([
'success' => true,
'message' => 'Laundry ditambahkan ke favorit',
'is_favorited' => true,
'data' => $favorite
], 201);
}
}

View File

@ -54,11 +54,11 @@ public function create(Request $request)
$timestamp = now()->format('Y-m-d-H-i-s');
$backupFile = $this->backupPath . "/backup_{$timestamp}.sql";
// Get database credentials
$database = env('DB_DATABASE');
$username = env('DB_USERNAME');
$password = env('DB_PASSWORD');
$host = env('DB_HOST');
// Get database credentials (use config() instead of env() for config:cache compatibility)
$database = config('database.connections.mysql.database');
$username = config('database.connections.mysql.username');
$password = config('database.connections.mysql.password');
$host = config('database.connections.mysql.host');
// Create SQL dump (Windows MySQL)
$command = sprintf(
@ -98,6 +98,8 @@ public function create(Request $request)
public function download($backup)
{
// Sanitize: prevent path traversal
$backup = basename($backup);
$backupPath = $this->backupPath . '/' . $backup;
if (!File::exists($backupPath)) {
@ -110,6 +112,8 @@ public function download($backup)
public function delete($backup)
{
try {
// Sanitize: prevent path traversal
$backup = basename($backup);
$backupPath = $this->backupPath . '/' . $backup;
if (!File::exists($backupPath)) {
@ -127,6 +131,8 @@ public function delete($backup)
public function restore(Request $request, $backup)
{
try {
// Sanitize: prevent path traversal
$backup = basename($backup);
$backupPath = $this->backupPath . '/' . $backup;
if (!File::exists($backupPath)) {
@ -154,11 +160,11 @@ public function restore(Request $request, $backup)
$sqlFile = $backupPath;
}
// Restore database
$database = env('DB_DATABASE');
$username = env('DB_USERNAME');
$password = env('DB_PASSWORD');
$host = env('DB_HOST');
// Restore database (use config() instead of env() for config:cache compatibility)
$database = config('database.connections.mysql.database');
$username = config('database.connections.mysql.username');
$password = config('database.connections.mysql.password');
$host = config('database.connections.mysql.host');
$command = sprintf(
'mysql --user=%s --password=%s --host=%s %s < "%s"',

View File

@ -29,7 +29,7 @@ public function index()
// ========== DATA TERBARU (No Cache) ==========
$recentKontrakan = Kontrakan::latest()
->select('id', 'nama', 'alamat', 'harga', 'jarak', 'luas', 'created_at') // Only needed columns
->select('id', 'nama', 'alamat', 'harga', 'jarak', 'jumlah_kamar', 'created_at') // Only needed columns
->take(5)
->get();
@ -86,15 +86,15 @@ public function index()
->selectRaw('
AVG(harga) as avg_harga,
AVG(jarak) as avg_jarak,
AVG(luas) as avg_luas,
AVG(jumlah_kamar) as avg_kamar,
MIN(harga) as min_harga,
MAX(harga) as max_harga
')
->first();
// 5. Top Kontrakan by Luas
$topKontrakan = Kontrakan::select('nama', 'harga', 'luas', 'jarak')
->orderBy('luas', 'desc')
// 5. Top Kontrakan by Harga
$topKontrakan = Kontrakan::select('nama', 'harga', 'jumlah_kamar', 'jarak')
->orderBy('harga', 'desc')
->take(5)
->get();
@ -136,7 +136,7 @@ public function index()
],
'avgHargaKontrakan' => $kontrakanStats->avg_harga ?? 0,
'avgJarakKontrakan' => $kontrakanStats->avg_jarak ?? 0,
'avgLuasKontrakan' => $kontrakanStats->avg_luas ?? 0,
'avgKamarKontrakan' => $kontrakanStats->avg_kamar ?? 0,
'minHargaKontrakan' => $kontrakanStats->min_harga ?? 0,
'maxHargaKontrakan' => $kontrakanStats->max_harga ?? 0,
'topKontrakan' => $topKontrakan,

View File

@ -176,20 +176,20 @@ public function laundryExcel(Request $request)
}
// Filter harga
if ($request->filled('harga_min')) {
if ($request->filled('harga_min') || $request->filled('harga_max')) {
$query->whereHas('layanan', function($q) use ($request) {
$q->where('harga', '>=', $request->harga_min);
});
}
if ($request->filled('harga_max')) {
$query->whereHas('layanan', function($q) use ($request) {
$q->where('harga', '<=', $request->harga_max);
if ($request->filled('harga_min')) {
$q->where('harga', '>=', $request->harga_min);
}
if ($request->filled('harga_max')) {
$q->where('harga', '<=', $request->harga_max);
}
});
}
// Filter jarak
if ($request->filled('jarak')) {
$jarakMeter = $request->jarak * 1000;
if ($request->filled('jarak_max')) {
$jarakMeter = $request->jarak_max * 1000;
$query->where('jarak', '<=', $jarakMeter);
}
@ -254,12 +254,16 @@ public function laundryPDF(Request $request)
});
}
// Filter harga
if ($request->filled('harga_min')) {
$query->where('harga', '>=', $request->harga_min);
}
if ($request->filled('harga_max')) {
$query->where('harga', '<=', $request->harga_max);
// Filter harga (harga ada di tabel layanan_laundry, bukan laundry)
if ($request->filled('harga_min') || $request->filled('harga_max')) {
$query->whereHas('layanan', function($q) use ($request) {
if ($request->filled('harga_min')) {
$q->where('harga', '>=', $request->harga_min);
}
if ($request->filled('harga_max')) {
$q->where('harga', '<=', $request->harga_max);
}
});
}
$laundry = $query->get();

View File

@ -106,6 +106,11 @@ public function setPrimaryKontrakan($id)
{
$galeri = Galeri::findOrFail($id);
// Pastikan galeri ini memang bertipe kontrakan
if ($galeri->type !== 'kontrakan') {
return back()->with('error', 'Foto ini bukan milik kontrakan');
}
// Reset semua foto di kontrakan yang sama jadi bukan primary
Galeri::where('type', 'kontrakan')
->where('item_id', $galeri->item_id)
@ -124,6 +129,11 @@ public function setPrimaryLaundry($id)
{
$galeri = Galeri::findOrFail($id);
// Pastikan galeri ini memang bertipe laundry
if ($galeri->type !== 'laundry') {
return back()->with('error', 'Foto ini bukan milik laundry');
}
// Reset semua foto di laundry yang sama jadi bukan primary
Galeri::where('type', 'laundry')
->where('item_id', $galeri->item_id)

View File

@ -223,7 +223,7 @@ public function store(Request $request)
'jarak' => $request->jarak,
'fasilitas' => $request->fasilitas,
'jumlah_kamar' => $request->jumlah_kamar,
'luas' => $request->luas ?? 0, // Default 0 jika tidak diisi
'bathroom_count' => $request->bathroom_count ?? 1,
'foto' => $filename,
]);
@ -333,7 +333,7 @@ public function update(Request $request, $id)
'jarak' => $request->jarak,
'fasilitas' => $request->fasilitas,
'jumlah_kamar' => $request->jumlah_kamar,
'luas' => $request->luas ?? $kontrakan->luas ?? 0, // Gunakan nilai lama jika tidak diisi
'bathroom_count' => $request->bathroom_count ?? $kontrakan->bathroom_count ?? 1,
'foto' => $filename,
]);

View File

@ -112,7 +112,7 @@ public function store(Request $request)
'tipe' => 'required|in:Benefit,Cost',
]);
Kriteria::create($request->all());
Kriteria::create($request->only(['tipe_bisnis', 'nama_kriteria', 'bobot', 'tipe']));
// Redirect dengan parameter filter
return redirect()
@ -139,7 +139,7 @@ public function update(Request $request, Kriteria $kriterium)
'tipe' => 'required|in:Benefit,Cost',
]);
$kriterium->update($request->all());
$kriterium->update($request->only(['tipe_bisnis', 'nama_kriteria', 'bobot', 'tipe']));
// Redirect dengan parameter filter
return redirect()

View File

@ -36,15 +36,14 @@ public function index(Request $request)
}
// ========== FILTER: RANGE HARGA (Min & Max dengan Slider) ==========
if ($request->filled('harga_min')) {
if ($request->filled('harga_min') || $request->filled('harga_max')) {
$query->whereHas('layanan', function($q) use ($request) {
$q->where('harga', '>=', $request->harga_min);
});
}
if ($request->filled('harga_max')) {
$query->whereHas('layanan', function($q) use ($request) {
$q->where('harga', '<=', $request->harga_max);
if ($request->filled('harga_min')) {
$q->where('harga', '>=', $request->harga_min);
}
if ($request->filled('harga_max')) {
$q->where('harga', '<=', $request->harga_max);
}
});
}

View File

@ -149,8 +149,8 @@ public function destroy($id)
{
$review = Review::findOrFail($id);
// Cek apakah user adalah pemilik review atau admin
if ($review->user_id !== Auth::id() && Auth::user()->role !== 'admin') {
// Cek apakah user adalah pemilik review atau admin/super_admin
if ($review->user_id !== Auth::id() && !in_array(Auth::user()->role, ['admin', 'super_admin'])) {
return back()->with('error', 'Anda tidak memiliki akses untuk menghapus review ini');
}

View File

@ -222,10 +222,7 @@ private function processData($data, $tipe, $jenisLayanan, $refLat, $refLng)
}
$item->harga_value = $layanan->harga ?? 0;
$item->kecepatan_value = $this->convertToHours(
$layanan->kecepatan ?? 0,
$layanan->satuan_kecepatan ?? 'jam'
);
$item->kecepatan_value = $layanan->estimasi_selesai ?? 0; // sudah dalam satuan jam
// LAUNDRY: Hitung jarak dari LOKASI USER
if ($item->latitude && $item->longitude) {
@ -389,20 +386,4 @@ private function normalize($nilai, $maxMin, $tipe)
}
}
// Convert kecepatan ke jam
private function convertToHours($kecepatan, $satuan)
{
try {
$kecepatan = floatval($kecepatan);
if ($satuan == 'hari') {
return $kecepatan * 24;
}
return $kecepatan;
} catch (Exception $e) {
Log::error('Error convertToHours: ' . $e->getMessage());
return 0;
}
}
}

View File

@ -33,7 +33,7 @@ public function index(Request $request)
if ($request->status === 'active') {
$query->whereNull('deleted_at');
} else {
$query->whereNotNull('deleted_at');
$query->onlyTrashed();
}
}
@ -91,15 +91,17 @@ public function update(Request $request, User $user)
$oldValues = $user->toArray();
if ($request->filled('password')) {
$user->password = Hash::make($request->password);
}
$user->update([
$updateData = [
'name' => $validated['name'],
'email' => $validated['email'],
'role' => $validated['role'],
]);
];
if ($request->filled('password')) {
$updateData['password'] = Hash::make($request->password);
}
$user->update($updateData);
ActivityLog::log('update', "Memperbarui user: {$user->name}", 'User', $user->id, $oldValues, $user->toArray());

View File

@ -9,7 +9,7 @@ class AdminAuth
{
public function handle($request, Closure $next)
{
if (! Auth::guard('admin')->check()) {
if (! Auth::check()) {
return redirect()->route('admin.login');
}

View File

@ -146,13 +146,13 @@ public static function archiveOldLogs($keepDays = 365)
// Dashboard Statistics
public static function getSummaryStats($userId = null)
{
$query = $userId ? self::where('user_id', $userId) : self::query();
$baseQuery = $userId ? self::where('user_id', $userId) : self::query();
return [
'total_actions' => $query->count(),
'today_actions' => $query->whereDate('created_at', today())->count(),
'this_week_actions' => $query->where('created_at', '>=', now()->startOfWeek())->count(),
'this_month_actions' => $query->whereMonth('created_at', now()->month)->count(),
'total_actions' => (clone $baseQuery)->count(),
'today_actions' => (clone $baseQuery)->whereDate('created_at', today())->count(),
'this_week_actions' => (clone $baseQuery)->where('created_at', '>=', now()->startOfWeek())->count(),
'this_month_actions' => (clone $baseQuery)->whereMonth('created_at', now()->month)->count(),
'most_common_action' => self::select('action')
->groupBy('action')
->orderByRaw('count(*) desc')
@ -162,7 +162,7 @@ public static function getSummaryStats($userId = null)
->groupBy('action')
->orderByRaw('count(*) desc')
->pluck('count', 'action'),
'recent_activity' => $query->with('user')
'recent_activity' => (clone $baseQuery)->with('user')
->latest()
->limit(10)
->get()

View File

@ -30,7 +30,7 @@ class Galeri extends Model
*/
public function kontrakan()
{
return $this->belongsTo(Kontrakan::class, 'item_id')->where('type', 'kontrakan');
return $this->belongsTo(Kontrakan::class, 'item_id');
}
/**
@ -38,7 +38,7 @@ public function kontrakan()
*/
public function laundry()
{
return $this->belongsTo(Laundry::class, 'item_id')->where('type', 'laundry');
return $this->belongsTo(Laundry::class, 'item_id');
}
/**

View File

@ -34,7 +34,7 @@ public function user()
*/
public function kontrakan()
{
return $this->belongsTo(Kontrakan::class, 'item_id')->where('type', 'kontrakan');
return $this->belongsTo(Kontrakan::class, 'item_id');
}
/**
@ -42,7 +42,7 @@ public function kontrakan()
*/
public function laundry()
{
return $this->belongsTo(Laundry::class, 'item_id')->where('type', 'laundry');
return $this->belongsTo(Laundry::class, 'item_id');
}
/**

View File

@ -97,4 +97,28 @@ public function activityLogs()
{
return $this->hasMany(ActivityLog::class);
}
/**
* Relationship ke Booking
*/
public function bookings()
{
return $this->hasMany(Booking::class);
}
/**
* Relationship ke Review
*/
public function reviews()
{
return $this->hasMany(Review::class);
}
/**
* Relationship ke Favorite
*/
public function favorites()
{
return $this->hasMany(Favorite::class);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -416,10 +416,6 @@ class="form-control border-start-0 @error('jumlah_kamar') is-invalid @enderror"
</div>
<small class="text-muted">Jumlah kamar tidur yang tersedia</small>
</div>
<i class="bi bi-info-circle me-1"></i>
Total kamar mandi (sistem akan otomatis menghitung kenyamanan)
</small>
</div>
<!-- Jarak - AUTO CALCULATED! -->
<div class="col-md-6">

View File

@ -568,21 +568,6 @@ class="btn btn-outline-secondary btn-sm"
</div>
</div>
<!-- Luas -->
<div class="col-md-6">
<div class="d-flex align-items-start">
<div class="bg-warning bg-opacity-10 rounded p-3 me-3">
<i class="bi bi-rulers text-warning fs-4"></i>
</div>
<div class="flex-grow-1">
<small class="text-muted d-block mb-1">Luas Bangunan</small>
<h5 class="mb-0 fw-semibold text-warning">
{{ $kontrakan->luas }}
</h5>
</div>
</div>
</div>
<!-- Jumlah Kamar Tidur -->
<div class="col-md-6">
<div class="d-flex align-items-start">
@ -728,8 +713,8 @@ class="btn btn-light w-100 fw-semibold"
<strong>Rp {{ number_format($kontrakan->harga / 12, 0, ',', '.') }}</strong>
</div>
<div class="d-flex justify-content-between align-items-center mb-3 pb-3 border-bottom">
<span class="text-muted">Harga/</span>
<strong>Rp {{ number_format($kontrakan->harga / $kontrakan->jumlah_kamar, 0, ',', '.') }}</strong>
<span class="text-muted">Harga/Kamar</span>
<strong>Rp {{ number_format($kontrakan->jumlah_kamar > 0 ? $kontrakan->harga / $kontrakan->jumlah_kamar : 0, 0, ',', '.') }}</strong>
</div>
<div class="d-flex justify-content-between align-items-center mb-3 pb-3 border-bottom">
<span class="text-muted">Status</span>

View File

@ -512,22 +512,41 @@ class="form-control"
<div class="col-md-6">
<label class="form-label fw-semibold">
Kecepatan <span class="text-danger">*</span>
Nama Paket <span class="text-danger">*</span>
</label>
<div class="input-group">
<input
type="number"
name="layanan[0][kecepatan]"
class="form-control"
placeholder="24"
min="1"
required
>
<select name="layanan[0][satuan_kecepatan]" class="form-select" style="max-width: 100px;">
<option value="jam">Jam</option>
<option value="hari">Hari</option>
</select>
</div>
<input
type="text"
name="layanan[0][nama_paket]"
class="form-control"
placeholder="Paket Reguler"
required
>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">
Estimasi Selesai (Jam) <span class="text-danger">*</span>
</label>
<input
type="number"
name="layanan[0][estimasi_selesai]"
class="form-control"
placeholder="24"
min="1"
required
>
</div>
<div class="col-md-12">
<label class="form-label fw-semibold">
Deskripsi <span class="text-muted">(Opsional)</span>
</label>
<textarea
name="layanan[0][deskripsi]"
class="form-control"
rows="2"
placeholder="Deskripsi singkat paket ini..."
></textarea>
</div>
</div>
</div>

View File

@ -113,7 +113,7 @@
<!-- FOTO ADA -->
<div class="position-relative" style="height: 400px; overflow: hidden;">
<img
src="{{ asset('uploads/laundry/' . $laundry->foto) }}"
src="{{ asset('uploads/Laundry/' . $laundry->foto) }}"
alt="{{ $laundry->nama }}"
class="w-100 h-100"
style="object-fit: cover; cursor: pointer;"
@ -232,9 +232,9 @@ class="btn btn-sm btn-danger">
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>Jenis Layanan</th>
<th>Harga/kg</th>
<th>Kecepatan</th>
<th>Nama Paket</th>
<th>Harga</th>
<th>Estimasi Selesai</th>
</tr>
</thead>
<tbody>
@ -242,7 +242,7 @@ class="btn btn-sm btn-danger">
<tr>
<td>
<span class="badge bg-primary">
{{ ucfirst($layanan->jenis_layanan) }}
{{ $layanan->nama_paket ?? ucfirst($layanan->jenis_layanan ?? '-') }}
</span>
</td>
<td>
@ -252,7 +252,7 @@ class="btn btn-sm btn-danger">
</td>
<td>
<span class="badge bg-warning text-dark">
{{ $layanan->kecepatan }} {{ $layanan->satuan_kecepatan }}
{{ $layanan->estimasi_selesai ? $layanan->estimasi_selesai . ' jam' : '-' }}
</span>
</td>
</tr>
@ -318,7 +318,7 @@ class="btn btn-sm btn-danger">
@endif
<div class="d-flex justify-content-between align-items-center mb-3 pb-3 border-bottom">
<span class="text-muted">Status</span>
<span class="badge bg-success">Buka</span>
<span class="badge bg-{{ ($laundry->status ?? 'buka') === 'buka' ? 'success' : 'danger' }}">{{ ($laundry->status ?? 'buka') === 'buka' ? 'Buka' : 'Tutup' }}</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span class="text-muted">Kategori</span>

View File

@ -54,6 +54,21 @@
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
/* Shared color variables for views */
--primary-color: #667eea;
--primary-color-rgb: 102, 126, 234;
--secondary-color: #764ba2;
--secondary-color-rgb: 118, 75, 162;
--info-color: #17a2b8;
--info-color-rgb: 23, 162, 184;
--success-color: #28a745;
--success-color-rgb: 40, 167, 69;
--warning-color: #ffc107;
--warning-color-rgb: 255, 193, 7;
--danger-color: #dc3545;
--danger-color-rgb: 220, 53, 69;
--text-muted: #6c757d;
}
* {

View File

@ -1036,7 +1036,6 @@ class="btn btn-sm btn-success rounded-pill px-3 py-1"
<h6 class="mb-0 fw-bold" style="color: var(--text-primary);">Rekomendasi Terbaik</h6>
</div>
</div>
</div>
<div class="card-body text-center p-3 p-md-4">
@if(count($hasil) > 0)
@if(isset($hasil[0]['foto']) && $hasil[0]['foto'])

View File

@ -173,7 +173,6 @@
<div class="auth-footer">
<p>Belum punya akun bisnis? <a href="{{ route('admin.register') }}">Daftar Sebagai Pemilik</a></p>
<p><a href="{{ route('admin.portal') }}"> Kembali ke Portal Pemilik</a></p>
</div>
</div>
</div>

View File

@ -31,7 +31,7 @@
}
.auth-header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px 20px;
text-align: center;
@ -197,7 +197,6 @@
<div class="auth-footer">
<p>Sudah punya akun? <a href="{{ route('admin.login') }}">Login di sini</a></p>
<p><a href="{{ route('admin.portal') }}"> Kembali ke Portal Pemilik</a></p>
</div>
</div>
</div>

View File

@ -567,7 +567,7 @@
@endif
@else
@if($item->foto)
<img src="{{ asset('uploads/laundry/' . $item->foto) }}" alt="{{ $item->nama }}">
<img src="{{ asset('uploads/Laundry/' . $item->foto) }}" alt="{{ $item->nama }}">
@else
<img src="https://via.placeholder.com/400x200/4facfe/ffffff?text={{ urlencode($item->nama) }}" alt="{{ $item->nama }}">
@endif

View File

@ -17,35 +17,15 @@
use App\Http\Controllers\BookingController; // ← BOOKING SYSTEM (ADMIN)
// -------------------------------------------------
// LANDING PAGE - Redirect ke Admin Portal
// LANDING PAGE - Langsung ke Login
// -------------------------------------------------
Route::get('/', function () {
return redirect()->route('admin.portal');
return redirect()->route('admin.login');
})->name('welcome');
// -------------------------------------------------
// ADMIN PORTAL - Landing page khusus untuk pemilik/admin
// -------------------------------------------------
Route::get('/admin-portal', function () {
// Hitung statistik real-time
$totalAdmins = \App\Models\User::where('role', 'admin')->count();
$totalKontrakan = \App\Models\Kontrakan::count();
$totalLaundry = \App\Models\Laundry::count();
$totalProperti = $totalKontrakan + $totalLaundry;
$totalBookings = \App\Models\Booking::where('status', 'confirmed')->count();
$avgRating = \App\Models\Review::avg('rating') ?? 4.8;
return view('admin-portal', [
'totalAdmins' => $totalAdmins,
'totalProperti' => $totalProperti,
'totalBookings' => $totalBookings,
'avgRating' => round($avgRating, 1)
]);
})->name('admin.portal');
// Shortcut URL - lebih mudah diingat
Route::get('/pemilik', function () {
return redirect()->route('admin.portal');
return redirect()->route('admin.login');
});
// -------------------------------------------------
@ -85,15 +65,15 @@
// ========== 🆕 ACTIVITY LOG ROUTES ==========
Route::prefix('admin')->name('admin.')->group(function () {
Route::resource('activity-logs', ActivityLogController::class)->only(['index', 'show']);
Route::get('/activity-logs/export', [ActivityLogController::class, 'export'])->name('activity-logs.export');
Route::post('/activity-logs/clear', [ActivityLogController::class, 'clear'])->name('activity-logs.clear');
Route::resource('activity-logs', ActivityLogController::class)->only(['index', 'show']);
});
// ========== 🆕 USER MANAGEMENT ROUTES ==========
Route::prefix('admin')->name('admin.')->group(function () {
Route::resource('users', UserManagementController::class);
Route::post('/users/{user}/restore', [UserManagementController::class, 'restore'])->name('users.restore');
Route::post('/users/{user}/restore', [UserManagementController::class, 'restore'])->name('users.restore')->withTrashed();
Route::post('/users/bulk-delete', [UserManagementController::class, 'bulkDelete'])->name('users.bulk-delete');
});
@ -186,7 +166,7 @@
});
// Legacy favorite routes (untuk kompatibilitas)
Route::post('/favorite', [FavoriteController::class, 'toggleOld'])->name('favorite.toggle');
Route::post('/favorite/{type}/{id}', [FavoriteController::class, 'toggle'])->name('favorite.toggle');
Route::post('/admin/logout', [AdminAuthController::class, 'logout'])->name('admin.logout');
});

View File

@ -0,0 +1,42 @@
@echo off
REM ============================================================================
REM Script untuk memulai Laravel Backend dengan Network Access
REM Tujuan: Agar Flutter Mobile Device bisa terhubung dari network
REM ============================================================================
cls
echo.
echo ╔════════════════════════════════════════════════════════╗
echo ║ 🚀 SPK Backend - Network Accessible Mode ║
echo ╚════════════════════════════════════════════════════════╝
echo.
REM Dapatkan 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
set IP=%IP: =%
if "%IP%"=="" (
echo ❌ ERROR: Tidak bisa menemukan IP Address!
echo Pastikan komputer terhubung ke network.
pause
exit /b 1
)
echo ✅ IP Address terdeteksi: %IP%
echo.
echo 📱 Backend akan accessible dari:
echo • Local: http://localhost:8000
echo • Network: http://%IP%:8000
echo • API: http://%IP%:8000/api
echo.
echo ⚙️ Starting Laravel Backend...
echo.
REM Pindah ke directory project
cd /d "%~dp0"
REM Jalankan Laravel serve dengan host 0.0.0.0
php artisan serve --host=0.0.0.0 --port=8000
pause

View File

@ -1,117 +0,0 @@
<?php if($paginator->hasPages()): ?>
<nav role="navigation" aria-label="<?php echo e(__('Pagination Navigation')); ?>" class="flex items-center justify-between">
<div class="flex justify-between flex-1 sm:hidden">
<?php if($paginator->onFirstPage()): ?>
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md dark:text-gray-600 dark:bg-gray-800 dark:border-gray-600">
<?php echo __('pagination.previous'); ?>
</span>
<?php else: ?>
<a href="<?php echo e($paginator->previousPageUrl()); ?>" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:focus:border-blue-700 dark:active:bg-gray-700 dark:active:text-gray-300">
<?php echo __('pagination.previous'); ?>
</a>
<?php endif; ?>
<?php if($paginator->hasMorePages()): ?>
<a href="<?php echo e($paginator->nextPageUrl()); ?>" class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:focus:border-blue-700 dark:active:bg-gray-700 dark:active:text-gray-300">
<?php echo __('pagination.next'); ?>
</a>
<?php else: ?>
<span class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md dark:text-gray-600 dark:bg-gray-800 dark:border-gray-600">
<?php echo __('pagination.next'); ?>
</span>
<?php endif; ?>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700 leading-5 dark:text-gray-400">
<?php echo __('Showing'); ?>
<?php if($paginator->firstItem()): ?>
<span class="font-medium"><?php echo e($paginator->firstItem()); ?></span>
<?php echo __('to'); ?>
<span class="font-medium"><?php echo e($paginator->lastItem()); ?></span>
<?php else: ?>
<?php echo e($paginator->count()); ?>
<?php endif; ?>
<?php echo __('of'); ?>
<span class="font-medium"><?php echo e($paginator->total()); ?></span>
<?php echo __('results'); ?>
</p>
</div>
<div>
<span class="relative z-0 inline-flex rtl:flex-row-reverse shadow-sm rounded-md">
<?php if($paginator->onFirstPage()): ?>
<span aria-disabled="true" aria-label="<?php echo e(__('pagination.previous')); ?>">
<span class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default rounded-l-md leading-5 dark:bg-gray-800 dark:border-gray-600" aria-hidden="true">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</span>
</span>
<?php else: ?>
<a href="<?php echo e($paginator->previousPageUrl()); ?>" rel="prev" class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-l-md leading-5 hover:text-gray-400 focus:z-10 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-500 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:active:bg-gray-700 dark:focus:border-blue-800" aria-label="<?php echo e(__('pagination.previous')); ?>">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</a>
<?php endif; ?>
<?php $__currentLoopData = $elements; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $element): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<?php if(is_string($element)): ?>
<span aria-disabled="true">
<span class="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-700 bg-white border border-gray-300 cursor-default leading-5 dark:bg-gray-800 dark:border-gray-600"><?php echo e($element); ?></span>
</span>
<?php endif; ?>
<?php if(is_array($element)): ?>
<?php $__currentLoopData = $element; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $page => $url): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<?php if($page == $paginator->currentPage()): ?>
<span aria-current="page">
<span class="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 dark:bg-gray-800 dark:border-gray-600"><?php echo e($page); ?></span>
</span>
<?php else: ?>
<a href="<?php echo e($url); ?>" class="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 hover:text-gray-500 focus:z-10 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:text-gray-300 dark:active:bg-gray-700 dark:focus:border-blue-800" aria-label="<?php echo e(__('Go to page :page', ['page' => $page])); ?>">
<?php echo e($page); ?>
</a>
<?php endif; ?>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
<?php endif; ?>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
<?php if($paginator->hasMorePages()): ?>
<a href="<?php echo e($paginator->nextPageUrl()); ?>" rel="next" class="relative inline-flex items-center px-2 py-2 -ml-px text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-r-md leading-5 hover:text-gray-400 focus:z-10 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-500 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:active:bg-gray-700 dark:focus:border-blue-800" aria-label="<?php echo e(__('pagination.next')); ?>">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</a>
<?php else: ?>
<span aria-disabled="true" aria-label="<?php echo e(__('pagination.next')); ?>">
<span class="relative inline-flex items-center px-2 py-2 -ml-px text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default rounded-r-md leading-5 dark:bg-gray-800 dark:border-gray-600" aria-hidden="true">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</span>
</span>
<?php endif; ?>
</span>
</div>
</div>
</nav>
<?php endif; ?>
<?php /**PATH C:\laragon\www\TA\spk_kontrakan\vendor\laravel\framework\src\Illuminate\Pagination/resources/views/tailwind.blade.php ENDPATH**/ ?>

View File

@ -1,163 +0,0 @@
<?php $__env->startSection('title', 'Activity Log'); ?>
<?php $__env->startSection('content'); ?>
<div class="container-fluid px-4">
<style>
.action-badge {
font-size: 0.8rem;
padding: 0.35rem 0.65rem;
border-radius: 6px;
}
.action-create { background: #d4edda; color: #155724; }
.action-update { background: #d1ecf1; color: #0c5460; }
.action-delete { background: #f8d7da; color: #721c24; }
.action-export { background: #fff3cd; color: #856404; }
.action-login { background: #cfe2ff; color: #084298; }
</style>
<!-- Header -->
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; padding: 2rem; color: white; margin-bottom: 2rem;">
<h2 class="mb-2">
<i class="bi bi-clock-history me-3"></i>Activity Log
</h2>
<p class="mb-0 fs-6">Riwayat semua aktivitas user dalam sistem</p>
</div>
<!-- Filter Card -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<form action="<?php echo e(route('admin.activity-logs.index')); ?>" method="GET" class="row g-3">
<div class="col-md-3">
<label class="form-label fw-semibold mb-2">User</label>
<select name="user_id" class="form-select">
<option value="">Semua User</option>
<?php $__currentLoopData = \App\Models\User::all(); $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $u): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<option value="<?php echo e($u->id); ?>" <?php echo e(request('user_id') == $u->id ? 'selected' : ''); ?>><?php echo e($u->name); ?></option>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
</select>
</div>
<div class="col-md-3">
<label class="form-label fw-semibold mb-2">Action</label>
<select name="action" class="form-select">
<option value="">Semua Action</option>
<option value="create" <?php echo e(request('action') == 'create' ? 'selected' : ''); ?>>Create</option>
<option value="update" <?php echo e(request('action') == 'update' ? 'selected' : ''); ?>>Update</option>
<option value="delete" <?php echo e(request('action') == 'delete' ? 'selected' : ''); ?>>Delete</option>
<option value="export" <?php echo e(request('action') == 'export' ? 'selected' : ''); ?>>Export</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label fw-semibold mb-2">Model Type</label>
<select name="model_type" class="form-select">
<option value="">Semua</option>
<option value="Kontrakan" <?php echo e(request('model_type') == 'Kontrakan' ? 'selected' : ''); ?>>Kontrakan</option>
<option value="Laundry" <?php echo e(request('model_type') == 'Laundry' ? 'selected' : ''); ?>>Laundry</option>
<option value="Kriteria" <?php echo e(request('model_type') == 'Kriteria' ? 'selected' : ''); ?>>Kriteria</option>
<option value="User" <?php echo e(request('model_type') == 'User' ? 'selected' : ''); ?>>User</option>
</select>
</div>
<div class="col-md-3 d-flex align-items-end gap-2">
<button type="submit" class="btn w-100" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<i class="bi bi-funnel me-2"></i>Filter
</button>
<a href="<?php echo e(route('admin.activity-logs.index')); ?>" class="btn btn-outline-secondary w-100">
<i class="bi bi-arrow-clockwise me-2"></i>Reset
</a>
</div>
</form>
</div>
</div>
<!-- Activity Table -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-list-ul me-2"></i>Daftar Aktivitas (<?php echo e($logs->total()); ?>)
</h5>
<a href="<?php echo e(route('admin.activity-logs.export', request()->query())); ?>" class="btn btn-sm" style="border: 2px solid #667eea; color: #667eea; background: transparent;">
<i class="bi bi-download me-1"></i>Export CSV
</a>
</div>
</div>
<div class="card-body p-0">
<?php if($logs->isEmpty()): ?>
<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 aktivitas ditemukan</h5>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Waktu</th>
<th>User</th>
<th>Action</th>
<th>Deskripsi</th>
<th>Model</th>
<th>IP Address</th>
</tr>
</thead>
<tbody>
<?php $__currentLoopData = $logs; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $log): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<tr class="align-middle">
<td>
<small><?php echo e($log->created_at->format('d M Y H:i')); ?></small>
</td>
<td>
<strong><?php echo e($log->user->name ?? 'Unknown'); ?></strong><br>
<small class="text-muted"><?php echo e($log->user->email ?? ''); ?></small>
</td>
<td>
<span class="action-badge action-<?php echo e(strtolower($log->action)); ?>">
<i class="bi bi-circle-fill" style="font-size: 0.5rem;"></i> <?php echo e(ucfirst($log->action)); ?>
</span>
</td>
<td>
<?php echo e($log->description); ?>
</td>
<td>
<?php if($log->model_type): ?>
<small class="text-muted">
<?php echo e($log->model_type); ?>
<?php if($log->model_id): ?>
#<?php echo e($log->model_id); ?>
<?php endif; ?>
</small>
<?php else: ?>
<span class="text-muted">-</span>
<?php endif; ?>
</td>
<td>
<small class="text-muted font-monospace"><?php echo e($log->ip_address); ?></small>
</td>
</tr>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<!-- Pagination -->
<div class="d-flex justify-content-center mt-4">
<?php echo e($logs->links()); ?>
</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/activity-logs/index.blade.php ENDPATH**/ ?>

View File

@ -1,231 +0,0 @@
<?php $__env->startSection('title', 'Kelola Booking'); ?>
<?php $__env->startSection('content'); ?>
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="fw-bold mb-1">
<i class="bi bi-calendar-check me-2" style="color: #667eea;"></i>Kelola Booking
</h2>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="<?php echo e(route('dashboard')); ?>" style="color: #667eea;">Dashboard</a></li>
<li class="breadcrumb-item active">Booking</li>
</ol>
</nav>
</div>
<a href="<?php echo e(route('admin.bookings.create')); ?>" class="btn" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<i class="bi bi-plus-lg me-1"></i>Booking Baru
</a>
</div>
<?php if(session('success')): ?>
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle 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-circle me-2"></i><?php echo e(session('error')); ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<form method="GET" class="row g-3">
<div class="col-md-4">
<label class="form-label small text-muted">Status</label>
<select name="status" class="form-select">
<option value="">Semua Status</option>
<option value="pending" <?php echo e(request('status') == 'pending' ? 'selected' : ''); ?>>Menunggu Konfirmasi</option>
<option value="confirmed" <?php echo e(request('status') == 'confirmed' ? 'selected' : ''); ?>>Dikonfirmasi</option>
<option value="checked_in" <?php echo e(request('status') == 'checked_in' ? 'selected' : ''); ?>>Sedang Ditempati</option>
<option value="completed" <?php echo e(request('status') == 'completed' ? 'selected' : ''); ?>>Selesai</option>
<option value="cancelled" <?php echo e(request('status') == 'cancelled' ? 'selected' : ''); ?>>Dibatalkan</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small text-muted">Kontrakan</label>
<select name="kontrakan_id" class="form-select">
<option value="">Semua Kontrakan</option>
<?php $__currentLoopData = $kontrakans; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $k): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<option value="<?php echo e($k->id); ?>" <?php echo e(request('kontrakan_id') == $k->id ? 'selected' : ''); ?>><?php echo e($k->nama); ?></option>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<button type="submit" class="btn me-2" style="border: 2px solid #667eea; color: #667eea; background: transparent;">
<i class="bi bi-filter me-1"></i>Filter
</button>
<a href="<?php echo e(route('admin.bookings.index')); ?>" class="btn btn-outline-secondary">
<i class="bi bi-x-lg"></i>
</a>
</div>
</form>
</div>
</div>
<div class="row g-3 mb-4">
<?php
$stats = [
['status' => 'pending', 'label' => 'Menunggu', 'color' => 'warning', 'icon' => 'hourglass-split'],
['status' => 'confirmed', 'label' => 'Dikonfirmasi', 'color' => 'info', 'icon' => 'check-circle'],
['status' => 'checked_in', 'label' => 'Ditempati', 'color' => 'success', 'icon' => 'house-door'],
['status' => 'completed', 'label' => 'Selesai', 'color' => 'secondary', 'icon' => 'check-all'],
];
?>
<?php $__currentLoopData = $stats; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $stat): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body text-center">
<i class="bi bi-<?php echo e($stat['icon']); ?> text-<?php echo e($stat['color']); ?> fs-3"></i>
<h4 class="fw-bold mb-0 mt-2">
<?php echo e(\App\Models\Booking::where('status', $stat['status'])->count()); ?>
</h4>
<small class="text-muted"><?php echo e($stat['label']); ?></small>
</div>
</div>
</div>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th class="ps-3">ID</th>
<th>Kontrakan</th>
<th>Penyewa</th>
<th>Periode</th>
<th>Biaya</th>
<th>Status</th>
<th>Pembayaran</th>
<th class="text-end pe-3">Aksi</th>
</tr>
</thead>
<tbody>
<?php $__empty_1 = true; $__currentLoopData = $bookings; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $booking): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?>
<tr>
<td class="ps-3">
<span class="badge bg-light text-dark">#<?php echo e($booking->id); ?></span>
</td>
<td>
<a href="<?php echo e(route('kontrakan.show', $booking->kontrakan_id)); ?>" class="text-decoration-none fw-semibold">
<?php echo e($booking->kontrakan->nama ?? '-'); ?>
</a>
</td>
<td>
<div class="fw-semibold"><?php echo e($booking->tenant_name); ?></div>
<small class="text-muted"><?php echo e($booking->tenant_phone); ?></small>
</td>
<td>
<div><?php echo e($booking->start_date->format('d M Y')); ?></div>
<small class="text-muted">s/d <?php echo e($booking->end_date->format('d M Y')); ?></small>
<br>
<span class="badge bg-light text-dark"><?php echo e($booking->duration_days); ?> hari</span>
</td>
<td>
<span class="fw-semibold">Rp <?php echo e(number_format($booking->amount, 0, ',', '.')); ?></span>
</td>
<td>
<span class="badge <?php echo e($booking->status_badge_class); ?>">
<?php echo e($booking->status_label); ?>
</span>
</td>
<td>
<span class="badge <?php echo e($booking->payment_status == 'paid' ? 'bg-success' : 'bg-secondary'); ?>">
<?php echo e($booking->payment_status_label); ?>
</span>
<?php if($booking->payment_proof): ?>
<a href="<?php echo e(asset('storage/' . $booking->payment_proof)); ?>" target="_blank" class="btn btn-sm btn-outline-success ms-1" title="Lihat Bukti Transfer">
<i class="bi bi-image"></i>
</a>
<?php endif; ?>
<?php if($booking->booking_source == 'user'): ?>
<span class="badge bg-info ms-1" title="Booking dari User">
<i class="bi bi-person"></i>
</span>
<?php endif; ?>
</td>
<td class="text-end pe-3">
<div class="btn-group">
<a href="<?php echo e(route('admin.bookings.show', $booking->id)); ?>" class="btn btn-sm btn-outline-primary" title="Detail">
<i class="bi bi-eye"></i>
</a>
<?php if($booking->status == 'pending'): ?>
<form action="<?php echo e(route('admin.bookings.confirm', $booking->id)); ?>" method="POST" class="d-inline">
<?php echo csrf_field(); ?>
<button type="submit" class="btn btn-sm btn-success" title="Konfirmasi" onclick="return confirm('Konfirmasi booking ini?')">
<i class="bi bi-check-lg"></i>
</button>
</form>
<?php endif; ?>
<?php if($booking->status == 'confirmed'): ?>
<form action="<?php echo e(route('admin.bookings.check-in', $booking->id)); ?>" method="POST" class="d-inline">
<?php echo csrf_field(); ?>
<button type="submit" class="btn btn-sm btn-info" title="Check-in" onclick="return confirm('Penyewa check-in?')">
<i class="bi bi-box-arrow-in-right"></i>
</button>
</form>
<?php endif; ?>
<?php if($booking->status == 'checked_in'): ?>
<form action="<?php echo e(route('admin.bookings.check-out', $booking->id)); ?>" method="POST" class="d-inline">
<?php echo csrf_field(); ?>
<button type="submit" class="btn btn-sm btn-warning" title="Check-out" onclick="return confirm('Penyewa check-out?')">
<i class="bi bi-box-arrow-right"></i>
</button>
</form>
<?php endif; ?>
<?php if(auth()->user()->role == 'super_admin' || $booking->status == 'pending'): ?>
<form action="<?php echo e(route('admin.bookings.destroy', $booking->id)); ?>" method="POST" class="d-inline">
<?php echo csrf_field(); ?>
<?php echo method_field('DELETE'); ?>
<button type="submit" class="btn btn-sm btn-danger" title="Hapus" onclick="return confirm('Yakin ingin menghapus booking ini? Tindakan ini tidak dapat dibatalkan!')">
<i class="bi bi-trash"></i>
</button>
</form>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?>
<tr>
<td colspan="8" class="text-center py-5 text-muted">
<i class="bi bi-calendar-x fs-1 d-block mb-2"></i>
Belum ada data booking
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php if($bookings->hasPages()): ?>
<div class="card-footer bg-transparent">
<?php echo e($bookings->withQueryString()->links()); ?>
</div>
<?php endif; ?>
</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/bookings/index.blade.php ENDPATH**/ ?>

View File

@ -1,427 +0,0 @@
<?php $__env->startSection('title', 'Portal Pemilik - Kelola Kontrakan & Laundry Anda'); ?>
<?php $__env->startSection('content'); ?>
<style>
/* ===== ADMIN PORTAL STYLES - Professional & Corporate ===== */
.admin-portal-hero {
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 50%, #7e22ce 100%);
background-size: 200% 200%;
animation: gradient-shift 15s ease infinite;
color: white;
padding: 80px 40px 120px;
margin: 0;
margin-left: calc(-50vw + 50%);
margin-right: calc(-50vw + 50%);
width: 100vw;
position: relative;
overflow: hidden;
box-shadow: 0 20px 60px rgba(30, 60, 114, 0.4);
}
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.admin-portal-hero::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
}
.admin-portal-hero::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 150px;
background: linear-gradient(to top, rgba(255,255,255,0.1) 0%, transparent 100%);
}
.portal-content {
position: relative;
z-index: 1;
max-width: 1200px;
margin: 0 auto;
}
.portal-badge {
display: inline-block;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
padding: 8px 20px;
border-radius: 30px;
font-size: 14px;
font-weight: 600;
margin-bottom: 20px;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.portal-title {
font-size: 3.5rem;
font-weight: 800;
margin-bottom: 25px;
line-height: 1.2;
text-shadow: 2px 2px 20px rgba(0,0,0,0.3);
}
.portal-subtitle {
font-size: 1.4rem;
margin-bottom: 40px;
opacity: 0.95;
font-weight: 300;
max-width: 700px;
}
.portal-buttons {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.btn-portal-primary {
background: white;
color: #1e3c72;
padding: 16px 40px;
border-radius: 12px;
font-weight: 700;
font-size: 1.1rem;
border: none;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
}
.btn-portal-primary:hover {
transform: translateY(-3px);
box-shadow: 0 15px 40px rgba(0,0,0,0.3);
color: #1e3c72;
}
.btn-portal-secondary {
background: transparent;
color: white;
padding: 16px 40px;
border-radius: 12px;
font-weight: 700;
font-size: 1.1rem;
border: 2px solid white;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
}
.btn-portal-secondary:hover {
background: white;
color: #1e3c72;
transform: translateY(-3px);
}
/* Features Section */
.features-admin {
padding: 80px 0;
background: #f8fafc;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 30px;
margin-top: 50px;
}
.feature-card-admin {
background: white;
padding: 40px;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0,0,0,0.08);
transition: all 0.3s ease;
border: 1px solid #e2e8f0;
}
.feature-card-admin:hover {
transform: translateY(-10px);
box-shadow: 0 20px 40px rgba(0,0,0,0.15);
}
.feature-icon {
width: 70px;
height: 70px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 25px;
font-size: 2rem;
}
.feature-title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 15px;
color: #1e293b;
}
.feature-description {
color: #64748b;
line-height: 1.7;
font-size: 1rem;
}
/* Stats Section */
.stats-section {
padding: 80px 0;
background: white;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 40px;
margin-top: 50px;
}
.stat-card {
text-align: center;
padding: 30px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20px;
color: white;
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
}
.stat-number {
font-size: 3rem;
font-weight: 800;
margin-bottom: 10px;
}
.stat-label {
font-size: 1.1rem;
opacity: 0.9;
}
/* CTA Section */
.cta-section {
padding: 100px 40px;
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
margin-left: calc(-50vw + 50%);
margin-right: calc(-50vw + 50%);
width: 100vw;
color: white;
text-align: center;
}
.cta-title {
font-size: 2.5rem;
font-weight: 800;
margin-bottom: 20px;
}
.cta-subtitle {
font-size: 1.3rem;
margin-bottom: 40px;
opacity: 0.9;
}
@media (max-width: 768px) {
.portal-title {
font-size: 2.5rem;
}
.portal-subtitle {
font-size: 1.1rem;
}
.portal-buttons {
flex-direction: column;
}
.btn-portal-primary,
.btn-portal-secondary {
width: 100%;
text-align: center;
}
}
</style>
<!-- Hero Section -->
<div class="admin-portal-hero">
<div class="portal-content">
<span class="portal-badge">
🏢 Portal Khusus Pemilik
</span>
<h1 class="portal-title">
Kelola Properti Anda<br>dengan Mudah & Efisien
</h1>
<p class="portal-subtitle">
Platform manajemen kontrakan dan laundry yang profesional. Pantau bisnis Anda, kelola pemesanan, dan tingkatkan pendapatan dengan sistem yang terintegrasi.
</p>
<div class="portal-buttons">
<a href="<?php echo e(route('admin.login')); ?>" class="btn-portal-primary">
🔐 Login Pemilik
</a>
<a href="<?php echo e(route('admin.register')); ?>" class="btn-portal-secondary">
📝 Daftar Sebagai Pemilik
</a>
</div>
</div>
</div>
<div class="container">
<!-- Features Section -->
<section class="features-admin">
<div class="text-center">
<h2 style="font-size: 2.5rem; font-weight: 800; color: #1e293b; margin-bottom: 15px;">
Fitur Lengkap untuk Pemilik
</h2>
<p style="font-size: 1.2rem; color: #64748b;">
Semua yang Anda butuhkan untuk mengelola bisnis properti Anda
</p>
</div>
<div class="features-grid">
<div class="feature-card-admin">
<div class="feature-icon">🏠</div>
<h3 class="feature-title">Manajemen Kontrakan</h3>
<p class="feature-description">
Kelola data kontrakan, upload foto, atur harga, dan update informasi dengan mudah. Sistem lengkap dengan galeri foto.
</p>
</div>
<div class="feature-card-admin">
<div class="feature-icon">👕</div>
<h3 class="feature-title">Kelola Laundry</h3>
<p class="feature-description">
Tambahkan layanan laundry, atur harga per layanan, dan kelola pesanan pelanggan secara real-time.
</p>
</div>
<div class="feature-card-admin">
<div class="feature-icon">📅</div>
<h3 class="feature-title">Sistem Booking</h3>
<p class="feature-description">
Pantau pemesanan masuk, konfirmasi booking, dan kelola jadwal ketersediaan properti Anda.
</p>
</div>
<div class="feature-card-admin">
<div class="feature-icon"></div>
<h3 class="feature-title">Review & Rating</h3>
<p class="feature-description">
Lihat ulasan pelanggan, tingkatkan kualitas layanan, dan bangun reputasi bisnis yang lebih baik.
</p>
</div>
<div class="feature-card-admin">
<div class="feature-icon">📊</div>
<h3 class="feature-title">Dashboard Analytics</h3>
<p class="feature-description">
Pantau performa bisnis dengan statistik lengkap, grafik penjualan, dan laporan yang detail.
</p>
</div>
<div class="feature-card-admin">
<div class="feature-icon">📱</div>
<h3 class="feature-title">Responsive Design</h3>
<p class="feature-description">
Kelola bisnis dari mana saja, kapan saja. Akses dashboard dari smartphone, tablet, atau komputer.
</p>
</div>
</div>
</section>
<!-- Stats Section -->
<section class="stats-section">
<div class="text-center">
<h2 style="font-size: 2.5rem; font-weight: 800; color: #1e293b; margin-bottom: 15px;">
Platform Terpercaya
</h2>
<p style="font-size: 1.2rem; color: #64748b;">
Bergabunglah dengan pemilik lainnya yang sudah merasakan kemudahan
</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-number" id="stat-owners"><?php echo e($totalAdmins); ?>+</div>
<div class="stat-label">Pemilik Terdaftar</div>
</div>
<div class="stat-card">
<div class="stat-number" id="stat-properties"><?php echo e($totalProperti); ?>+</div>
<div class="stat-label">Properti Dikelola</div>
</div>
<div class="stat-card">
<div class="stat-number" id="stat-bookings"><?php echo e($totalBookings); ?>+</div>
<div class="stat-label">Booking Sukses</div>
</div>
<div class="stat-card">
<div class="stat-number"><?php echo e($avgRating); ?>⭐</div>
<div class="stat-label">Rating Rata-rata</div>
</div>
</div>
</section>
</div>
<!-- CTA Section -->
<section class="cta-section">
<div style="max-width: 800px; margin: 0 auto;">
<h2 class="cta-title">Siap Memulai?</h2>
<p class="cta-subtitle">
Daftar sekarang dan dapatkan akses penuh ke dashboard manajemen properti
</p>
<a href="<?php echo e(route('admin.register')); ?>" class="btn-portal-primary" style="display: inline-block;">
Daftar Gratis Sekarang
</a>
</div>
</section>
<script>
// Animate numbers on scroll - UPDATED WITH REAL DATA
const animateValue = (element, start, end, duration, suffix = '+') => {
let startTimestamp = null;
const step = (timestamp) => {
if (!startTimestamp) startTimestamp = timestamp;
const progress = Math.min((timestamp - startTimestamp) / duration, 1);
const value = Math.floor(progress * (end - start) + start);
element.textContent = value + suffix;
if (progress < 1) {
window.requestAnimationFrame(step);
}
};
window.requestAnimationFrame(step);
};
// Observer for stats animation
const statsObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Gunakan data real dari backend
animateValue(document.getElementById('stat-owners'), 0, <?php echo e($totalAdmins); ?>, 2000);
animateValue(document.getElementById('stat-properties'), 0, <?php echo e($totalProperti); ?>, 2000);
animateValue(document.getElementById('stat-bookings'), 0, <?php echo e($totalBookings); ?>, 2000);
statsObserver.unobserve(entry.target);
}
});
});
const statsSection = document.querySelector('.stats-section');
if (statsSection) {
statsObserver.observe(statsSection);
}
</script>
<?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/admin-portal.blade.php ENDPATH**/ ?>

View File

@ -762,7 +762,7 @@
<?php $__currentLoopData = $recentLaundry; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $l): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<div class="list-group-item px-0 py-2">
<div class="d-flex w-100 justify-content-between align-items-start">
<div class="flex-grow-1 me-2">
<div class="flex-grow-1 me-2">
<h6 class="mb-1 fw-semibold" style="font-size: 0.875rem;"><?php echo e($l->nama); ?></h6>
<p class="mb-0 text-muted" style="font-size: 0.75rem;">
<i class="bi bi-geo-alt me-1"></i><?php echo e(Str::limit($l->alamat ?? '-', 25)); ?>

View File

@ -1,176 +0,0 @@
<?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(date('d M Y H:i', $backup['date'])); ?>
<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(date('d M Y H:i', $backups[0]['date'])); ?>
</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**/ ?>

View File

@ -54,6 +54,21 @@
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
/* Shared color variables for views */
--primary-color: #667eea;
--primary-color-rgb: 102, 126, 234;
--secondary-color: #764ba2;
--secondary-color-rgb: 118, 75, 162;
--info-color: #17a2b8;
--info-color-rgb: 23, 162, 184;
--success-color: #28a745;
--success-color-rgb: 40, 167, 69;
--warning-color: #ffc107;
--warning-color-rgb: 255, 193, 7;
--danger-color: #dc3545;
--danger-color-rgb: 220, 53, 69;
--text-muted: #6c757d;
}
* {

View File

@ -1,214 +0,0 @@
<?php $__env->startSection('title', 'Kelola User'); ?>
<?php $__env->startSection('content'); ?>
<div class="container-fluid px-4">
<style>
.header-users {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 2rem;
color: white;
margin-bottom: 2rem;
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.15);
}
.user-card {
border: none;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0,0,0,0.08);
transition: all 0.3s ease;
}
.user-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.2);
}
.badge-role {
font-size: 0.85rem;
padding: 0.35rem 0.75rem;
}
.badge-admin {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.badge-super-admin {
background: linear-gradient(135deg, #f5576c 0%, #f093fb 100%);
color: white;
}
</style>
<!-- Header -->
<div class="header-users">
<div class="row align-items-center">
<div class="col-md-8">
<h2 class="mb-2">
<i class="bi bi-people-fill me-3"></i>Kelola User Administrator
</h2>
<p class="mb-0 fs-6">Manage user accounts dan permissions</p>
</div>
<div class="col-md-4 text-md-end mt-3 mt-md-0">
<a href="<?php echo e(route('admin.users.create')); ?>" class="btn btn-light fw-semibold">
<i class="bi bi-plus-circle me-2"></i>Tambah User
</a>
</div>
</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; ?>
<!-- Filter & Search -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<form action="<?php echo e(route('admin.users.index')); ?>" method="GET" class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold mb-2">Cari User</label>
<input type="text" name="search" class="form-control" placeholder="Nama atau email..." value="<?php echo e(request('search')); ?>">
</div>
<div class="col-md-3">
<label class="form-label fw-semibold mb-2">Role</label>
<select name="role" class="form-select">
<option value="">Semua Role</option>
<option value="admin" <?php echo e(request('role') == 'admin' ? 'selected' : ''); ?>>Admin</option>
<option value="super_admin" <?php echo e(request('role') == 'super_admin' ? 'selected' : ''); ?>>Super Admin</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label fw-semibold mb-2">Status</label>
<select name="status" class="form-select">
<option value="">Semua</option>
<option value="active" <?php echo e(request('status') == 'active' ? 'selected' : ''); ?>>Aktif</option>
<option value="inactive" <?php echo e(request('status') == 'inactive' ? 'selected' : ''); ?>>Tidak Aktif</option>
</select>
</div>
<div class="col-12 d-flex gap-2">
<button type="submit" class="btn" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<i class="bi bi-search me-2"></i>Cari
</button>
<a href="<?php echo e(route('admin.users.index')); ?>" class="btn btn-outline-secondary">
<i class="bi bi-arrow-clockwise me-2"></i>Reset
</a>
</div>
</form>
</div>
</div>
<!-- Users Table -->
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 py-3">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-list-ul me-2"></i>Daftar User (<?php echo e($users->total()); ?>)
</h5>
</div>
</div>
<div class="card-body p-0">
<?php if($users->isEmpty()): ?>
<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 user ditemukan</h5>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Nama</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Dibuat</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
<?php $__currentLoopData = $users; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $user): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<tr>
<td>
<div class="d-flex align-items-center gap-3">
<div class="avatar-sm rounded-circle" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold;">
<?php echo e(strtoupper(substr($user->name, 0, 1))); ?>
</div>
<strong><?php echo e($user->name); ?></strong>
</div>
</td>
<td>
<small class="text-muted"><?php echo e($user->email); ?></small>
</td>
<td>
<?php if($user->role === 'super_admin'): ?>
<span class="badge badge-super-admin">Super Admin</span>
<?php else: ?>
<span class="badge badge-admin">Admin</span>
<?php endif; ?>
</td>
<td>
<?php if($user->deleted_at): ?>
<span class="badge bg-danger">Tidak Aktif</span>
<?php else: ?>
<span class="badge bg-success">Aktif</span>
<?php endif; ?>
</td>
<td>
<small class="text-muted"><?php echo e($user->created_at->format('d M Y')); ?></small>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<?php if($user->deleted_at): ?>
<form action="<?php echo e(route('admin.users.restore', $user->id)); ?>" method="POST" style="display: inline;">
<?php echo csrf_field(); ?>
<button type="submit" class="btn btn-warning btn-sm" title="Restore">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</form>
<?php else: ?>
<a href="<?php echo e(route('admin.users.edit', $user->id)); ?>" class="btn btn-primary btn-sm" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<?php endif; ?>
<?php if($user->id !== auth()->id()): ?>
<form action="<?php echo e(route('admin.users.destroy', $user->id)); ?>" method="POST" style="display: inline;" onsubmit="return confirm('Yakin hapus user ini?');">
<?php echo csrf_field(); ?>
<?php echo method_field('DELETE'); ?>
<button type="submit" class="btn btn-danger btn-sm" title="Hapus">
<i class="bi bi-trash"></i>
</button>
</form>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<!-- Pagination -->
<div class="d-flex justify-content-center mt-4">
<?php echo e($users->links()); ?>
</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/users/index.blade.php ENDPATH**/ ?>

View File

@ -1,821 +0,0 @@
<?php $__env->startSection('title', 'Metode SAW'); ?>
<?php $__env->startSection('content'); ?>
<div class="container-fluid px-4">
<style>
.breadcrumb {
background: transparent;
border-radius: 8px;
padding: 0;
margin-bottom: 2rem;
}
.breadcrumb-item a {
color: #667eea;
font-weight: 500;
}
.breadcrumb-item.active {
color: #764ba2;
font-weight: 600;
}
.header-saw {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 2rem;
color: white;
margin-bottom: 2rem;
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.15);
}
.header-saw h2 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.header-saw p {
opacity: 0.95;
margin-bottom: 0;
}
.section-header {
color: #667eea;
font-size: 1.1rem;
font-weight: 700;
padding-bottom: 1rem;
border-bottom: 2px solid #667eea;
margin-bottom: 1.5rem;
}
.form-card {
border: none;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0,0,0,0.08);
overflow: hidden;
}
.btn-submit {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
font-weight: 600;
padding: 0.75rem 1.75rem;
border-radius: 8px;
transition: all 0.3s ease;
}
.btn-submit:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
color: white;
}
</style>
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="<?php echo e(route('dashboard')); ?>">Dashboard</a></li>
<li class="breadcrumb-item active">Metode SAW</li>
</ol>
</nav>
<!-- Header -->
<div class="header-saw mb-4">
<h2 class="mb-0">🧮 Metode SAW</h2>
<p class="mb-0 opacity-95">Sistem Pendukung Keputusan untuk rekomendasi terbaik</p>
</div>
<!-- Alert -->
<?php if(session('error')): ?>
<div class="alert alert-danger alert-dismissible fade show shadow-sm mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<span><?php echo e(session('error')); ?></span>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<!-- Info Alert -->
<div class="alert alert-info border-0 shadow-sm mb-4" style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border-left: 4px solid #667eea;">
<div class="d-flex">
<div class="me-3">
<i class="bi bi-info-circle fs-5" style="color: #667eea;"></i>
</div>
<div>
<h6 class="fw-bold mb-2" style="color: #667eea;">Tentang Metode SAW</h6>
<p class="mb-0">
Metode SAW digunakan untuk menentukan ranking alternatif terbaik berdasarkan kriteria yang telah ditentukan.
Sistem akan menghitung nilai normalisasi dan bobot untuk setiap kriteria, kemudian menghasilkan peringkat rekomendasi.
</p>
</div>
</div>
</div>
<!-- Main Content -->
<div class="row">
<div class="col-12">
<!-- Choose Type Card -->
<div class="card form-card mb-4">
<div class="card-body p-4">
<h5 class="section-header mb-0">
<i class="bi bi-list-ul me-2" style="color: #667eea;"></i>Pilih Jenis Pencarian
</h5>
<form action="<?php echo e(route('saw.proses')); ?>" method="POST" id="sawForm">
<?php echo csrf_field(); ?>
<!-- Hidden inputs untuk koordinat user (HANYA UNTUK LAUNDRY) -->
<input type="hidden" name="user_lat" id="user_lat">
<input type="hidden" name="user_lng" id="user_lng">
<div class="row g-2 g-md-3 mb-3 mb-md-4">
<!-- Pilih Kontrakan -->
<div class="col-12 col-md-6">
<input type="radio" class="btn-check" name="tipe" id="tipe_kontrakan" value="kontrakan" checked>
<label class="btn btn-outline-warning w-100 py-3 py-md-4" for="tipe_kontrakan" style="border-color: #667eea; color: #667eea;">
<i class="bi bi-building fs-2 fs-md-1 d-block mb-2 mb-md-3"></i>
<h5 class="mb-1 mb-md-2 fs-6 fs-md-5">Kontrakan</h5>
<small class="text-muted">Cari kontrakan deket kampus</small>
</label>
</div>
<!-- Pilih Laundry -->
<div class="col-12 col-md-6">
<input type="radio" class="btn-check" name="tipe" id="tipe_laundry" value="laundry">
<label class="btn btn-outline-warning w-100 py-3 py-md-4" for="tipe_laundry" style="border-color: #667eea; color: #667eea;">
<i class="bi bi-basket3 fs-2 fs-md-1 d-block mb-2 mb-md-3"></i>
<h5 class="mb-1 mb-md-2 fs-6 fs-md-5">Laundry</h5>
<small class="text-muted">Cari laundry deket lokasi Anda</small>
</label>
</div>
</div>
<!-- INFO JARAK KONTRAKAN -->
<div class="alert border-0 mb-3 mb-md-4" id="infoKontrakan" style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border-left: 4px solid #667eea !important;">
<div class="d-flex align-items-start">
<i class="bi bi-info-circle-fill fs-5 me-2 mt-1" style="color: #667eea;"></i>
<div>
<strong class="small" style="color: #667eea;">Jarak Kontrakan</strong>
<p class="mb-0 small mt-1">
Jarak dihitung otomatis dari <strong>Kampus Polije</strong>.
Sistem akan merekomendasikan kontrakan terdekat dari kampus.
</p>
</div>
</div>
</div>
<!-- DETEKSI LOKASI UNTUK LAUNDRY (OPSIONAL) -->
<div class="card form-card mb-4" id="deteksiLokasiCard" style="display: none;">
<div class="card-body p-4">
<div class="d-flex align-items-start align-items-md-center mb-3">
<i class="bi bi-geo-alt-fill fs-4 fs-md-3 me-2 me-md-3 mt-1 mt-md-0" style="color: #667eea;"></i>
<div class="flex-grow-1">
<h6 class="fw-bold mb-1 fs-6">Referensi Jarak Laundry</h6>
<small class="text-muted d-block" style="font-size: 0.8rem;">
Pilih titik referensi untuk menghitung jarak ke laundry
</small>
</div>
</div>
<!-- Pilihan Referensi Jarak -->
<div class="row g-2 mb-3">
<div class="col-6">
<input type="radio" class="btn-check" name="referensi_jarak" id="referensi_kampus" value="kampus" checked>
<label class="btn btn-outline-warning w-100 py-2" for="referensi_kampus" style="border-color: #667eea; color: #667eea;">
<i class="bi bi-building d-block fs-4 mb-1"></i>
<strong class="small d-block">Dari Kampus</strong>
<small class="text-muted" style="font-size: 0.7rem;">Default</small>
</label>
</div>
<div class="col-6">
<input type="radio" class="btn-check" name="referensi_jarak" id="referensi_user" value="user">
<label class="btn btn-outline-warning w-100 py-2" for="referensi_user" style="border-color: #667eea; color: #667eea;">
<i class="bi bi-person-pin d-block fs-4 mb-1"></i>
<strong class="small d-block">Dari Lokasi Saya</strong>
<small class="text-muted" style="font-size: 0.7rem;">GPS</small>
</label>
</div>
</div>
<!-- Info Default (Dari Kampus) -->
<div id="infoDefaultKampus" class="alert alert-info border-0 mb-0 p-2 small">
<i class="bi bi-info-circle me-2"></i>
Jarak laundry akan dihitung dari <strong>Kampus Polije</strong>.
</div>
<!-- Tombol Deteksi Lokasi (muncul ketika pilih "Dari Lokasi Saya") -->
<div id="deteksiLokasiWrapper" style="display: none;">
<button type="button" id="detectLocationBtn" class="btn btn-success w-100 mb-2">
<i class="bi bi-crosshair me-2"></i>Deteksi Lokasi Saya Sekarang
</button>
<div id="locationStatus" class="mt-2" style="display: none;">
<div class="alert alert-success mb-0 p-2 small">
<i class="bi bi-check-circle-fill me-2"></i>
<strong>Lokasi terdeteksi!</strong>
<div class="mt-1" style="font-size: 0.75rem;" id="locationCoords"></div>
</div>
</div>
<div id="locationWarning" class="alert alert-warning mb-0 p-2 small">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Klik tombol di atas</strong> untuk mendeteksi lokasi Anda.
Jika tidak diklik, jarak akan dihitung dari kampus.
</div>
</div>
</div>
</div>
<!-- Pilihan Jenis Layanan (khusus Laundry) -->
<div class="card form-card mb-4" id="jenisLayananCard" style="display: none;">
<div class="card-body p-4">
<h6 class="section-header mb-0">
<i class="bi bi-speedometer2 me-2" style="color: #667eea;"></i>Pilih Jenis Layanan
</h6>
<div class="row g-2 g-md-3">
<div class="col-12 col-md-4">
<input type="radio" class="btn-check" name="jenis_layanan" id="layanan_reguler" value="reguler">
<label class="btn btn-outline-warning w-100 py-2 py-md-3" for="layanan_reguler" style="border-color: #667eea; color: #667eea;">
<i class="bi bi-clock fs-5 fs-md-4 d-block mb-2"></i>
<strong class="small">Reguler</strong>
<small class="d-block text-muted" style="font-size: 0.75rem;">Normal</small>
</label>
</div>
<div class="col-12 col-md-4">
<input type="radio" class="btn-check" name="jenis_layanan" id="layanan_express" value="express">
<label class="btn btn-outline-warning w-100 py-2 py-md-3" for="layanan_express" style="border-color: #667eea; color: #667eea;">
<i class="bi bi-lightning-charge fs-5 fs-md-4 d-block mb-2"></i>
<strong class="small">Express</strong>
<small class="d-block text-muted" style="font-size: 0.75rem;">Cepat</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>
</div>
</div>
</div>
</div>
<!-- Kriteria Info -->
<div class="card form-card mb-4">
<div class="card-body p-4">
<h6 class="section-header mb-0">
<i class="bi bi-star me-2" style="color: #667eea;"></i>Kriteria yang Digunakan
</h6>
<div class="table-responsive">
<table class="table table-sm mb-0 small" id="kriteriaTable">
<thead>
<tr>
<th>Kriteria</th>
<th class="text-center">Bobot</th>
<th class="text-center">Tipe</th>
</tr>
</thead>
<tbody>
<?php if(isset($kriteria) && $kriteria->count() > 0): ?>
<?php $__currentLoopData = $kriteria; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $k): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<tr data-tipe-bisnis="<?php echo e($k->tipe_bisnis ?? 'kontrakan'); ?>" data-bobot="<?php echo e($k->bobot ?? 0); ?>">
<td><?php echo e($k->nama_kriteria ?? 'N/A'); ?></td>
<td class="text-center">
<span class="badge" style="background-color: #667eea;"><?php echo e($k->bobot ?? 0); ?></span>
</td>
<td class="text-center">
<?php if(isset($k->tipe) && strtolower($k->tipe) == 'benefit'): ?>
<span class="badge bg-success">
<i class="bi bi-arrow-up-circle me-1"></i>Benefit
</span>
<?php else: ?>
<span class="badge bg-danger">
<i class="bi bi-arrow-down-circle me-1"></i>Cost
</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
<?php else: ?>
<tr>
<td colspan="3" class="text-center text-muted">Tidak ada kriteria yang tersedia</td>
</tr>
<?php endif; ?>
</tbody>
<tfoot class="fw-bold">
<tr>
<td>Total Bobot</td>
<td class="text-center">
<span class="badge" id="totalBobotDisplay" style="background-color: #667eea;">
<?php echo e(isset($kriteria) ? $kriteria->where('tipe_bisnis', 'kontrakan')->sum('bobot') : 0); ?>
</span>
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<!-- Submit Button -->
<div class="d-grid gap-2">
<button type="submit" class="btn btn-lg" id="btnProsesSAW" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; font-weight: 600;">
<span id="btnText">
<i class="bi bi-calculator me-2"></i>Proses Perhitungan SAW
</span>
<span id="btnLoading" style="display: none;">
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Memproses data...
</span>
</button>
</div>
<!-- Loading Overlay -->
<div id="loadingOverlay" style="display: none;">
<div class="loading-content">
<div class="spinner-grow text-primary mb-3" style="width: 3rem; height: 3rem;" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<h5 class="text-white mb-2">Memproses Perhitungan SAW</h5>
<p class="text-white-50 small">Mohon tunggu sebentar...</p>
<div class="progress mt-3" style="height: 4px; width: 250px; max-width: 90vw;">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%"></div>
</div>
</div>
</div>
</form>
</div>
</div>
<!-- Info Cards -->
<div class="row g-2 g-md-3">
<div class="col-12 col-md-6">
<div class="card border-0 h-100" style="background: rgba(102, 126, 234, 0.1);">
<div class="card-body p-3">
<h6 class="fw-bold mb-2 fs-6" style="color: #667eea;">
<i class="bi bi-check-circle me-2"></i>Kelebihan SAW
</h6>
<ul class="small mb-0" style="font-size: 0.85rem;">
<li>Mudah dipahami</li>
<li>Perhitungan sederhana</li>
<li>Hasil objektif</li>
<li>Jarak dihitung otomatis dari GPS</li>
</ul>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="card border-0 h-100" style="background: rgba(102, 126, 234, 0.1);">
<div class="card-body p-3">
<h6 class="fw-bold mb-2 fs-6" style="color: #667eea;">
<i class="bi bi-lightbulb me-2"></i>Cara Kerja
</h6>
<ol class="small mb-0" style="font-size: 0.85rem;">
<li><strong>Kontrakan:</strong> Jarak dari Kampus Polije</li>
<li><strong>Laundry:</strong> Jarak dari Kampus <em>atau</em> Lokasi Anda</li>
<li>Normalisasi nilai kriteria</li>
<li>Kalikan dengan bobot</li>
<li>Urutkan berdasarkan nilai tertinggi</li>
</ol>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Toast Container -->
<div id="toastContainer" style="position: fixed; top: 10px; right: 10px; z-index: 10000; max-width: 90vw;"></div>
<style>
/* Purple Theme for Radio Buttons */
.btn-check:checked + .btn-outline-warning {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
border-color: #667eea !important;
color: white !important;
}
.btn-check:checked + .btn-outline-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
border-color: #667eea !important;
color: white !important;
}
.btn-check:checked + .btn-outline-success {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
border-color: #667eea !important;
color: white !important;
}
.btn-check:checked + .btn-outline-danger {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
border-color: #667eea !important;
color: white !important;
}
.btn-outline-warning:hover,
.btn-outline-primary:hover,
.btn-outline-success:hover,
.btn-outline-danger:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.2);
transition: all 0.3s ease;
}
#kriteriaTable tbody tr {
transition: all 0.3s ease;
}
.bg-gradient {
position: relative;
overflow: hidden;
}
.bg-gradient::before {
content: '';
position: absolute;
top: -50%;
right: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
animation: pulse 3s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
.bg-gradient .btn,
.bg-gradient .card-body > * {
position: relative;
z-index: 1;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 0.5; }
50% { transform: scale(1.1); opacity: 0.8; }
}
/* Loading Overlay */
#loadingOverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
backdrop-filter: blur(5px);
}
.loading-content {
text-align: center;
animation: fadeInUp 0.5s ease;
padding: 0 1rem;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Toast Notification */
.custom-toast {
min-width: 250px;
max-width: 350px;
margin-bottom: 10px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
animation: slideInRight 0.3s ease;
font-size: 0.9rem;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.custom-toast.hiding {
animation: slideOutRight 0.3s ease;
}
@keyframes slideOutRight {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
/* Mobile optimizations */
@media (max-width: 767px) {
.btn-lg {
padding: 0.75rem 1rem;
font-size: 1rem;
}
.badge {
font-size: 0.75rem;
padding: 0.25em 0.5em;
}
#toastContainer {
right: 5px;
top: 5px;
}
.custom-toast {
min-width: auto;
width: calc(100vw - 20px);
max-width: calc(100vw - 20px);
}
}
</style>
<script>
// Toast Notification Function
function showToast(type, message) {
const container = document.getElementById('toastContainer');
const toastId = 'toast_' + Date.now();
const iconMap = {
success: 'check-circle-fill',
error: 'exclamation-triangle-fill',
warning: 'exclamation-circle-fill',
info: 'info-circle-fill'
};
const bgMap = {
success: 'bg-success',
error: 'bg-danger',
warning: 'bg-warning',
info: 'bg-info'
};
const toast = document.createElement('div');
toast.id = toastId;
toast.className = `custom-toast alert ${bgMap[type]} alert-dismissible fade show text-white`;
toast.setAttribute('role', 'alert');
toast.innerHTML = `
<div class="d-flex align-items-center">
<i class="bi bi-${iconMap[type]} me-2"></i>
<div class="flex-grow-1 small">${message}</div>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
container.appendChild(toast);
// Auto remove after 4 seconds
setTimeout(() => {
toast.classList.add('hiding');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 300);
}, 4000);
}
// Main Script
window.addEventListener('DOMContentLoaded', function() {
console.log('Script SAW loaded!');
const radioKontrakan = document.getElementById('tipe_kontrakan');
const radioLaundry = document.getElementById('tipe_laundry');
const kriteriaTable = document.getElementById('kriteriaTable');
const jenisLayananCard = document.getElementById('jenisLayananCard');
const referensiJarakCard = document.getElementById('referensiJarakCard');
const deteksiLokasiCard = document.getElementById('deteksiLokasiCard');
const infoKontrakan = document.getElementById('infoKontrakan');
const sawForm = document.getElementById('sawForm');
const detectLocationBtn = document.getElementById('detectLocationBtn');
const locationStatus = document.getElementById('locationStatus');
const locationCoords = document.getElementById('locationCoords');
const userLatInput = document.getElementById('user_lat');
const userLngInput = document.getElementById('user_lng');
const radioReferensiUser = document.getElementById('referensi_user');
const radioReferensiKampus = document.getElementById('referensi_kampus');
const deteksiLokasiWrapper = document.getElementById('deteksiLokasiWrapper');
const infoDefaultKampus = document.getElementById('infoDefaultKampus');
const locationWarning = document.getElementById('locationWarning');
let locationDetected = false;
// ========== TOGGLE REFERENSI JARAK (KAMPUS/USER) ==========
function toggleReferensiJarak() {
if (radioReferensiUser && radioReferensiUser.checked) {
// User memilih "Dari Lokasi Saya"
if (deteksiLokasiWrapper) deteksiLokasiWrapper.style.display = 'block';
if (infoDefaultKampus) infoDefaultKampus.style.display = 'none';
if (locationWarning) locationWarning.style.display = locationDetected ? 'none' : 'block';
} else {
// Default: "Dari Kampus"
if (deteksiLokasiWrapper) deteksiLokasiWrapper.style.display = 'none';
if (infoDefaultKampus) infoDefaultKampus.style.display = 'block';
// Reset user location ketika pilih kampus
if (userLatInput) userLatInput.value = '';
if (userLngInput) userLngInput.value = '';
}
}
// ========== TOGGLE DETEKSI LOKASI BERDASARKAN TIPE ==========
function toggleDeteksiLokasi() {
if (radioLaundry.checked) {
// LAUNDRY: Tampilkan jenis layanan dan pilihan referensi jarak
jenisLayananCard.style.display = 'block';
deteksiLokasiCard.style.display = 'block';
infoKontrakan.style.display = 'none';
toggleReferensiJarak();
} else {
// KONTRAKAN: Sembunyikan jenis layanan dan deteksi lokasi
jenisLayananCard.style.display = 'none';
deteksiLokasiCard.style.display = 'none';
infoKontrakan.style.display = 'block';
// Reset user location untuk kontrakan
if (userLatInput) userLatInput.value = '';
if (userLngInput) userLngInput.value = '';
}
}
// ========== EVENT LISTENER UNTUK PILIHAN REFERENSI JARAK ==========
if (radioReferensiUser) {
radioReferensiUser.addEventListener('change', toggleReferensiJarak);
}
if (radioReferensiKampus) {
radioReferensiKampus.addEventListener('change', toggleReferensiJarak);
}
// ========== DETEKSI LOKASI (HANYA UNTUK LAUNDRY) ==========
if (detectLocationBtn) {
detectLocationBtn.addEventListener('click', function(e) {
e.preventDefault();
console.log('Tombol deteksi lokasi diklik!');
const button = this;
const originalHTML = button.innerHTML;
if (!navigator.geolocation) {
showToast('error', 'Browser Anda tidak mendukung Geolocation.');
return;
}
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Mendeteksi lokasi...';
navigator.geolocation.getCurrentPosition(
function(position) {
console.log('Lokasi berhasil terdeteksi!', position);
const lat = position.coords.latitude;
const lng = position.coords.longitude;
userLatInput.value = lat;
userLngInput.value = lng;
locationDetected = true;
console.log('Lat:', lat, 'Lng:', lng);
locationCoords.innerHTML = `
<strong>Latitude:</strong> ${lat.toFixed(6)}<br>
<strong>Longitude:</strong> ${lng.toFixed(6)}
`;
locationStatus.style.display = 'block';
if (locationWarning) locationWarning.style.display = 'none';
button.disabled = false;
button.innerHTML = '<i class="bi bi-check-circle-fill me-2"></i>Lokasi Terdeteksi!';
button.classList.remove('btn-success');
button.classList.add('btn-outline-success');
showToast('success', '📍 Lokasi Anda berhasil terdeteksi! Jarak laundry akan dihitung dari posisi Anda.');
},
function(error) {
console.error('Error geolocation:', error);
button.disabled = false;
button.innerHTML = originalHTML;
let errorMsg = '';
switch(error.code) {
case error.PERMISSION_DENIED:
errorMsg = 'Izinkan akses lokasi di browser Anda.';
break;
case error.POSITION_UNAVAILABLE:
errorMsg = 'Informasi lokasi tidak tersedia.';
break;
case error.TIMEOUT:
errorMsg = 'Waktu permintaan habis. Coba lagi.';
break;
default:
errorMsg = 'Terjadi kesalahan saat mendeteksi lokasi.';
}
showToast('error', errorMsg);
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0
}
);
});
}
// ========== FILTER KRITERIA ==========
function filterKriteria() {
if (!kriteriaTable) return;
const selectedTipe = radioKontrakan.checked ? 'kontrakan' : 'laundry';
const rows = kriteriaTable.querySelectorAll('tbody tr');
let totalBobot = 0;
rows.forEach(row => {
const tipeBisnis = row.getAttribute('data-tipe-bisnis');
const bobot = parseFloat(row.getAttribute('data-bobot')) || 0;
if (tipeBisnis === selectedTipe) {
row.style.display = '';
totalBobot += bobot;
} else {
row.style.display = 'none';
}
});
const totalBobotDisplay = document.getElementById('totalBobotDisplay');
if (totalBobotDisplay) {
totalBobotDisplay.textContent = totalBobot.toFixed(2);
}
}
// ========== VALIDASI FORM ==========
if (sawForm) {
sawForm.addEventListener('submit', function(e) {
// Validasi jenis layanan untuk laundry
if (radioLaundry.checked) {
const jenisLayananChecked = document.querySelector('input[name="jenis_layanan"]:checked');
if (!jenisLayananChecked) {
e.preventDefault();
showToast('warning', '⚠️ Pilih jenis layanan terlebih dahulu!');
return false;
}
}
// Tampilkan loading
const btnProsesSAW = document.getElementById('btnProsesSAW');
const btnText = document.getElementById('btnText');
const btnLoading = document.getElementById('btnLoading');
const loadingOverlay = document.getElementById('loadingOverlay');
btnProsesSAW.disabled = true;
btnText.style.display = 'none';
btnLoading.style.display = 'inline-block';
loadingOverlay.style.display = 'flex';
});
}
// Event listeners
if (radioKontrakan) {
radioKontrakan.addEventListener('change', function() {
toggleDeteksiLokasi();
filterKriteria();
});
}
if (radioLaundry) {
radioLaundry.addEventListener('change', function() {
toggleDeteksiLokasi();
filterKriteria();
});
}
// Initialize
toggleDeteksiLokasi();
filterKriteria();
});
</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/saw/index.blade.php ENDPATH**/ ?>

View File

@ -174,7 +174,6 @@
<div class="auth-footer">
<p>Belum punya akun bisnis? <a href="<?php echo e(route('admin.register')); ?>">Daftar Sebagai Pemilik</a></p>
<p><a href="<?php echo e(route('admin.portal')); ?>"> Kembali ke Portal Pemilik</a></p>
</div>
</div>
</div>

View File

@ -1,109 +0,0 @@
<?php if($paginator->hasPages()): ?>
<nav role="navigation" aria-label="<?php echo e(__('Pagination Navigation')); ?>" class="flex items-center justify-between">
<div class="flex justify-between flex-1 sm:hidden">
<?php if($paginator->onFirstPage()): ?>
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md dark:text-gray-600 dark:bg-gray-800 dark:border-gray-600">
<?php echo __('pagination.previous'); ?>
</span>
<?php else: ?>
<a href="<?php echo e($paginator->previousPageUrl()); ?>" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:focus:border-blue-700 dark:active:bg-gray-700 dark:active:text-gray-300">
<?php echo __('pagination.previous'); ?>
</a>
<?php endif; ?>
<?php if($paginator->hasMorePages()): ?>
<a href="<?php echo e($paginator->nextPageUrl()); ?>" class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:focus:border-blue-700 dark:active:bg-gray-700 dark:active:text-gray-300">
<?php echo __('pagination.next'); ?>
</a>
<?php else: ?>
<span class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md dark:text-gray-600 dark:bg-gray-800 dark:border-gray-600">
<?php echo __('pagination.next'); ?>
</span>
<?php endif; ?>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700 leading-5 dark:text-gray-400">
<?php echo __('Showing'); ?>
<?php if($paginator->firstItem()): ?>
<span class="font-medium"><?php echo e($paginator->firstItem()); ?></span>
<?php echo __('to'); ?>
<span class="font-medium"><?php echo e($paginator->lastItem()); ?></span>
<?php else: ?>
<?php echo e($paginator->count()); ?>
<?php endif; ?>
<?php echo __('of'); ?>
<span class="font-medium"><?php echo e($paginator->total()); ?></span>
<?php echo __('results'); ?>
</p>
</div>
<div>
<span class="relative z-0 inline-flex rtl:flex-row-reverse shadow-sm rounded-md">
<?php if($paginator->onFirstPage()): ?>
<span aria-disabled="true" aria-label="<?php echo e(__('pagination.previous')); ?>">
<span class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default rounded-l-md leading-5 dark:bg-gray-800 dark:border-gray-600">
Prev
</span>
</span>
<?php else: ?>
<a href="<?php echo e($paginator->previousPageUrl()); ?>" rel="prev" class="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-l-md leading-5 hover:text-gray-400 focus:z-10 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-500 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:active:bg-gray-700 dark:focus:border-blue-800" aria-label="<?php echo e(__('pagination.previous')); ?>">
Prev
</a>
<?php endif; ?>
<?php $__currentLoopData = $elements; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $element): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<?php if(is_string($element)): ?>
<span aria-disabled="true">
<span class="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-700 bg-white border border-gray-300 cursor-default leading-5 dark:bg-gray-800 dark:border-gray-600"><?php echo e($element); ?></span>
</span>
<?php endif; ?>
<?php if(is_array($element)): ?>
<?php $__currentLoopData = $element; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $page => $url): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<?php if($page == $paginator->currentPage()): ?>
<span aria-current="page">
<span class="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 dark:bg-gray-800 dark:border-gray-600"><?php echo e($page); ?></span>
</span>
<?php else: ?>
<a href="<?php echo e($url); ?>" class="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 hover:text-gray-500 focus:z-10 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:text-gray-300 dark:active:bg-gray-700 dark:focus:border-blue-800" aria-label="<?php echo e(__('Go to page :page', ['page' => $page])); ?>">
<?php echo e($page); ?>
</a>
<?php endif; ?>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
<?php endif; ?>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
<?php if($paginator->hasMorePages()): ?>
<a href="<?php echo e($paginator->nextPageUrl()); ?>" rel="next" class="relative inline-flex items-center px-3 py-2 -ml-px text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-r-md leading-5 hover:text-gray-400 focus:z-10 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-500 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:active:bg-gray-700 dark:focus:border-blue-800" aria-label="<?php echo e(__('pagination.next')); ?>">
Next
</a>
<?php else: ?>
<span aria-disabled="true" aria-label="<?php echo e(__('pagination.next')); ?>">
<span class="relative inline-flex items-center px-3 py-2 -ml-px text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default rounded-r-md leading-5 dark:bg-gray-800 dark:border-gray-600">
Next
</span>
</span>
<?php endif; ?>
</span>
</div>
</div>
</nav>
<?php endif; ?>
<?php /**PATH C:\laragon\www\TA\spk_kontrakan\resources\views/vendor/pagination/tailwind.blade.php ENDPATH**/ ?>

View File

@ -1,957 +0,0 @@
<?php $__env->startSection('title', 'Tambah Laundry'); ?>
<?php $__env->startSection('content'); ?>
<div class="container-fluid px-4">
<style>
.breadcrumb {
background: transparent;
border-radius: 8px;
padding: 0;
margin-bottom: 2rem;
}
.breadcrumb-item a {
color: #667eea;
font-weight: 500;
}
.breadcrumb-item.active {
color: #764ba2;
font-weight: 600;
}
.page-header {
background: linear-gradient(135deg, #818cf8 0%, #667eea 50%, #764ba2 100%);
color: white;
padding: 2rem;
border-radius: 12px;
margin-bottom: 2rem;
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.25);
}
.page-header h2 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.form-card {
border: none;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0,0,0,0.08);
overflow: hidden;
}
.section-header {
color: #667eea;
font-size: 1.1rem;
font-weight: 700;
padding-bottom: 1rem;
border-bottom: 2px solid #667eea;
margin-bottom: 1.5rem;
}
.form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.btn-submit {
background: linear-gradient(135deg, #818cf8 0%, #667eea 100%);
border: none;
color: white;
font-weight: 600;
padding: 0.75rem 1.75rem;
border-radius: 8px;
transition: all 0.3s ease;
}
.btn-submit:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
color: white;
}
</style>
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="<?php echo e(route('dashboard')); ?>">Dashboard</a></li>
<li class="breadcrumb-item"><a href="<?php echo e(route('laundry.index')); ?>">Laundry</a></li>
<li class="breadcrumb-item active">Tambah Data</li>
</ol>
</nav>
<!-- Header -->
<div class="page-header">
<h2 class="mb-0">🧺 Tambah Laundry Baru</h2>
<p class="mb-0 opacity-95">Lengkapi formulir di bawah untuk menambahkan data laundry</p>
</div>
<!-- Form Card -->
<div class="row">
<div class="col-12">
<div class="card form-card">
<div class="card-body p-4">
<form action="<?php echo e(route('laundry.store')); ?>" method="POST" enctype="multipart/form-data" id="laundryForm">
<?php echo csrf_field(); ?>
<!-- Informasi Dasar Section -->
<div class="mb-4">
<h5 class="section-header mb-0">
<i class="bi bi-info-circle me-2" style="color: #667eea;"></i>Informasi Dasar
</h5>
<div class="row g-3">
<!-- Nama Laundry -->
<div class="col-md-12">
<label for="nama" class="form-label fw-semibold">
Nama Laundry <span class="text-danger">*</span>
</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-basket3" style="color: #667eea;"></i>
</span>
<input
type="text"
name="nama"
class="form-control border-start-0 <?php $__errorArgs = ['nama'];
$__bag = $errors->getBag($__errorArgs[1] ?? 'default');
if ($__bag->has($__errorArgs[0])) :
if (isset($message)) { $__messageOriginal = $message; }
$message = $__bag->first($__errorArgs[0]); ?> is-invalid <?php unset($message);
if (isset($__messageOriginal)) { $message = $__messageOriginal; }
endif;
unset($__errorArgs, $__bag); ?>"
id="nama"
placeholder="Contoh: Laundry Express 88"
value="<?php echo e(old('nama')); ?>"
required
>
<?php $__errorArgs = ['nama'];
$__bag = $errors->getBag($__errorArgs[1] ?? 'default');
if ($__bag->has($__errorArgs[0])) :
if (isset($message)) { $__messageOriginal = $message; }
$message = $__bag->first($__errorArgs[0]); ?>
<div class="invalid-feedback"><?php echo e($message); ?></div>
<?php unset($message);
if (isset($__messageOriginal)) { $message = $__messageOriginal; }
endif;
unset($__errorArgs, $__bag); ?>
</div>
<small class="text-muted">Masukkan nama laundry yang mudah dikenali</small>
</div>
<!-- Alamat -->
<div class="col-md-12">
<label for="alamat" class="form-label fw-semibold">
Alamat Lengkap <span class="text-danger">*</span>
</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0 align-items-start pt-2">
<i class="bi bi-geo-alt text-danger"></i>
</span>
<textarea
name="alamat"
class="form-control border-start-0 <?php $__errorArgs = ['alamat'];
$__bag = $errors->getBag($__errorArgs[1] ?? 'default');
if ($__bag->has($__errorArgs[0])) :
if (isset($message)) { $__messageOriginal = $message; }
$message = $__bag->first($__errorArgs[0]); ?> is-invalid <?php unset($message);
if (isset($__messageOriginal)) { $message = $__messageOriginal; }
endif;
unset($__errorArgs, $__bag); ?>"
id="alamat"
rows="3"
placeholder="Contoh: Jl. Gejayan No. 45, Condongcatur, Sleman, Yogyakarta"
required
><?php echo e(old('alamat')); ?></textarea>
<?php $__errorArgs = ['alamat'];
$__bag = $errors->getBag($__errorArgs[1] ?? 'default');
if ($__bag->has($__errorArgs[0])) :
if (isset($message)) { $__messageOriginal = $message; }
$message = $__bag->first($__errorArgs[0]); ?>
<div class="invalid-feedback"><?php echo e($message); ?></div>
<?php unset($message);
if (isset($__messageOriginal)) { $message = $__messageOriginal; }
endif;
unset($__errorArgs, $__bag); ?>
</div>
<small class="text-muted">Alamat lengkap beserta patokan jika ada</small>
</div>
<!-- Info Jarak Otomatis -->
<div class="col-12">
<div class="alert alert-info border-0 d-flex align-items-start">
<i class="bi bi-info-circle-fill fs-5 me-2 mt-1"></i>
<div>
<strong class="small">Perhitungan Jarak Otomatis</strong>
<p class="mb-0 small mt-1">
Jarak akan dihitung otomatis dari <strong>Kampus Polije</strong> berdasarkan koordinat GPS yang Anda tentukan.
</p>
</div>
</div>
</div>
<!-- Jarak ke Kampus - AUTO CALCULATED! -->
<div class="col-md-6">
<label for="jarak" class="form-label fw-semibold">
Jarak ke Kampus <span class="text-danger">*</span>
<span class="badge bg-info bg-opacity-10 text-info ms-2">
<i class="bi bi-calculator me-1"></i>Auto-calculated
</span>
</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-pin-map text-info"></i>
</span>
<input
type="number"
name="jarak"
class="form-control border-start-0 <?php $__errorArgs = ['jarak'];
$__bag = $errors->getBag($__errorArgs[1] ?? 'default');
if ($__bag->has($__errorArgs[0])) :
if (isset($message)) { $__messageOriginal = $message; }
$message = $__bag->first($__errorArgs[0]); ?> is-invalid <?php unset($message);
if (isset($__messageOriginal)) { $message = $__messageOriginal; }
endif;
unset($__errorArgs, $__bag); ?>"
id="jarak"
placeholder="Otomatis dihitung"
value="<?php echo e(old('jarak')); ?>"
min="0"
readonly
required
>
<span class="input-group-text bg-light">meter</span>
<?php $__errorArgs = ['jarak'];
$__bag = $errors->getBag($__errorArgs[1] ?? 'default');
if ($__bag->has($__errorArgs[0])) :
if (isset($message)) { $__messageOriginal = $message; }
$message = $__bag->first($__errorArgs[0]); ?>
<div class="invalid-feedback"><?php echo e($message); ?></div>
<?php unset($message);
if (isset($__messageOriginal)) { $message = $__messageOriginal; }
endif;
unset($__errorArgs, $__bag); ?>
</div>
<small class="text-muted">
<i class="bi bi-info-circle me-1"></i>
Jarak otomatis dihitung dari koordinat laundry ke kampus
</small>
</div>
<!-- Fasilitas -->
<div class="col-md-6">
<label for="fasilitas" class="form-label fw-semibold">
Fasilitas <span class="text-danger">*</span>
</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="bi bi-list-check text-primary"></i>
</span>
<input
type="text"
name="fasilitas"
class="form-control border-start-0 <?php $__errorArgs = ['fasilitas'];
$__bag = $errors->getBag($__errorArgs[1] ?? 'default');
if ($__bag->has($__errorArgs[0])) :
if (isset($message)) { $__messageOriginal = $message; }
$message = $__bag->first($__errorArgs[0]); ?> is-invalid <?php unset($message);
if (isset($__messageOriginal)) { $message = $__messageOriginal; }
endif;
unset($__errorArgs, $__bag); ?>"
id="fasilitas"
placeholder="Contoh: Cuci + Setrika"
value="<?php echo e(old('fasilitas')); ?>"
required
>
<?php $__errorArgs = ['fasilitas'];
$__bag = $errors->getBag($__errorArgs[1] ?? 'default');
if ($__bag->has($__errorArgs[0])) :
if (isset($message)) { $__messageOriginal = $message; }
$message = $__bag->first($__errorArgs[0]); ?>
<div class="invalid-feedback"><?php echo e($message); ?></div>
<?php unset($message);
if (isset($__messageOriginal)) { $message = $__messageOriginal; }
endif;
unset($__errorArgs, $__bag); ?>
</div>
<small class="text-muted">Fasilitas yang disediakan laundry ini</small>
</div>
</div>
</div>
<!-- Lokasi Koordinat Section -->
<div class="mb-4">
<h5 class="section-header mb-0">
<i class="bi bi-pin-map-fill text-danger me-2"></i>Lokasi & Koordinat
</h5>
<div class="alert alert-info">
<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.
</div>
<div class="row g-3">
<!-- Latitude -->
<div class="col-md-6">
<label for="latitude" class="form-label fw-semibold">
Latitude <span class="text-danger">*</span>
</label>
<div class="input-group">
<span class="input-group-text bg-light">
<i class="bi bi-geo text-danger"></i>
</span>
<input
type="text"
name="latitude"
class="form-control <?php $__errorArgs = ['latitude'];
$__bag = $errors->getBag($__errorArgs[1] ?? 'default');
if ($__bag->has($__errorArgs[0])) :
if (isset($message)) { $__messageOriginal = $message; }
$message = $__bag->first($__errorArgs[0]); ?> is-invalid <?php unset($message);
if (isset($__messageOriginal)) { $message = $__messageOriginal; }
endif;
unset($__errorArgs, $__bag); ?>"
id="latitude"
placeholder="-7.7828012"
value="<?php echo e(old('latitude')); ?>"
readonly
required
>
<?php $__errorArgs = ['latitude'];
$__bag = $errors->getBag($__errorArgs[1] ?? 'default');
if ($__bag->has($__errorArgs[0])) :
if (isset($message)) { $__messageOriginal = $message; }
$message = $__bag->first($__errorArgs[0]); ?>
<div class="invalid-feedback"><?php echo e($message); ?></div>
<?php unset($message);
if (isset($__messageOriginal)) { $message = $__messageOriginal; }
endif;
unset($__errorArgs, $__bag); ?>
</div>
</div>
<!-- Longitude -->
<div class="col-md-6">
<label for="longitude" class="form-label fw-semibold">
Longitude <span class="text-danger">*</span>
</label>
<div class="input-group">
<span class="input-group-text bg-light">
<i class="bi bi-geo text-danger"></i>
</span>
<input
type="text"
name="longitude"
class="form-control <?php $__errorArgs = ['longitude'];
$__bag = $errors->getBag($__errorArgs[1] ?? 'default');
if ($__bag->has($__errorArgs[0])) :
if (isset($message)) { $__messageOriginal = $message; }
$message = $__bag->first($__errorArgs[0]); ?> is-invalid <?php unset($message);
if (isset($__messageOriginal)) { $message = $__messageOriginal; }
endif;
unset($__errorArgs, $__bag); ?>"
id="longitude"
placeholder="110.4086598"
value="<?php echo e(old('longitude')); ?>"
readonly
required
>
<?php $__errorArgs = ['longitude'];
$__bag = $errors->getBag($__errorArgs[1] ?? 'default');
if ($__bag->has($__errorArgs[0])) :
if (isset($message)) { $__messageOriginal = $message; }
$message = $__bag->first($__errorArgs[0]); ?>
<div class="invalid-feedback"><?php echo e($message); ?></div>
<?php unset($message);
if (isset($__messageOriginal)) { $message = $__messageOriginal; }
endif;
unset($__errorArgs, $__bag); ?>
</div>
</div>
<!-- Tombol Deteksi Lokasi -->
<div class="col-12">
<button type="button" id="detectLocation" class="btn btn-outline-primary">
<i class="bi bi-crosshair me-2"></i>Deteksi Lokasi Saya
</button>
<small class="text-muted d-block mt-2">Atau klik langsung pada peta di bawah</small>
</div>
<!-- Map Container -->
<div class="col-12">
<div id="map" style="height: 400px; border-radius: 8px; border: 2px solid #dee2e6;"></div>
</div>
</div>
</div>
<!-- Layanan Section (Dynamic) -->
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom-2" style="border-bottom: 2px solid #667eea;">
<h5 class="section-header mb-0">
<i class="bi bi-gear text-primary me-2"></i>Jenis Layanan
</h5>
<button type="button" class="btn btn-sm btn-primary" id="addLayanan">
<i class="bi bi-plus-circle me-1"></i>Tambah Layanan
</button>
</div>
<div id="layananContainer">
<!-- Layanan Item 1 (Default) -->
<div class="layanan-item card border mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0 fw-semibold text-success">Layanan #1</h6>
<button type="button" class="btn btn-sm btn-outline-danger remove-layanan" style="display: none;">
<i class="bi bi-trash"></i>
</button>
</div>
<div class="row g-3">
<!-- Jenis Layanan -->
<div class="col-md-6">
<label class="form-label fw-semibold">
Jenis Layanan <span class="text-danger">*</span>
</label>
<select name="layanan[0][jenis_layanan]" class="form-select" required>
<option value="">-- Pilih Jenis --</option>
<option value="reguler">🕐 Reguler (Normal)</option>
<option value="express"> Express (Cepat)</option>
<option value="kilat">🚀 Kilat (Super Cepat)</option>
</select>
</div>
<!-- Nama Paket -->
<div class="col-md-6">
<label class="form-label fw-semibold">
Nama Paket <span class="text-danger">*</span>
</label>
<input
type="text"
name="layanan[0][nama_paket]"
class="form-control"
placeholder="Paket Reguler"
required
>
</div>
<!-- Harga -->
<div class="col-md-6">
<label class="form-label fw-semibold">
Harga (Rp) <span class="text-danger">*</span>
</label>
<div class="input-group">
<span class="input-group-text">Rp</span>
<input
type="number"
name="layanan[0][harga]"
class="form-control"
placeholder="7000"
min="0"
required
>
</div>
</div>
<!-- Estimasi Selesai -->
<div class="col-md-6">
<label class="form-label fw-semibold">
Estimasi Selesai (Jam) <span class="text-danger">*</span>
</label>
<input
type="number"
name="layanan[0][estimasi_selesai]"
class="form-control"
placeholder="24"
min="1"
required
>
</div>
<!-- Deskripsi -->
<div class="col-md-12">
<label class="form-label fw-semibold">
Deskripsi <span class="text-muted">(Opsional)</span>
</label>
<textarea
name="layanan[0][deskripsi]"
class="form-control"
rows="2"
placeholder="Deskripsi singkat paket ini..."
></textarea>
</div>
<!-- Status -->
<div class="col-md-12">
<label class="form-label fw-semibold">
Status <span class="text-danger">*</span>
</label>
<select name="layanan[0][status]" class="form-select" required>
<option value="aktif" selected>Aktif</option>
<option value="nonaktif">Nonaktif</option>
</select>
</div>
</div>
</div>
</div>
</div>
<?php $__errorArgs = ['layanan'];
$__bag = $errors->getBag($__errorArgs[1] ?? 'default');
if ($__bag->has($__errorArgs[0])) :
if (isset($message)) { $__messageOriginal = $message; }
$message = $__bag->first($__errorArgs[0]); ?>
<div class="alert alert-danger"><?php echo e($message); ?></div>
<?php unset($message);
if (isset($__messageOriginal)) { $message = $__messageOriginal; }
endif;
unset($__errorArgs, $__bag); ?>
</div>
<!-- Upload Foto Section -->
<div class="mb-4">
<h5 class="fw-bold mb-3 pb-2 border-bottom">
<i class="bi bi-camera text-success me-2"></i>Upload Foto
</h5>
<div class="row g-3">
<div class="col-md-12">
<label for="foto" class="form-label fw-semibold">
Foto Laundry <span class="text-muted">(Opsional)</span>
</label>
<input
type="file"
name="foto"
class="form-control <?php $__errorArgs = ['foto'];
$__bag = $errors->getBag($__errorArgs[1] ?? 'default');
if ($__bag->has($__errorArgs[0])) :
if (isset($message)) { $__messageOriginal = $message; }
$message = $__bag->first($__errorArgs[0]); ?> is-invalid <?php unset($message);
if (isset($__messageOriginal)) { $message = $__messageOriginal; }
endif;
unset($__errorArgs, $__bag); ?>"
id="foto"
accept="image/jpeg,image/png,image/jpg"
>
<small class="text-muted">Format: JPG, PNG, JPEG (Maksimal 2MB). Kosongkan jika tidak ingin upload foto.</small>
<?php $__errorArgs = ['foto'];
$__bag = $errors->getBag($__errorArgs[1] ?? 'default');
if ($__bag->has($__errorArgs[0])) :
if (isset($message)) { $__messageOriginal = $message; }
$message = $__bag->first($__errorArgs[0]); ?>
<div class="invalid-feedback"><?php echo e($message); ?></div>
<?php unset($message);
if (isset($__messageOriginal)) { $message = $__messageOriginal; }
endif;
unset($__errorArgs, $__bag); ?>
</div>
<!-- Preview Foto -->
<div class="col-md-12">
<div id="preview-foto-container" style="display: none;">
<label class="form-label fw-semibold">Preview Foto:</label>
<div class="border rounded p-2 bg-light">
<img id="preview-foto" src="" alt="Preview" style="max-width: 100%; max-height: 300px; border-radius: 8px; display: block; margin: 0 auto;">
</div>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="d-flex gap-2 justify-content-end pt-3 border-top">
<a href="<?php echo e(route('laundry.index')); ?>" class="btn btn-light px-4">
<i class="bi bi-arrow-left me-2"></i>Batal
</a>
<button type="submit" class="btn btn-success px-4">
<i class="bi bi-check-circle me-2"></i>Simpan Data
</button>
</div>
</form>
</div>
</div>
<!-- Info Card -->
<div class="card border-0 bg-light mt-3">
<div class="card-body">
<div class="d-flex">
<div class="me-3">
<i class="bi bi-info-circle text-success" style="font-size: 1.5rem;"></i>
</div>
<div>
<h6 class="fw-bold mb-2">Tips Pengisian:</h6>
<ul class="mb-0 small text-muted">
<li>Minimal harus ada 1 jenis layanan</li>
<li>Klik pada peta untuk menentukan lokasi laundry</li>
<li>Koordinat akan terisi otomatis saat klik peta</li>
<li>Reguler: Layanan normal dengan harga standar</li>
<li>Express: Layanan cepat dengan harga lebih tinggi</li>
<li>Kilat: Layanan super cepat dengan harga premium</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- JavaScript -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// ========== KOORDINAT KAMPUS POLIJE ==========
const KAMPUS_LAT = -8.15981;
const KAMPUS_LNG = 113.72312;
// ========== FUNGSI HITUNG JARAK (Haversine Formula) ==========
function calculateDistance(lat1, lng1, lat2, lng2) {
const R = 6371; // Radius bumi dalam km
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng/2) * Math.sin(dLng/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c; // Jarak dalam km
}
// ========== UPDATE JARAK KE KAMPUS ==========
function updateJarakKampus(lat, lng) {
if (lat && lng) {
const jarakKm = calculateDistance(lat, lng, KAMPUS_LAT, KAMPUS_LNG);
const jarakMeter = Math.round(jarakKm * 1000); // Convert ke meter
document.getElementById('jarak').value = jarakMeter;
console.log(`Jarak ke kampus: ${jarakKm.toFixed(2)} km (${jarakMeter} m)`);
}
}
// ========== MAP FUNCTIONALITY ==========
// Default center: Semarang, Central Java
const defaultLat = -6.966667;
const defaultLng = 110.416664;
// Initialize map
const map = L.map('map').setView([defaultLat, defaultLng], 13);
// Add tile layer (OpenStreetMap)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
// Marker variable
let marker;
// Function to add/update marker
function addMarker(lat, lng) {
if (marker) {
marker.setLatLng([lat, lng]);
} else {
marker = L.marker([lat, lng], {
draggable: true
}).addTo(map);
// Update coordinates when marker is dragged
marker.on('dragend', function(e) {
const position = marker.getLatLng();
updateCoordinates(position.lat, position.lng);
});
}
// Center map to marker
map.setView([lat, lng], 15);
// Update input fields
updateCoordinates(lat, lng);
}
// Function to update coordinate inputs
function updateCoordinates(lat, lng) {
document.getElementById('latitude').value = lat.toFixed(8);
document.getElementById('longitude').value = lng.toFixed(8);
// Hitung jarak ke kampus otomatis
updateJarakKampus(lat, lng);
}
// Click on map to add marker
map.on('click', function(e) {
addMarker(e.latlng.lat, e.latlng.lng);
});
// Detect user location button
document.getElementById('detectLocation').addEventListener('click', function() {
const button = this;
const originalHTML = button.innerHTML;
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Mendeteksi lokasi...';
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
function(position) {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
addMarker(lat, lng);
button.disabled = false;
button.innerHTML = originalHTML;
// Show success message
alert('Lokasi berhasil terdeteksi!');
},
function(error) {
button.disabled = false;
button.innerHTML = originalHTML;
let errorMsg = 'Gagal mendeteksi lokasi. ';
switch(error.code) {
case error.PERMISSION_DENIED:
errorMsg += 'Izinkan akses lokasi di browser Anda.';
break;
case error.POSITION_UNAVAILABLE:
errorMsg += 'Informasi lokasi tidak tersedia.';
break;
case error.TIMEOUT:
errorMsg += 'Waktu permintaan habis.';
break;
default:
errorMsg += 'Terjadi kesalahan.';
}
alert(errorMsg);
}
);
} else {
button.disabled = false;
button.innerHTML = originalHTML;
alert('Browser Anda tidak mendukung Geolocation.');
}
});
// ========== LAYANAN FUNCTIONALITY ==========
let layananCount = 1;
const container = document.getElementById('layananContainer');
const addButton = document.getElementById('addLayanan');
// Tambah Layanan
addButton.addEventListener('click', function() {
const newItem = document.createElement('div');
newItem.className = 'layanan-item card border mb-3';
newItem.innerHTML = `
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0 fw-semibold text-success">Layanan #${layananCount + 1}</h6>
<button type="button" class="btn btn-sm btn-outline-danger remove-layanan">
<i class="bi bi-trash"></i>
</button>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">
Jenis Layanan <span class="text-danger">*</span>
</label>
<select name="layanan[${layananCount}][jenis_layanan]" class="form-select" required>
<option value="">-- Pilih Jenis --</option>
<option value="reguler">🕐 Reguler (Normal)</option>
<option value="express"> Express (Cepat)</option>
<option value="kilat">🚀 Kilat (Super Cepat)</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">
Nama Paket <span class="text-danger">*</span>
</label>
<input
type="text"
name="layanan[${layananCount}][nama_paket]"
class="form-control"
placeholder="Paket Reguler"
required
>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">
Harga (Rp) <span class="text-danger">*</span>
</label>
<div class="input-group">
<span class="input-group-text">Rp</span>
<input
type="number"
name="layanan[${layananCount}][harga]"
class="form-control"
placeholder="7000"
min="0"
required
>
</div>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">
Estimasi Selesai (Jam) <span class="text-danger">*</span>
</label>
<input
type="number"
name="layanan[${layananCount}][estimasi_selesai]"
class="form-control"
placeholder="24"
min="1"
required
>
</div>
<div class="col-md-12">
<label class="form-label fw-semibold">
Deskripsi <span class="text-muted">(Opsional)</span>
</label>
<textarea
name="layanan[${layananCount}][deskripsi]"
class="form-control"
rows="2"
placeholder="Deskripsi singkat paket ini..."
></textarea>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">
Rating (0-5) <span class="text-muted">(Opsional)</span>
</label>
<input
type="number"
name="layanan[${layananCount}][rating]"
class="form-control"
placeholder="4.5"
min="0"
max="5"
step="0.1"
>
<small class="text-muted">Rating rata-rata paket ini (0-5)</small>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">
Waktu Proses (Jam) <span class="text-muted">(Opsional)</span>
</label>
<input
type="number"
name="layanan[${layananCount}][waktu_proses]"
class="form-control"
placeholder="24"
min="1"
>
<small class="text-muted">Waktu rata-rata proses paket ini</small>
</div>
<div class="col-md-12">
<label class="form-label fw-semibold">
Status <span class="text-danger">*</span>
</label>
<select name="layanan[${layananCount}][status]" class="form-select" required>
<option value="aktif" selected>Aktif</option>
<option value="nonaktif">Nonaktif</option>
</select>
</div>
</div>
</div>
`;
container.appendChild(newItem);
layananCount++;
updateRemoveButtons();
});
// Hapus Layanan
container.addEventListener('click', function(e) {
if (e.target.closest('.remove-layanan')) {
e.target.closest('.layanan-item').remove();
updateRemoveButtons();
}
});
// Update visibility tombol hapus
function updateRemoveButtons() {
const items = container.querySelectorAll('.layanan-item');
items.forEach((item, index) => {
const removeBtn = item.querySelector('.remove-layanan');
if (items.length > 1) {
removeBtn.style.display = 'block';
} else {
removeBtn.style.display = 'none';
}
});
}
// ========== FOTO PREVIEW ==========
const fotoInput = document.getElementById('foto');
const previewFoto = document.getElementById('preview-foto');
const previewContainer = document.getElementById('preview-foto-container');
fotoInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
if (file.size > 2097152) {
alert('Ukuran file maksimal 2MB!');
this.value = '';
previewContainer.style.display = 'none';
return;
}
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
if (!allowedTypes.includes(file.type)) {
alert('Hanya file JPG, PNG, dan JPEG yang diperbolehkan!');
this.value = '';
previewContainer.style.display = 'none';
return;
}
const reader = new FileReader();
reader.onload = function(e) {
previewFoto.src = e.target.result;
previewContainer.style.display = 'block';
}
reader.readAsDataURL(file);
} else {
previewContainer.style.display = 'none';
}
});
});
</script>
<style>
.form-control:focus, .form-select:focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.15);
}
.layanan-item {
transition: all 0.3s ease;
}
.layanan-item:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
/* Leaflet map styling */
.leaflet-container {
font-family: inherit;
}
.leaflet-popup-content-wrapper {
border-radius: 8px;
}
</style>
<?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/laundry/create.blade.php ENDPATH**/ ?>

View File

@ -1,14 +1,27 @@
class AppConfig {
// Base URL - Ganti sesuai environment
// Windows Desktop: http://localhost:8000
// Android Emulator: http://10.0.2.2:8000
// iOS Simulator: http://localhost:8000
// Real Device: http://192.168.18.16:8000 (IP komputer Anda)
// ============================================================================
// 🔧 BASE URL CONFIGURATION - AUTO-DETECTED AT RUNTIME
// ============================================================================
// URL ini otomatis dideteksi saat app startup via ServerDiscoveryService.
// Fallback default: IP terakhir yang berhasil terhubung.
//
// Tidak perlu update manual lagi!
// ============================================================================
// CATATAN: Untuk real device, ganti IP sesuai dengan IP komputer Anda
// Cek IP dengan: ipconfig (di Windows) atau ifconfig (di Linux/Mac)
static const String baseUrl = 'http://192.168.18.16:8000/api';
static const String storageUrl = 'http://192.168.18.16:8000/storage';
// Default fallback (dipakai jika auto-detect gagal)
static const String _defaultServer = 'http://192.168.1.154:8000';
// Runtime values diupdate otomatis oleh ServerDiscoveryService
static String _serverUrl = _defaultServer;
static String get serverUrl => _serverUrl;
static String get baseUrl => '$_serverUrl/api';
static String get storageUrl => '$_serverUrl/storage';
/// Dipanggil oleh ServerDiscoveryService setelah server ditemukan
static void setServerUrl(String url) {
_serverUrl = url;
}
// Timeouts
static const Duration connectionTimeout = Duration(seconds: 10);

View File

@ -0,0 +1,24 @@
// Environment configuration yang dapat berubah runtime
class Environment {
// 💻 For Development
// Android Emulator = http://10.0.2.2:8000
// iOS Simulator = http://localhost:8000
// Real Device = Ganti dengan IP komputer Anda
// Pilih sesuai platform dan environment saat development
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://192.168.1.154:8000', // Default IP lokal
);
static const String storageBaseUrl = String.fromEnvironment(
'STORAGE_BASE_URL',
defaultValue: 'http://192.168.1.154:8000/storage',
);
// Mode debugging
static const bool isDebugMode = true;
}
// Catatan: Untuk production:
// flutter run --dart-define=API_BASE_URL=https://api.production.com

View File

@ -210,24 +210,34 @@ class _LoginScreenState extends State<LoginScreen> {
setState(() => _isLoading = true);
final result = await _authService.login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
setState(() => _isLoading = false);
if (!mounted) return;
if (result['success']) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const ImprovedHomeScreen()),
try {
final result = await _authService.login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
} else {
if (!mounted) return;
setState(() => _isLoading = false);
if (result['success']) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const ImprovedHomeScreen()),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Login gagal'),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Login gagal'),
content: Text('Terjadi kesalahan: $e'),
backgroundColor: Colors.red,
),
);

View File

@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'login.dart';
import 'screens/improved_home_screen.dart';
import 'services/auth_service.dart';
import 'services/server_discovery_service.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
@ -110,6 +111,7 @@ class _SplashScreenState extends State<SplashScreen>
late AnimationController _animController;
late Animation<double> _fadeAnim;
late Animation<double> _scaleAnim;
String _statusText = 'Memulai aplikasi...';
@override
void initState() {
@ -135,8 +137,16 @@ class _SplashScreenState extends State<SplashScreen>
}
Future<void> _checkAuth() async {
// 🔍 Auto-detect server IP
await ServerDiscoveryService.discover(
onStatus: (status) {
if (mounted) setState(() => _statusText = status);
},
);
if (mounted) setState(() => _statusText = 'Memeriksa sesi...');
await _authService.loadToken();
await Future.delayed(const Duration(milliseconds: 1600));
await Future.delayed(const Duration(milliseconds: 400));
if (!mounted) return;
@ -228,6 +238,14 @@ class _SplashScreenState extends State<SplashScreen>
),
),
),
const SizedBox(height: 16),
Text(
_statusText,
style: TextStyle(
fontSize: 13,
color: Colors.white.withOpacity(0.7),
),
),
],
),
),

View File

@ -1,3 +1,5 @@
import '../config/app_config.dart';
class Kontrakan {
final int id;
final String nama;
@ -108,8 +110,7 @@ class Kontrakan {
if (foto!.startsWith('http')) {
return foto!;
}
const String baseUrl = 'http://192.168.18.16:8000';
return '$baseUrl/uploads/Kontrakan/$foto';
return '${AppConfig.serverUrl}/uploads/Kontrakan/$foto';
}
// 2. Fall back to galeri items if foto is not set
if (galeri.isNotEmpty) {
@ -121,8 +122,7 @@ class Kontrakan {
return primary.foto;
}
if (primary.foto.isNotEmpty) {
const String baseUrl = 'http://192.168.18.16:8000';
return '$baseUrl/uploads/Kontrakan/${primary.foto}';
return '${AppConfig.serverUrl}/uploads/Kontrakan/${primary.foto}';
}
}
return 'https://via.placeholder.com/300';
@ -168,7 +168,6 @@ class Galeri {
return foto;
}
// Build full URL from uploads
const String baseUrl = 'http://192.168.18.16:8000';
return '$baseUrl/uploads/Kontrakan/$foto';
return '${AppConfig.serverUrl}/uploads/Kontrakan/$foto';
}
}

View File

@ -1,3 +1,5 @@
import '../config/app_config.dart';
class Laundry {
final int id;
final String nama;
@ -127,8 +129,7 @@ class Laundry {
if (foto!.startsWith('http')) {
return foto!;
}
const String baseUrl = 'http://192.168.18.16:8000';
return '$baseUrl/uploads/Laundry/$foto';
return '${AppConfig.serverUrl}/uploads/Laundry/$foto';
}
// 2. Fall back to galeri items if foto is not set
if (galeri.isNotEmpty) {
@ -140,8 +141,7 @@ class Laundry {
return primary.foto;
}
if (primary.foto.isNotEmpty) {
const String baseUrl = 'http://192.168.18.16:8000';
return '$baseUrl/uploads/Laundry/${primary.foto}';
return '${AppConfig.serverUrl}/uploads/Laundry/${primary.foto}';
}
}
return 'https://via.placeholder.com/300';
@ -209,7 +209,6 @@ class GaleriLaundry {
return foto;
}
// Build full URL from uploads
const String baseUrl = 'http://192.168.18.16:8000';
return '$baseUrl/uploads/Laundry/$foto';
return '${AppConfig.serverUrl}/uploads/Laundry/$foto';
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'login.dart';
import 'screens/mobile_home_screen.dart';
import 'screens/improved_home_screen.dart';
import 'services/auth_service.dart';
class RegisterScreen extends StatefulWidget {
@ -11,6 +12,7 @@ class RegisterScreen extends StatefulWidget {
}
class _RegisterScreenState extends State<RegisterScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@ -35,6 +37,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
backgroundColor: Colors.white,
body: Column(
children: [
// Header
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(8, 0, 16, 24),
@ -53,15 +56,13 @@ class _RegisterScreenState extends State<RegisterScreen> {
bottom: false,
child: Column(
children: [
Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_rounded, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
],
Align(
alignment: Alignment.centerLeft,
child: IconButton(
icon: const Icon(Icons.arrow_back_rounded, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
@ -103,25 +104,38 @@ class _RegisterScreenState extends State<RegisterScreen> {
),
),
),
// Form
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTextField(
label: 'Nama',
controller: _nameController,
prefixIcon: Icons.person_outline,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Nama tidak boleh kosong';
return null;
},
),
const SizedBox(height: 16),
const SizedBox(height: 18),
_buildTextField(
label: 'Email',
controller: _emailController,
keyboardType: TextInputType.emailAddress,
prefixIcon: Icons.email_outlined,
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Email tidak boleh kosong';
if (!v.contains('@')) return 'Email tidak valid';
return null;
},
),
const SizedBox(height: 16),
const SizedBox(height: 18),
_buildTextField(
label: 'Password',
controller: _passwordController,
@ -134,14 +148,15 @@ class _RegisterScreenState extends State<RegisterScreen> {
: Icons.visibility_off_outlined,
color: const Color(0xFF1565C0),
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
validator: (v) {
if (v == null || v.isEmpty) return 'Password tidak boleh kosong';
if (v.length < 6) return 'Password minimal 6 karakter';
return null;
},
),
const SizedBox(height: 16),
const SizedBox(height: 18),
_buildTextField(
label: 'Konfirmasi Password',
controller: _confirmPasswordController,
@ -154,48 +169,32 @@ class _RegisterScreenState extends State<RegisterScreen> {
: Icons.visibility_off_outlined,
color: const Color(0xFF1565C0),
),
onPressed: () {
setState(() {
_obscureConfirmPassword = !_obscureConfirmPassword;
});
},
onPressed: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
),
validator: (v) {
if (v != _passwordController.text) return 'Password tidak cocok';
return null;
},
),
const SizedBox(height: 28),
// Tombol Daftar
SizedBox(
width: double.infinity,
child: ElevatedButton(
child: _buildActionButton(
label: 'DAFTAR',
onPressed: _isLoading ? null : _handleRegister,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1565C0),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
strokeWidth: 2,
),
)
: const Text(
'DAFTAR',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
),
),
const SizedBox(height: 24),
// Info card
_buildInfoCard(),
const SizedBox(height: 20),
// Link ke login
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@ -206,9 +205,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
GestureDetector(
onTap: () => Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => const LoginScreen(),
),
MaterialPageRoute(builder: (_) => const LoginScreen()),
),
child: const Text(
'Masuk',
@ -226,9 +223,10 @@ class _RegisterScreenState extends State<RegisterScreen> {
),
),
),
],
),
);
),
],
),
);
}
Widget _buildTextField({
@ -238,6 +236,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
bool obscureText = false,
IconData? prefixIcon,
Widget? suffixIcon,
String? Function(String?)? validator,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -251,10 +250,11 @@ class _RegisterScreenState extends State<RegisterScreen> {
),
),
const SizedBox(height: 8),
TextField(
TextFormField(
controller: controller,
keyboardType: keyboardType,
obscureText: obscureText,
validator: validator,
decoration: InputDecoration(
prefixIcon: prefixIcon != null
? Icon(prefixIcon, color: const Color(0xFF1565C0), size: 20)
@ -274,6 +274,14 @@ class _RegisterScreenState extends State<RegisterScreen> {
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0xFF1565C0), width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0xFFEF5350)),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0xFFEF5350), width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
),
),
@ -281,107 +289,187 @@ class _RegisterScreenState extends State<RegisterScreen> {
);
}
Widget _buildActionButton({
required String label,
required VoidCallback? onPressed,
}) {
return ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1565C0),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
elevation: 0,
disabledBackgroundColor: Colors.grey[300],
shadowColor: const Color(0xFF1565C0).withOpacity(0.3),
),
child: _isLoading
? const SpinKitThreeBounce(color: Colors.white, size: 20)
: Text(
label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
);
}
Widget _buildInfoCard() {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: const Color(0xFFF7F8FC),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE0E0E0)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF1565C0).withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.school_rounded, color: Color(0xFF1565C0), size: 22),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Untuk Mahasiswa',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: Color(0xFF1A1A2E),
),
),
Text(
'Politeknik Negeri Jember',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF1565C0),
),
),
],
),
),
],
),
const SizedBox(height: 16),
_buildInfoItem(Icons.home_rounded, 'Temukan Kontrakan Terbaik',
'Rekomendasi kontrakan terdekat dari kampus'),
const SizedBox(height: 10),
_buildInfoItem(Icons.local_laundry_service_rounded, 'Cari Laundry Terpercaya',
'Temukan laundry dengan kualitas terbaik'),
const SizedBox(height: 10),
_buildInfoItem(Icons.analytics_rounded, 'Metode SAW',
'Rekomendasi berdasarkan preferensi Anda'),
],
),
);
}
Widget _buildInfoItem(IconData icon, String title, String description) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: const Color(0xFF1565C0).withOpacity(0.6), size: 18),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF1A1A2E))),
const SizedBox(height: 2),
Text(description,
style: TextStyle(fontSize: 12, color: Colors.grey[500], height: 1.3)),
],
),
),
],
);
}
Future<void> _handleRegister() async {
final name = _nameController.text.trim();
final email = _emailController.text.trim();
final password = _passwordController.text;
final confirmPassword = _confirmPasswordController.text;
if (!_formKey.currentState!.validate()) return;
if (name.isEmpty) {
_showError('Nama tidak boleh kosong');
return;
}
if (email.isEmpty) {
_showError('Email tidak boleh kosong');
return;
}
if (password.isEmpty) {
_showError('Password tidak boleh kosong');
return;
}
if (password.length < 6) {
_showError('Password minimal 6 karakter');
return;
}
if (password != confirmPassword) {
_showError('Password tidak cocok');
return;
}
setState(() {
_isLoading = true;
});
setState(() => _isLoading = true);
try {
final result = await _authService.register(
name: name,
email: email,
password: password,
passwordConfirmation: confirmPassword,
name: _nameController.text.trim(),
email: _emailController.text.trim(),
password: _passwordController.text,
passwordConfirmation: _confirmPasswordController.text,
);
if (!mounted) return;
if (result['success'] == true) {
_showSuccess('Registrasi berhasil! Selamat datang!');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Row(
children: [
Icon(Icons.check_circle_rounded, color: Colors.white, size: 20),
SizedBox(width: 10),
Text('Registrasi berhasil! Selamat datang!'),
],
),
backgroundColor: const Color(0xFF2E7D32),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 2),
),
);
await Future.delayed(const Duration(seconds: 1));
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const MobileHomeScreen()),
MaterialPageRoute(builder: (_) => const ImprovedHomeScreen()),
);
}
} else {
final message = result['message'] ?? 'Registrasi gagal';
_showError(message);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline_rounded, color: Colors.white, size: 20),
const SizedBox(width: 10),
Expanded(child: Text(result['message'] ?? 'Registrasi gagal')),
],
),
backgroundColor: const Color(0xFFC62828),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 3),
),
);
}
} catch (e) {
_showError('Error: $e');
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
backgroundColor: const Color(0xFFC62828),
),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline_rounded, color: Colors.white, size: 20),
const SizedBox(width: 10),
Expanded(child: Text(message)),
],
),
backgroundColor: const Color(0xFFC62828),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 3),
),
);
}
void _showSuccess(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle_rounded, color: Colors.white, size: 20),
const SizedBox(width: 10),
Expanded(child: Text(message)),
],
),
backgroundColor: const Color(0xFF2E7D32),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 2),
),
);
}
}

View File

@ -72,7 +72,7 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
},
);
if (picked != null) {
if (picked != null && mounted) {
setState(() => _tanggalMulai = picked);
}
}
@ -118,15 +118,25 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
if (source == null) return;
final picked = await _imagePicker.pickImage(
source: source,
maxWidth: 1920,
maxHeight: 1920,
imageQuality: 85,
);
try {
final picked = await _imagePicker.pickImage(
source: source,
maxWidth: 1920,
maxHeight: 1920,
imageQuality: 85,
);
if (picked != null) {
setState(() => _paymentProofImage = File(picked.path));
if (picked != null && mounted) {
setState(() => _paymentProofImage = File(picked.path));
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Gagal memilih gambar: $e'),
backgroundColor: Colors.red,
),
);
}
}
@ -218,25 +228,25 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
),
);
if (confirmed != true) return;
if (confirmed != true || !mounted) return;
setState(() => _isSubmitting = true);
final result = await _bookingService.createBooking(
kontrakanId: widget.kontrakan.id,
tanggalMulai: _tanggalMulai!,
durasiBulan: _durasiBulan,
catatan: _catatanController.text.isNotEmpty
? _catatanController.text
: null,
paymentProof: _paymentProofImage,
);
try {
final result = await _bookingService.createBooking(
kontrakanId: widget.kontrakan.id,
tanggalMulai: _tanggalMulai!,
durasiBulan: _durasiBulan,
catatan: _catatanController.text.isNotEmpty
? _catatanController.text
: null,
paymentProof: _paymentProofImage,
);
setState(() => _isSubmitting = false);
if (!mounted) return;
setState(() => _isSubmitting = false);
if (!mounted) return;
if (result['success'] == true) {
if (result['success'] == true) {
showDialog(
context: context,
barrierDismissible: false,
@ -304,6 +314,16 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
),
);
}
} catch (e) {
if (!mounted) return;
setState(() => _isSubmitting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
Widget _buildConfirmRow(String label, String value) {

View File

@ -38,16 +38,32 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
Future<void> _loadBookings() async {
setState(() => _isLoading = true);
final bookings = await _bookingService.getBookingHistory();
setState(() {
_activeBookings = bookings
.where((b) => b.status == 'confirmed' || b.status == 'pending')
.toList();
_pastBookings = bookings
.where((b) => b.status == 'completed' || b.status == 'cancelled')
.toList();
_isLoading = false;
});
try {
final bookings = await _bookingService.getBookingHistory();
if (!mounted) return;
setState(() {
_activeBookings = bookings
.where((b) => b.status == 'confirmed' || b.status == 'pending')
.toList();
_pastBookings = bookings
.where((b) => b.status == 'completed' || b.status == 'cancelled')
.toList();
_isLoading = false;
});
} catch (e) {
debugPrint('Load bookings error: $e');
if (!mounted) return;
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Gagal memuat riwayat booking'),
backgroundColor: Colors.red[700],
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.all(16),
),
);
}
}
Future<void> _cancelBooking(int bookingId) async {
@ -91,8 +107,9 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
),
);
if (confirm != true) return;
final result = await _bookingService.cancelBooking(bookingId);
if (mounted) {
try {
final result = await _bookingService.cancelBooking(bookingId);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
@ -119,6 +136,17 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
),
);
if (result['success'] == true) _loadBookings();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Gagal membatalkan booking: $e'),
backgroundColor: const Color(0xFFC62828),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.all(16),
),
);
}
}
@ -216,12 +244,14 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
);
if (picked == null) return;
if (!mounted) return;
setState(() => _uploadingBookingId = booking.id);
final result = await _bookingService.uploadPaymentProof(
booking.id,
File(picked.path),
);
if (mounted) {
try {
final result = await _bookingService.uploadPaymentProof(
booking.id,
File(picked.path),
);
if (!mounted) return;
setState(() => _uploadingBookingId = null);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@ -250,6 +280,18 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
),
);
if (result['success'] == true) _loadBookings();
} catch (e) {
if (!mounted) return;
setState(() => _uploadingBookingId = null);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Gagal upload bukti pembayaran: $e'),
backgroundColor: const Color(0xFFC62828),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.all(16),
),
);
}
}

View File

@ -29,27 +29,37 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
setState(() => _isLoading = true);
final result = await _authService.changePassword(
password: _passwordController.text,
passwordConfirmation: _confirmController.text,
);
setState(() => _isLoading = false);
if (!mounted) return;
if (result['success'] == true) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Password berhasil diubah'),
backgroundColor: Colors.green,
),
try {
final result = await _authService.changePassword(
password: _passwordController.text,
passwordConfirmation: _confirmController.text,
);
Navigator.pop(context);
} else {
if (!mounted) return;
setState(() => _isLoading = false);
if (result['success'] == true) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Password berhasil diubah'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Gagal mengubah password'),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Gagal mengubah password'),
content: Text('Terjadi kesalahan: $e'),
backgroundColor: Colors.red,
),
);

View File

@ -39,30 +39,40 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
setState(() => _isLoading = true);
final result = await _authService.updateProfile(
name: _nameController.text.trim(),
email: _emailController.text.trim(),
phone: _phoneController.text.trim().isEmpty
? null
: _phoneController.text.trim(),
);
setState(() => _isLoading = false);
if (!mounted) return;
if (result['success'] == true) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Profil berhasil diperbarui'),
backgroundColor: Colors.green,
),
try {
final result = await _authService.updateProfile(
name: _nameController.text.trim(),
email: _emailController.text.trim(),
phone: _phoneController.text.trim().isEmpty
? null
: _phoneController.text.trim(),
);
Navigator.pop(context, true);
} else {
if (!mounted) return;
setState(() => _isLoading = false);
if (result['success'] == true) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Profil berhasil diperbarui'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context, true);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Gagal memperbarui profil'),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Gagal memperbarui profil'),
content: Text('Terjadi kesalahan: $e'),
backgroundColor: Colors.red,
),
);
@ -109,7 +119,9 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
radius: 45,
backgroundColor: Colors.white,
child: Text(
widget.user.name.substring(0, 1).toUpperCase(),
widget.user.name.isNotEmpty
? widget.user.name.substring(0, 1).toUpperCase()
: 'U',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import '../models/kontrakan.dart';
import '../models/laundry.dart';
import '../services/favorite_service.dart';
@ -36,23 +36,46 @@ class _FavoritesScreenState extends State<FavoritesScreen>
super.dispose();
}
String? _errorMessage;
Future<void> _loadFavorites() async {
setState(() => _isLoading = true);
// Clear image cache agar foto terbaru selalu dimuat
await DefaultCacheManager().emptyCache();
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
if (!mounted) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final result = await _favoriteService.getFavoritesWithModels();
if (!mounted) return;
if (result['success'] == true) {
setState(() {
_kontrakanFavorites = result['kontrakan'] is List<Kontrakan>
? result['kontrakan'] as List<Kontrakan>
: <Kontrakan>[];
_laundryFavorites = result['laundry'] is List<Laundry>
? result['laundry'] as List<Laundry>
: <Laundry>[];
_isLoading = false;
});
debugPrint('[FAV_SCREEN] Loaded ${_kontrakanFavorites.length} kontrakan, ${_laundryFavorites.length} laundry');
} else {
setState(() {
_kontrakanFavorites = [];
_laundryFavorites = [];
_errorMessage = result['message']?.toString() ?? 'Gagal memuat favorit';
_isLoading = false;
});
debugPrint('[FAV_SCREEN] ❌ Load failed: $_errorMessage');
}
} catch (e) {
debugPrint('[FAV_SCREEN] ❌ Exception: $e');
if (mounted) {
setState(() {
_kontrakanFavorites = (result['kontrakan'] as List<Kontrakan>?) ?? [];
_laundryFavorites = (result['laundry'] as List<Laundry>?) ?? [];
_errorMessage = 'Terjadi kesalahan: $e';
_isLoading = false;
});
}
} catch (e) {
if (mounted) setState(() => _isLoading = false);
}
}
@ -93,15 +116,18 @@ class _FavoritesScreenState extends State<FavoritesScreen>
),
);
if (confirm != true) return;
if (!mounted) return;
Map<String, dynamic> result;
if (type == 'kontrakan') {
result = await _favoriteService.toggleKontrakanFavorite(itemId);
} else {
result = await _favoriteService.toggleLaundryFavorite(itemId);
}
try {
Map<String, dynamic> result;
if (type == 'kontrakan') {
result = await _favoriteService.toggleKontrakanFavorite(itemId);
} else {
result = await _favoriteService.toggleLaundryFavorite(itemId);
}
if (mounted && result['success'] == true) {
if (!mounted) return;
if (result['success'] == true) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
@ -123,7 +149,19 @@ class _FavoritesScreenState extends State<FavoritesScreen>
margin: const EdgeInsets.all(16),
),
);
_loadFavorites();
_loadFavorites();
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Gagal menghapus favorit'),
backgroundColor: Colors.red.shade600,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.all(16),
),
);
}
}
@ -262,10 +300,45 @@ class _FavoritesScreenState extends State<FavoritesScreen>
color: Color(0xFF1565C0),
),
)
: TabBarView(
controller: _tabController,
children: [_buildKontrakanList(), _buildLaundryList()],
),
: _errorMessage != null
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline_rounded,
size: 56, color: Colors.red.shade300),
const SizedBox(height: 16),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15,
color: Colors.grey[600],
),
),
const SizedBox(height: 20),
ElevatedButton.icon(
onPressed: _loadFavorites,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Coba Lagi'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1565C0),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
],
),
),
)
: TabBarView(
controller: _tabController,
children: [_buildKontrakanList(), _buildLaundryList()],
),
),
],
),

View File

@ -42,13 +42,16 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
_loadData();
}
Future<void> _loadData() async {
Future<void> _loadData({bool clearCache = false}) async {
setState(() => _isLoading = true);
// Clear image cache agar foto terbaru dari server selalu dimuat
await DefaultCacheManager().emptyCache();
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
// Clear image cache hanya saat user eksplisit refresh (pull-to-refresh)
if (clearCache) {
await DefaultCacheManager().emptyCache();
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
}
await Future.wait([_loadKontrakan(), _loadLaundry(), _loadFavoriteIds()]);
if (!mounted) return;
setState(() => _isLoading = false);
}
@ -56,6 +59,7 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
try {
if (!_authService.isAuthenticated) return;
final ids = await _favoriteService.getFavoriteIds();
if (!mounted) return;
setState(() {
_favKontrakanIds = (ids['kontrakan'] ?? []).toSet();
_favLaundryIds = (ids['laundry'] ?? []).toSet();
@ -66,6 +70,14 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
}
Future<void> _toggleKontrakanFav(int id) async {
if (!_authService.isAuthenticated) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Silakan login terlebih dahulu'), backgroundColor: Colors.orange),
);
}
return;
}
final wasFav = _favKontrakanIds.contains(id);
setState(() {
if (wasFav) {
@ -74,19 +86,44 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
_favKontrakanIds.add(id);
}
});
final result = await _favoriteService.toggleKontrakanFavorite(id);
if (result['success'] != true && mounted) {
setState(() {
if (wasFav) {
_favKontrakanIds.add(id);
} else {
_favKontrakanIds.remove(id);
}
});
try {
final result = await _favoriteService.toggleKontrakanFavorite(id);
if (result['success'] != true && mounted) {
setState(() {
if (wasFav) {
_favKontrakanIds.add(id);
} else {
_favKontrakanIds.remove(id);
}
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Gagal mengubah favorit'),
backgroundColor: Colors.red.shade600,
),
);
}
} catch (e) {
if (mounted) {
setState(() {
if (wasFav) _favKontrakanIds.add(id); else _favKontrakanIds.remove(id);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red.shade600),
);
}
}
}
Future<void> _toggleLaundryFav(int id) async {
if (!_authService.isAuthenticated) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Silakan login terlebih dahulu'), backgroundColor: Colors.orange),
);
}
return;
}
final wasFav = _favLaundryIds.contains(id);
setState(() {
if (wasFav) {
@ -95,21 +132,39 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
_favLaundryIds.add(id);
}
});
final result = await _favoriteService.toggleLaundryFavorite(id);
if (result['success'] != true && mounted) {
setState(() {
if (wasFav) {
_favLaundryIds.add(id);
} else {
_favLaundryIds.remove(id);
}
});
try {
final result = await _favoriteService.toggleLaundryFavorite(id);
if (result['success'] != true && mounted) {
setState(() {
if (wasFav) {
_favLaundryIds.add(id);
} else {
_favLaundryIds.remove(id);
}
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Gagal mengubah favorit'),
backgroundColor: Colors.red.shade600,
),
);
}
} catch (e) {
if (mounted) {
setState(() {
if (wasFav) _favLaundryIds.add(id); else _favLaundryIds.remove(id);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red.shade600),
);
}
}
}
Future<void> _loadKontrakan() async {
try {
final list = await _kontrakanService.getKontrakan();
if (!mounted) return;
setState(() => _kontrakanList = list.take(6).toList());
} catch (e) {
// Error loading kontrakan silently
@ -119,6 +174,7 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
Future<void> _loadLaundry() async {
try {
final list = await _laundryService.getLaundry();
if (!mounted) return;
setState(() => _laundryList = list.take(6).toList());
} catch (e) {
// Error loading laundry silently
@ -154,7 +210,7 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
// HOME CONTENT
Widget _buildHomeContent() {
return RefreshIndicator(
onRefresh: _loadData,
onRefresh: () => _loadData(clearCache: true),
child: CustomScrollView(
slivers: [
// Gradient Header
@ -716,63 +772,6 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
);
}
// SERVICE TILE
Widget _buildServiceTile({
required IconData icon,
required String label,
required Color color,
required Color bgColor,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withOpacity(0.12)),
boxShadow: [
BoxShadow(
color: color.withOpacity(0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: color,
height: 1.3,
),
),
),
Icon(
Icons.arrow_forward_ios_rounded,
size: 14,
color: color.withOpacity(0.5),
),
],
),
),
);
}
// SERVICE TILE INTEGRATED
Widget _buildServiceTileIntegrated({
required IconData icon,

View File

@ -7,6 +7,10 @@ import '../services/auth_service.dart';
import '../services/favorite_service.dart';
import 'booking_form_screen.dart';
// Koordinat resmi Kampus Polije (Politeknik Negeri Jember)
const double _polije_lat = -8.1599551;
const double _polije_lng = 113.7230733;
class KontrakanDetailScreen extends StatefulWidget {
final Kontrakan kontrakan;
@ -17,11 +21,7 @@ class KontrakanDetailScreen extends StatefulWidget {
}
class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
double? userLat;
double? userLng;
double? distance;
bool isLoadingLocation = false;
String? locationError;
double? distance; // jarak dari kampus Polije ke kontrakan ini
bool _isFavorite = false;
bool _isFavLoading = false;
final _favoriteService = FavoriteService();
@ -30,53 +30,96 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
void initState() {
super.initState();
_checkFavorite();
_calcDistanceFromPolije();
}
/// Hitung jarak dari Kampus Polije ke kontrakan ini secara langsung.
void _calcDistanceFromPolije() {
if (widget.kontrakan.latitude == null || widget.kontrakan.longitude == null) return;
final dist = LocationService.calculateDistance(
_polije_lat,
_polije_lng,
widget.kontrakan.latitude!,
widget.kontrakan.longitude!,
);
setState(() => distance = dist);
}
Future<void> _checkFavorite() async {
final result = await _favoriteService.isKontrakanFavorite(
widget.kontrakan.id,
);
if (mounted) setState(() => _isFavorite = result);
try {
final result = await _favoriteService.isKontrakanFavorite(
widget.kontrakan.id,
);
if (mounted) setState(() => _isFavorite = result);
} catch (e) {
debugPrint('Check favorite error: $e');
}
}
Future<void> _toggleFavorite() async {
setState(() => _isFavLoading = true);
final result = await _favoriteService.toggleKontrakanFavorite(
widget.kontrakan.id,
);
if (mounted) {
setState(() {
_isFavLoading = false;
if (result['success'] == true) {
try {
final result = await _favoriteService.toggleKontrakanFavorite(
widget.kontrakan.id,
);
if (!mounted) return;
if (result['success'] == true) {
setState(() {
_isFavorite = result['isFavorite'] ?? !_isFavorite;
}
});
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(
_isFavorite
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
color: Colors.white,
size: 20,
),
const SizedBox(width: 10),
Text(result['message'] ?? 'Status favorit diubah'),
],
),
backgroundColor: _isFavorite
? const Color(0xFF1565C0)
: Colors.grey[700],
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 2),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Gagal mengubah favorit'),
backgroundColor: Colors.red[700],
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.all(16),
),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(
_isFavorite
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
color: Colors.white,
size: 20,
),
const SizedBox(width: 10),
Text(result['message'] ?? 'Status favorit diubah'),
],
),
backgroundColor: _isFavorite
? const Color(0xFF1565C0)
: Colors.grey[700],
content: Text('Gagal: $e'),
backgroundColor: Colors.red[700],
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 2),
),
);
} finally {
if (mounted) setState(() => _isFavLoading = false);
}
}
@ -423,10 +466,10 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.purple.withValues(alpha: 0.1),
color: const Color(0xFF1565C0).withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.purple.withValues(alpha: 0.3),
color: const Color(0xFF1565C0).withValues(alpha: 0.3),
width: 1,
),
),
@ -434,114 +477,83 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.location_on, color: Colors.purple, size: 24),
const SizedBox(width: 12),
Text(
'Deteksi Lokasi Saya',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
],
),
if (isLoadingLocation)
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.purple),
),
const Icon(Icons.school_rounded, color: Color(0xFF1565C0), size: 22),
const SizedBox(width: 10),
const Text(
'Jarak dari Kampus Polije',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xFF1565C0),
),
),
],
),
const SizedBox(height: 12),
if (distance != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.check_circle, color: Colors.green, size: 20),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Jarak dari Lokasi Saya',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
Text(
distance! < 1
? '${(distance! * 1000).toStringAsFixed(0)} m'
: '${distance!.toStringAsFixed(2)} km',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
],
),
],
),
),
] else if (locationError != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.error, color: Colors.red, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
locationError!,
style: const TextStyle(fontSize: 13, color: Colors.red),
),
),
],
),
),
],
const SizedBox(height: 12),
SizedBox(
Container(
width: double.infinity,
height: 44,
child: ElevatedButton.icon(
onPressed: isLoadingLocation ? null : _detectLocation,
icon: Icon(
isLoadingLocation ? Icons.hourglass_bottom : Icons.my_location,
size: 20,
),
label: Text(
isLoadingLocation ? 'Mendeteksi...' : 'Deteksi Lokasi Saya',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple,
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: const Color(0xFF1565C0).withValues(alpha: 0.15),
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF1565C0).withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.directions_walk_rounded,
color: Color(0xFF1565C0),
size: 20,
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Politeknik Negeri Jember',
style: TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 2),
Text(
distance == null
? '-'
: distance! < 1
? '${(distance! * 1000).toStringAsFixed(0)} meter'
: '${distance!.toStringAsFixed(2)} km',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: Color(0xFF1565C0),
),
),
],
),
],
),
),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.location_pin, size: 13, color: Colors.grey[500]),
const SizedBox(width: 4),
Text(
'Jl. Mastrip No.164, Sumbersari, Jember',
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
),
],
),
],
),
@ -577,51 +589,4 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
}
}
Future<void> _detectLocation() async {
setState(() {
isLoadingLocation = true;
locationError = null;
});
try {
final locationService = LocationService();
final isEnabled = await locationService.isLocationServiceEnabled();
if (!isEnabled) {
setState(() {
locationError = 'Layanan lokasi tidak aktif';
isLoadingLocation = false;
});
return;
}
final position = await locationService.getCurrentLocation();
if (position != null) {
final dist = LocationService.calculateDistance(
position.latitude,
position.longitude,
widget.kontrakan.latitude!,
widget.kontrakan.longitude!,
);
setState(() {
userLat = position.latitude;
userLng = position.longitude;
distance = dist;
isLoadingLocation = false;
});
} else {
setState(() {
locationError = 'Gagal mendapatkan lokasi Anda';
isLoadingLocation = false;
});
}
} catch (e) {
setState(() {
locationError = 'Error: ${e.toString()}';
isLoadingLocation = false;
});
}
}
}

View File

@ -30,48 +30,78 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
}
Future<void> _checkFavorite() async {
final result = await _favoriteService.isLaundryFavorite(widget.laundry.id);
if (mounted) setState(() => _isFavorite = result);
try {
final result = await _favoriteService.isLaundryFavorite(widget.laundry.id);
if (mounted) setState(() => _isFavorite = result);
} catch (e) {
debugPrint('Check favorite error: $e');
}
}
Future<void> _toggleFavorite() async {
setState(() => _isFavLoading = true);
final result = await _favoriteService.toggleLaundryFavorite(
widget.laundry.id,
);
if (mounted) {
setState(() {
_isFavLoading = false;
if (result['success'] == true) {
try {
final result = await _favoriteService.toggleLaundryFavorite(
widget.laundry.id,
);
if (!mounted) return;
if (result['success'] == true) {
setState(() {
_isFavorite = result['isFavorite'] ?? !_isFavorite;
}
});
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(
_isFavorite
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
color: Colors.white,
size: 20,
),
const SizedBox(width: 10),
Text(result['message'] ?? 'Status favorit diubah'),
],
),
backgroundColor: _isFavorite
? const Color(0xFF00897B)
: Colors.grey[700],
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 2),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Gagal mengubah favorit'),
backgroundColor: Colors.red[700],
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.all(16),
),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(
_isFavorite
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
color: Colors.white,
size: 20,
),
const SizedBox(width: 10),
Text(result['message'] ?? 'Status favorit diubah'),
],
),
backgroundColor: _isFavorite
? const Color(0xFF00897B)
: Colors.grey[700],
content: Text('Gagal: $e'),
backgroundColor: Colors.red[700],
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 2),
),
);
} finally {
if (mounted) setState(() => _isFavLoading = false);
}
}
@ -519,6 +549,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
final locationService = LocationService();
final isEnabled = await locationService.isLocationServiceEnabled();
if (!mounted) return;
if (!isEnabled) {
setState(() {
locationError = 'Layanan lokasi tidak aktif';
@ -529,6 +560,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
final position = await locationService.getCurrentLocation();
if (!mounted) return;
if (position != null) {
final dist = LocationService.calculateDistance(
position.latitude,
@ -550,6 +582,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
});
}
} catch (e) {
if (!mounted) return;
setState(() {
locationError = 'Error: ${e.toString()}';
isLoadingLocation = false;
@ -700,7 +733,9 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
}
Future<void> _launchWhatsApp(String phone) async {
final url = 'https://wa.me/$phone';
final cleaned = phone.replaceAll(RegExp(r'[^0-9]'), '');
final formatted = cleaned.startsWith('0') ? '62${cleaned.substring(1)}' : cleaned;
final url = 'https://wa.me/$formatted';
if (await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}

View File

@ -33,38 +33,54 @@ class _LaundryListScreenState extends State<LaundryListScreen> {
Future<void> _loadLaundry() async {
setState(() => _isLoading = true);
final list = await _laundryService.getLaundry();
setState(() {
_laundryList = list;
_filteredList = list;
_isLoading = false;
});
try {
final list = await _laundryService.getLaundry();
if (!mounted) return;
setState(() {
_laundryList = list;
_filteredList = List.from(list);
_applySortInPlace(_sortBy); // apply current sort
_isLoading = false;
});
} catch (e) {
debugPrint('Load laundry error: $e');
if (!mounted) return;
setState(() => _isLoading = false);
}
}
void _filterList(String query) {
setState(() {
if (query.isEmpty) {
_filteredList = _laundryList;
_filteredList = List.from(_laundryList);
} else {
_filteredList = _laundryList.where((laundry) {
return laundry.nama.toLowerCase().contains(query.toLowerCase()) ||
laundry.alamat.toLowerCase().contains(query.toLowerCase());
}).toList();
}
_applySortInPlace(_sortBy);
});
}
void _sortList(String sortBy) {
setState(() {
_sortBy = sortBy;
if (sortBy == 'rating') {
_filteredList.sort((a, b) => b.rating.compareTo(a.rating));
} else if (sortBy == 'harga') {
_filteredList.sort((a, b) => a.hargaKiloan.compareTo(b.hargaKiloan));
}
_applySortInPlace(sortBy);
});
}
/// Internal sort helper call within setState only
void _applySortInPlace(String sortBy) {
_sortBy = sortBy;
if (sortBy == 'rating') {
_filteredList.sort((a, b) => b.rating.compareTo(a.rating));
} else if (sortBy == 'harga') {
_filteredList.sort((a, b) => a.hargaKiloan.compareTo(b.hargaKiloan));
} else if (sortBy == 'jarak') {
_filteredList.sort((a, b) => a.jarakKampus.compareTo(b.jarakKampus));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -187,7 +203,7 @@ class _LaundryListScreenState extends State<LaundryListScreen> {
],
),
Text(
'Urut: ${_sortBy == "rating" ? "Rating" : "Harga"}',
'Urut: ${_sortBy == "rating" ? "Rating" : _sortBy == "harga" ? "Harga" : "Jarak"}',
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],

File diff suppressed because it is too large Load Diff

View File

@ -3,10 +3,11 @@ import 'package:http/http.dart' as http;
import 'dart:async';
import 'dart:convert';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import '../config/app_config.dart';
import '../models/kontrakan.dart';
import '../models/laundry.dart';
import '../models/user.dart';
import '../services/auth_service.dart';
import '../widgets/kontrakan_card.dart';
import '../widgets/laundry_card.dart';
@ -28,6 +29,10 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
bool _noData =
false; // true = memang tidak ada data, false = filter terlalu ketat
// User info
final _authService = AuthService();
User? _currentUser;
// Bobot values (percentage, total must = 100)
// Default: profil mahasiswa
int _bobotHarga = 50;
@ -38,8 +43,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
// Jenis layanan selection for laundry
String _selectedJenisLayanan = 'reguler';
// Location values
String _referensiJarak = 'kampus';
// Location values untuk referensi jarak (deteksi lokasi user)
double? _userLatitude;
double? _userLongitude;
bool _isDetectingLocation = false;
@ -48,12 +52,6 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
widget.category == 'kontrakan' ? 'Jumlah Kamar' : 'Kecepatan Layanan';
String get _kriteria4Label =>
widget.category == 'kontrakan' ? 'Fasilitas' : 'Variasi Layanan';
// ignore: unused_element
String get _kriteria3Key =>
widget.category == 'kontrakan' ? 'jumlah_kamar' : 'kecepatan';
// ignore: unused_element
String get _kriteria4Key =>
widget.category == 'kontrakan' ? 'fasilitas' : 'layanan';
int get _totalBobot =>
_bobotHarga + _bobotJarak + _bobotKriteria3 + _bobotKriteria4;
@ -70,6 +68,33 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
_bobotJarak = 20;
_bobotKriteria3 = 15;
_bobotKriteria4 = 15;
// Load user info
_loadUser();
// Auto-detect lokasi untuk laundry
if (widget.category == 'laundry') {
WidgetsBinding.instance.addPostFrameCallback((_) {
_detectUserLocation();
});
}
}
Future<void> _loadUser() async {
try {
// Use cached user first (no API call needed)
if (_authService.currentUser != null) {
if (!mounted) return;
setState(() => _currentUser = _authService.currentUser);
return;
}
// Fallback: load from SharedPreferences via API
await _authService.loadToken();
if (!mounted) return;
setState(() => _currentUser = _authService.currentUser);
} catch (e) {
debugPrint('Load user error: $e');
}
}
/// Auto-balance: when one bobot changes, redistribute the remaining
@ -123,17 +148,15 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
bobots[otherIdx[i]] = newOther[i];
}
// Final safety check: if sum != 100 due to clamping, adjust the largest
// Final safety: force total == 100 by adjusting the largest other bobot
// without snapping to 5 (exact correction takes priority over pretty numbers)
int total = bobots.reduce((a, b) => a + b);
if (total != 100) {
int diff = 100 - total;
int adjustIdx = otherIdx.reduce(
(a, b) => bobots[a] >= bobots[b] ? a : b,
);
int adjusted = (bobots[adjustIdx] + diff).clamp(10, 70);
// Snap to nearest multiple of 5
adjusted = ((adjusted / 5).round() * 5).clamp(10, 70);
bobots[adjustIdx] = adjusted;
bobots[adjustIdx] = (bobots[adjustIdx] + diff).clamp(5, 75);
}
_bobotHarga = bobots[0];
@ -168,17 +191,31 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
return;
}
// Validate location for laundry
if (widget.category == 'laundry' && _userLatitude == null) {
// Auto-detect location first, then re-calculate
await _detectUserLocation();
if (!mounted) return;
// If still no location after detection attempt, warn and stop
if (_userLatitude == null || _userLongitude == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Lokasi belum terdeteksi. Aktifkan GPS dan coba lagi.',
),
backgroundColor: Colors.orange,
),
);
return;
}
}
setState(() {
_isLoading = true;
_errorMessage = null;
_noData = false;
});
// Clear image cache agar foto terbaru selalu dimuat
await DefaultCacheManager().emptyCache();
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
final endpoint = widget.category == 'kontrakan'
? '/saw/calculate/kontrakan'
: '/saw/calculate/laundry';
@ -197,11 +234,11 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
bodyParams['jenis_layanan'] = _selectedJenisLayanan;
}
if (widget.category == 'laundry' && _referensiJarak == 'user') {
if (_userLatitude != null && _userLongitude != null) {
bodyParams['user_lat'] = _userLatitude;
bodyParams['user_lng'] = _userLongitude;
}
if (widget.category == 'laundry' &&
_userLatitude != null &&
_userLongitude != null) {
bodyParams['user_lat'] = _userLatitude;
bodyParams['user_lng'] = _userLongitude;
}
final response = await http
@ -215,6 +252,8 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
)
.timeout(const Duration(seconds: 15));
if (!mounted) return;
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['success'] == true) {
@ -245,35 +284,37 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
} catch (e) {
debugPrint('SAW API Error: $e');
debugPrint('URL: ${AppConfig.baseUrl}$endpoint');
if (!mounted) return;
setState(() {
_errorMessage =
'Tidak dapat terhubung ke server (${AppConfig.baseUrl}). Periksa koneksi internet Anda dan coba lagi.\n\nDetail: $e';
_hasCalculated = true;
});
} finally {
setState(() {
_isLoading = false;
});
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
Future<void> _detectUserLocation() async {
// Guard: prevent re-entrant / duplicate calls
if (_isDetectingLocation) return;
setState(() => _isDetectingLocation = true);
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Aktifkan GPS Anda terlebih dahulu'),
backgroundColor: Colors.red,
),
);
}
setState(() {
_referensiJarak = 'kampus';
_isDetectingLocation = false;
});
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Aktifkan GPS Anda terlebih dahulu'),
backgroundColor: Colors.red,
),
);
setState(() => _isDetectingLocation = false);
return;
}
@ -281,71 +322,62 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Izin lokasi ditolak'),
backgroundColor: Colors.red,
),
);
}
setState(() {
_referensiJarak = 'kampus';
_isDetectingLocation = false;
});
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Izin lokasi ditolak'),
backgroundColor: Colors.red,
),
);
setState(() => _isDetectingLocation = false);
return;
}
}
if (permission == LocationPermission.deniedForever) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Izin lokasi ditolak permanen. Ubah di pengaturan.',
),
backgroundColor: Colors.red,
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Izin lokasi ditolak permanen. Ubah di pengaturan.',
),
);
}
setState(() {
_referensiJarak = 'kampus';
_isDetectingLocation = false;
});
backgroundColor: Colors.red,
),
);
setState(() => _isDetectingLocation = false);
return;
}
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
).timeout(
const Duration(seconds: 15),
onTimeout: () => throw TimeoutException('Deteksi lokasi timeout'),
);
if (!mounted) return;
setState(() {
_userLatitude = position.latitude;
_userLongitude = position.longitude;
_isDetectingLocation = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Lokasi berhasil dideteksi!'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Lokasi berhasil dideteksi!'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Gagal mendeteksi lokasi: $e'),
backgroundColor: Colors.red,
),
);
}
setState(() {
_referensiJarak = 'kampus';
_isDetectingLocation = false;
});
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Gagal mendeteksi lokasi: ${e.toString().replaceAll('Exception: ', '')}'),
backgroundColor: Colors.red,
),
);
setState(() => _isDetectingLocation = false);
}
}
@ -390,6 +422,8 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildUserInfoCard(),
const SizedBox(height: 16),
_buildMethodInfoCard(),
const SizedBox(height: 16),
if (widget.category == 'laundry') ...[
@ -409,12 +443,151 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
);
}
Widget _buildUserInfoCard() {
final name = _currentUser?.name ?? '';
final email = _currentUser?.email ?? '';
final initials = name.isEmpty
? 'U'
: name.trim().split(' ').length >= 2
? '${name.trim().split(' ')[0][0]}${name.trim().split(' ')[1][0]}'.toUpperCase()
: name.trim()[0].toUpperCase();
final hour = DateTime.now().hour;
final greeting = hour < 11
? 'Selamat Pagi'
: hour < 15
? 'Selamat Siang'
: hour < 18
? 'Selamat Sore'
: 'Selamat Malam';
final categoryLabel = widget.category == 'kontrakan' ? 'kontrakan' : 'laundry';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: Row(
children: [
// Avatar
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [_categoryColor, _categoryColor.withValues(alpha: 0.7)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: _categoryColor.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Center(
child: Text(
initials,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
),
),
const SizedBox(width: 14),
// Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$greeting,',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
name.isEmpty ? 'Pengguna' : name,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: Color(0xFF1A1A2E),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (email.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
email,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade400,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
// Category badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: _categoryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
widget.category == 'kontrakan'
? Icons.home_work_rounded
: Icons.local_laundry_service_rounded,
size: 14,
color: _categoryColor,
),
const SizedBox(width: 5),
Text(
categoryLabel,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: _categoryColor,
),
),
],
),
),
],
),
);
}
Widget _buildMethodInfoCard() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [_categoryColor, _categoryColor.withOpacity(0.8)],
colors: [_categoryColor, _categoryColor.withValues(alpha: 0.8)],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
@ -902,158 +1075,114 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
children: [
Icon(Icons.my_location, color: _categoryColor, size: 20),
const SizedBox(width: 8),
Text(
'Referensi Jarak',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.grey[800],
Expanded(
child: Text(
'Lokasi Saya',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.grey[800],
),
),
),
if (_userLatitude != null && !_isDetectingLocation)
InkWell(
onTap: _detectUserLocation,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.refresh,
size: 18,
color: _categoryColor,
),
),
),
],
),
const SizedBox(height: 4),
Text(
'Tentukan titik referensi untuk perhitungan jarak',
'Jarak dihitung dari lokasi Anda saat ini',
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[200]!),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _referensiJarak,
isExpanded: true,
icon: Icon(Icons.arrow_drop_down, color: _categoryColor),
items: [
DropdownMenuItem(
value: 'kampus',
child: Row(
children: [
Icon(Icons.school, size: 18, color: _categoryColor),
const SizedBox(width: 10),
const Text('Dari Kampus Polije'),
],
),
if (_isDetectingLocation)
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
DropdownMenuItem(
value: 'user',
child: Row(
children: [
Icon(
Icons.location_on,
size: 18,
color: Colors.green[700],
),
const SizedBox(width: 10),
const Text('Dari Lokasi Saya'),
],
),
SizedBox(width: 10),
Text(
'Mendeteksi lokasi...',
style: TextStyle(fontSize: 12),
),
],
onChanged: (val) {
setState(() {
_referensiJarak = val!;
});
if (val == 'user' && _userLatitude == null)
_detectUserLocation();
},
),
),
),
if (_referensiJarak == 'user') ...[
const SizedBox(height: 10),
if (_isDetectingLocation)
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 10),
Text(
'Mendeteksi lokasi...',
style: TextStyle(fontSize: 12),
),
],
),
)
else if (_userLatitude != null && _userLongitude != null)
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.green[50],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green[200]!),
),
child: Row(
children: [
const Icon(
Icons.check_circle,
size: 16,
color: Colors.green,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Lokasi: ${_userLatitude!.toStringAsFixed(6)}, ${_userLongitude!.toStringAsFixed(6)}',
style: TextStyle(
fontSize: 11,
color: Colors.green[700],
),
),
),
InkWell(
onTap: _detectUserLocation,
child: Icon(
Icons.refresh,
size: 16,
)
else if (_userLatitude != null && _userLongitude != null)
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.green[50],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green[200]!),
),
child: Row(
children: [
const Icon(
Icons.check_circle,
size: 16,
color: Colors.green,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Lokasi terdeteksi: ${_userLatitude!.toStringAsFixed(6)}, ${_userLongitude!.toStringAsFixed(6)}',
style: TextStyle(
fontSize: 11,
color: Colors.green[700],
),
),
],
),
)
else
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.orange[50],
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.warning, size: 16, color: Colors.orange),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Lokasi belum terdeteksi',
style: TextStyle(fontSize: 12),
),
),
TextButton(
onPressed: _detectUserLocation,
child: const Text(
'Deteksi',
style: TextStyle(fontSize: 12),
),
),
],
),
),
],
),
],
)
else
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.orange[50],
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.warning, size: 16, color: Colors.orange),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Lokasi belum terdeteksi',
style: TextStyle(fontSize: 12),
),
),
TextButton(
onPressed: _detectUserLocation,
child: const Text(
'Deteksi',
style: TextStyle(fontSize: 12),
),
),
],
),
),
],
),
);
@ -1123,7 +1252,6 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
children: [
_buildBobotSummary(),
if (widget.category == 'laundry' &&
_referensiJarak == 'user' &&
_userLatitude != null)
Container(
width: double.infinity,

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import '../models/kontrakan.dart';
import '../models/laundry.dart';
import '../services/kontrakan_service.dart';
@ -50,20 +49,22 @@ class _SearchScreenState extends State<SearchScreen> {
Future<void> _loadData() async {
setState(() => _isLoading = true);
// Clear image cache agar data terbaru dari server selalu dimuat
await DefaultCacheManager().emptyCache();
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
final kontrakan = await _kontrakanService.getKontrakan();
final laundry = await _laundryService.getLaundry();
setState(() {
_allKontrakan = kontrakan;
_filteredKontrakan = kontrakan;
_allLaundry = laundry;
_filteredLaundry = laundry;
_isLoading = false;
});
_loadFavoriteIds();
try {
final kontrakan = await _kontrakanService.getKontrakan();
final laundry = await _laundryService.getLaundry();
if (!mounted) return;
setState(() {
_allKontrakan = kontrakan;
_filteredKontrakan = kontrakan;
_allLaundry = laundry;
_filteredLaundry = laundry;
_isLoading = false;
});
_loadFavoriteIds();
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
}
}
Future<void> _loadFavoriteIds() async {
@ -84,38 +85,48 @@ class _SearchScreenState extends State<SearchScreen> {
Future<void> _toggleKontrakanFav(int id) async {
final wasFav = _favKontrakanIds.contains(id);
setState(() {
if (wasFav)
_favKontrakanIds.remove(id);
else
_favKontrakanIds.add(id);
if (wasFav) _favKontrakanIds.remove(id); else _favKontrakanIds.add(id);
});
final result = await _favoriteService.toggleKontrakanFavorite(id);
if (result['success'] != true && mounted) {
setState(() {
if (wasFav)
_favKontrakanIds.add(id);
else
_favKontrakanIds.remove(id);
});
try {
final result = await _favoriteService.toggleKontrakanFavorite(id);
if (result['success'] != true && mounted) {
setState(() {
if (wasFav) _favKontrakanIds.add(id); else _favKontrakanIds.remove(id);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result['message'] ?? 'Gagal'), backgroundColor: Colors.red.shade600),
);
}
} catch (e) {
if (mounted) {
setState(() {
if (wasFav) _favKontrakanIds.add(id); else _favKontrakanIds.remove(id);
});
}
}
}
Future<void> _toggleLaundryFav(int id) async {
final wasFav = _favLaundryIds.contains(id);
setState(() {
if (wasFav)
_favLaundryIds.remove(id);
else
_favLaundryIds.add(id);
if (wasFav) _favLaundryIds.remove(id); else _favLaundryIds.add(id);
});
final result = await _favoriteService.toggleLaundryFavorite(id);
if (result['success'] != true && mounted) {
setState(() {
if (wasFav)
_favLaundryIds.add(id);
else
_favLaundryIds.remove(id);
});
try {
final result = await _favoriteService.toggleLaundryFavorite(id);
if (result['success'] != true && mounted) {
setState(() {
if (wasFav) _favLaundryIds.add(id); else _favLaundryIds.remove(id);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result['message'] ?? 'Gagal'), backgroundColor: Colors.red.shade600),
);
}
} catch (e) {
if (mounted) {
setState(() {
if (wasFav) _favLaundryIds.add(id); else _favLaundryIds.remove(id);
});
}
}
}
@ -194,10 +205,9 @@ class _SearchScreenState extends State<SearchScreen> {
}).toList();
}
// Filter by price
// Filter by price (laundry hargaKiloan is per-kg, no multiplication needed)
filtered = filtered.where((l) {
final harga = l.hargaKiloan * 10; // Approximate for 10kg
return harga >= _priceRange.start && harga <= _priceRange.end;
return l.hargaKiloan >= _priceRange.start && l.hargaKiloan <= _priceRange.end;
}).toList();
setState(() => _filteredLaundry = filtered);
@ -280,8 +290,10 @@ class _SearchScreenState extends State<SearchScreen> {
children: [
Expanded(
child: GestureDetector(
onTap: () =>
setState(() => _selectedCategory = 'Kontrakan'),
onTap: () {
setState(() => _selectedCategory = 'Kontrakan');
_applyFilters();
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
@ -329,8 +341,10 @@ class _SearchScreenState extends State<SearchScreen> {
const SizedBox(width: 4),
Expanded(
child: GestureDetector(
onTap: () =>
setState(() => _selectedCategory = 'Laundry'),
onTap: () {
setState(() => _selectedCategory = 'Laundry');
_applyLaundryFilters();
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),

View File

@ -220,6 +220,9 @@ class AuthService {
required String password,
required String passwordConfirmation,
}) async {
if (_currentUser == null || _token == null) {
return {'success': false, 'message': 'Silakan login ulang'};
}
try {
final response = await http.put(
Uri.parse('${AppConfig.baseUrl}/profile/update'),
@ -229,8 +232,8 @@ class AuthService {
'Authorization': 'Bearer $_token',
},
body: jsonEncode({
'name': _currentUser?.name ?? '',
'email': _currentUser?.email ?? '',
'name': _currentUser!.name,
'email': _currentUser!.email,
'password': password,
'password_confirmation': passwordConfirmation,
}),

View File

@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../config/app_config.dart';
import '../models/kontrakan.dart';
@ -21,54 +22,118 @@ class FavoriteService {
return headers;
}
// Get all user's favorites with full data
/// Pastikan token ada sebelum request
bool _ensureAuthenticated() {
if (_authService.token == null) {
debugPrint('[FAV] ❌ Token NULL — user belum login!');
return false;
}
return true;
}
// Get all user's favorites with full data (raw JSON)
Future<Map<String, dynamic>> getFavorites() async {
if (!_ensureAuthenticated()) {
return {'success': false, 'message': 'Belum login', 'kontrakan': [], 'laundry': []};
}
try {
final response = await http.get(
Uri.parse('${AppConfig.baseUrl}/favorites'),
headers: _headers,
);
final url = '${AppConfig.baseUrl}/favorites';
debugPrint('[FAV] GET $url');
debugPrint('[FAV] Token: ${_authService.token!.substring(0, 10)}...');
final response = await http
.get(Uri.parse(url), headers: _headers)
.timeout(AppConfig.connectionTimeout);
debugPrint('[FAV] GET Status: ${response.statusCode}');
debugPrint('[FAV] GET Body: ${response.body.length > 300 ? response.body.substring(0, 300) : response.body}');
if (response.statusCode == 401) {
debugPrint('[FAV] ❌ 401 Unauthorized — token expired/invalid');
return {'success': false, 'message': 'Sesi expired, silakan login ulang', 'kontrakan': [], 'laundry': []};
}
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) {
return {
'success': true,
'kontrakan': data['data']['kontrakan'] ?? [],
'laundry': data['data']['laundry'] ?? [],
'kontrakan': data['data']?['kontrakan'] ?? [],
'laundry': data['data']?['laundry'] ?? [],
};
} else {
return {'success': false, 'kontrakan': [], 'laundry': []};
debugPrint('[FAV] ❌ Response not success: ${response.statusCode} ${data['message']}');
return {
'success': false,
'message': data['message'] ?? 'Gagal memuat favorit',
'kontrakan': [],
'laundry': [],
};
}
} catch (e) {
// Error getting favorites silently
return {'success': false, 'kontrakan': [], 'laundry': []};
} catch (e, stack) {
debugPrint('[FAV] ❌ Exception in getFavorites: $e');
debugPrint('[FAV] Stack: $stack');
return {
'success': false,
'message': 'Gagal terhubung ke server: $e',
'kontrakan': [],
'laundry': [],
};
}
}
// Get favorites with parsed models
// Get favorites with parsed Kontrakan/Laundry models
Future<Map<String, dynamic>> getFavoritesWithModels() async {
final result = await getFavorites();
if (result['success'] != true) {
// Return typed empty lists to avoid cast errors downstream
return {
'success': false,
'message': result['message'] ?? 'Gagal memuat favorit',
'kontrakan': <Kontrakan>[],
'laundry': <Laundry>[],
};
}
try {
final result = await getFavorites();
if (result['success'] != true) return result;
final rawKontrakan = result['kontrakan'] as List? ?? [];
final rawLaundry = result['laundry'] as List? ?? [];
final kontrakanList = (result['kontrakan'] as List)
.map((json) => Kontrakan.fromJson(json as Map<String, dynamic>))
.toList();
debugPrint('[FAV] Parsing ${rawKontrakan.length} kontrakan, ${rawLaundry.length} laundry');
final laundryList = (result['laundry'] as List)
.map((json) => Laundry.fromJson(json as Map<String, dynamic>))
.toList();
final kontrakanList = <Kontrakan>[];
for (final json in rawKontrakan) {
try {
kontrakanList.add(Kontrakan.fromJson(json as Map<String, dynamic>));
} catch (e) {
debugPrint('[FAV] ⚠ Skip kontrakan parse error: $e — data: $json');
}
}
final laundryList = <Laundry>[];
for (final json in rawLaundry) {
try {
laundryList.add(Laundry.fromJson(json as Map<String, dynamic>));
} catch (e) {
debugPrint('[FAV] ⚠ Skip laundry parse error: $e — data: $json');
}
}
debugPrint('[FAV] ✅ Parsed ${kontrakanList.length} kontrakan, ${laundryList.length} laundry');
return {
'success': true,
'kontrakan': kontrakanList,
'laundry': laundryList,
};
} catch (e) {
// Error parsing favorites silently
} catch (e, stack) {
debugPrint('[FAV] ❌ Exception parsing favorites: $e');
debugPrint('[FAV] Stack: $stack');
return {
'success': false,
'message': 'Gagal memproses data favorit',
'kontrakan': <Kontrakan>[],
'laundry': <Laundry>[],
};
@ -83,80 +148,121 @@ class FavoriteService {
return {'kontrakan': [], 'laundry': []};
}
return {
'kontrakan': (result['kontrakan'] as List)
.map<int>((e) => (e as Map<String, dynamic>)['id'] as int? ?? 0)
.toList(),
'laundry': (result['laundry'] as List)
.map<int>((e) => (e as Map<String, dynamic>)['id'] as int? ?? 0)
.toList(),
};
final kontrakanIds = <int>[];
for (final e in (result['kontrakan'] as List? ?? [])) {
final id = (e as Map<String, dynamic>?)?['id'];
if (id != null) kontrakanIds.add(id is int ? id : int.tryParse(id.toString()) ?? 0);
}
final laundryIds = <int>[];
for (final e in (result['laundry'] as List? ?? [])) {
final id = (e as Map<String, dynamic>?)?['id'];
if (id != null) laundryIds.add(id is int ? id : int.tryParse(id.toString()) ?? 0);
}
debugPrint('[FAV] IDs → kontrakan: $kontrakanIds, laundry: $laundryIds');
return {'kontrakan': kontrakanIds, 'laundry': laundryIds};
} catch (e) {
debugPrint('[FAV] ❌ Exception in getFavoriteIds: $e');
return {'kontrakan': [], 'laundry': []};
}
}
// Toggle kontrakan favorite status
Future<Map<String, dynamic>> toggleKontrakanFavorite(int kontrakanId) async {
if (!_ensureAuthenticated()) {
return {'success': false, 'message': 'Belum login'};
}
try {
final response = await http.post(
Uri.parse('${AppConfig.baseUrl}/favorites/kontrakan/$kontrakanId'),
headers: _headers,
);
final url = '${AppConfig.baseUrl}/favorites/kontrakan/$kontrakanId';
debugPrint('[FAV] POST $url');
final response = await http
.post(Uri.parse(url), headers: _headers)
.timeout(AppConfig.connectionTimeout);
debugPrint('[FAV] Toggle kontrakan → ${response.statusCode}: ${response.body}');
if (response.statusCode == 401) {
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
}
final data = jsonDecode(response.body);
final statusOk = response.statusCode == 200 || response.statusCode == 201;
if (response.statusCode == 200 && data['success'] == true) {
if (statusOk && data['success'] == true) {
return {
'success': true,
'message': data['message'] ?? 'Status favorit berhasil diubah',
'isFavorite': data['data']['is_favorite'] ?? false,
'isFavorite': data['is_favorited'] ?? false,
};
} else {
return {
'success': false,
'message': data['message'] ?? 'Gagal mengubah status favorit',
'message': data['message'] ?? 'Gagal mengubah status favorit (${response.statusCode})',
};
}
} catch (e) {
return {'success': false, 'message': 'Error: $e'};
debugPrint('[FAV] ❌ Exception toggleKontrakan: $e');
return {'success': false, 'message': 'Gagal terhubung ke server'};
}
}
// Toggle laundry favorite status
Future<Map<String, dynamic>> toggleLaundryFavorite(int laundryId) async {
if (!_ensureAuthenticated()) {
return {'success': false, 'message': 'Belum login'};
}
try {
final response = await http.post(
Uri.parse('${AppConfig.baseUrl}/favorites/laundry/$laundryId'),
headers: _headers,
);
final url = '${AppConfig.baseUrl}/favorites/laundry/$laundryId';
debugPrint('[FAV] POST $url');
final response = await http
.post(Uri.parse(url), headers: _headers)
.timeout(AppConfig.connectionTimeout);
debugPrint('[FAV] Toggle laundry → ${response.statusCode}: ${response.body}');
if (response.statusCode == 401) {
return {'success': false, 'message': 'Sesi expired, silakan login ulang'};
}
final data = jsonDecode(response.body);
final statusOk = response.statusCode == 200 || response.statusCode == 201;
if (response.statusCode == 200 && data['success'] == true) {
if (statusOk && data['success'] == true) {
return {
'success': true,
'message': data['message'] ?? 'Status favorit berhasil diubah',
'isFavorite': data['data']['is_favorite'] ?? false,
'isFavorite': data['is_favorited'] ?? false,
};
} else {
return {
'success': false,
'message': data['message'] ?? 'Gagal mengubah status favorit',
'message': data['message'] ?? 'Gagal mengubah status favorit (${response.statusCode})',
};
}
} catch (e) {
return {'success': false, 'message': 'Error: $e'};
debugPrint('[FAV] ❌ Exception toggleLaundry: $e');
return {'success': false, 'message': 'Gagal terhubung ke server'};
}
}
// Remove favorite
Future<Map<String, dynamic>> removeFavorite(int favoriteId) async {
if (!_ensureAuthenticated()) {
return {'success': false, 'message': 'Belum login'};
}
try {
final response = await http.delete(
Uri.parse('${AppConfig.baseUrl}/favorites/$favoriteId'),
headers: _headers,
);
final response = await http
.delete(
Uri.parse('${AppConfig.baseUrl}/favorites/$favoriteId'),
headers: _headers,
)
.timeout(AppConfig.connectionTimeout);
final data = jsonDecode(response.body);
@ -172,18 +278,18 @@ class FavoriteService {
};
}
} catch (e) {
return {'success': false, 'message': 'Error: $e'};
debugPrint('[FAV] ❌ Exception removeFavorite: $e');
return {'success': false, 'message': 'Gagal terhubung ke server'};
}
}
// Check if kontrakan is favorite
Future<bool> isKontrakanFavorite(int kontrakanId) async {
try {
final favorites = await getFavorites();
final kontrakanList = favorites['kontrakan'] as List<int>? ?? [];
return kontrakanList.contains(kontrakanId);
final ids = await getFavoriteIds();
return (ids['kontrakan'] ?? []).contains(kontrakanId);
} catch (e) {
// Error checking favorite silently
debugPrint('[FAV] ❌ Exception isKontrakanFavorite: $e');
return false;
}
}
@ -191,11 +297,10 @@ class FavoriteService {
// Check if laundry is favorite
Future<bool> isLaundryFavorite(int laundryId) async {
try {
final favorites = await getFavorites();
final laundryList = favorites['laundry'] as List<int>? ?? [];
return laundryList.contains(laundryId);
final ids = await getFavoriteIds();
return (ids['laundry'] ?? []).contains(laundryId);
} catch (e) {
// Error checking favorite silently
debugPrint('[FAV] ❌ Exception isLaundryFavorite: $e');
return false;
}
}

View File

@ -30,22 +30,17 @@ class KontrakanService {
String status = 'tersedia',
}) async {
try {
var url = '${AppConfig.baseUrl}/kontrakan?status=$status';
var uri = Uri.parse('${AppConfig.baseUrl}/kontrakan').replace(
queryParameters: {
'status': status,
if (search != null && search.isNotEmpty) 'search': search,
if (hargaMin != null) 'harga_min': hargaMin.toString(),
if (hargaMax != null) 'harga_max': hargaMax.toString(),
if (jumlahKamar != null) 'jumlah_kamar': jumlahKamar.toString(),
},
);
if (search != null && search.isNotEmpty) {
url += '&search=$search';
}
if (hargaMin != null) {
url += '&harga_min=$hargaMin';
}
if (hargaMax != null) {
url += '&harga_max=$hargaMax';
}
if (jumlahKamar != null) {
url += '&jumlah_kamar=$jumlahKamar';
}
final response = await http.get(Uri.parse(url), headers: _headers);
final response = await http.get(uri, headers: _headers);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);

View File

@ -0,0 +1,145 @@
import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
/// Service untuk mendeteksi IP server Laravel secara otomatis.
/// Urutan pencarian:
/// 1. IP tersimpan di cache (paling cepat jika masih valid)
/// 2. Android Emulator (10.0.2.2)
/// 3. Localhost (desktop / iOS simulator)
/// 4. Scan subnet lokal HP secara paralel
class ServerDiscoveryService {
static const int _port = 8000;
static const String _cacheKey = 'discovered_server_url';
static const Duration _probeTimeout = Duration(seconds: 2);
// PUBLIC ENTRY POINT
/// Temukan server dan update [AppConfig] secara otomatis.
/// Kembalikan `true` jika server ditemukan, `false` jika tidak.
static Future<bool> discover({
void Function(String status)? onStatus,
}) async {
onStatus?.call('Mencari server...');
// 1. Coba cache terlebih dahulu
final cached = await _getCachedUrl();
if (cached != null) {
onStatus?.call('Mencoba koneksi tersimpan...');
if (await _isAlive(cached)) {
AppConfig.setServerUrl(cached);
return true;
}
}
// 2. Android Emulator
const emulatorUrl = 'http://10.0.2.2:$_port';
if (await _isAlive(emulatorUrl)) {
await _saveCache(emulatorUrl);
AppConfig.setServerUrl(emulatorUrl);
return true;
}
// 3. Localhost (Windows/iOS)
const localhostUrl = 'http://localhost:$_port';
if (await _isAlive(localhostUrl)) {
await _saveCache(localhostUrl);
AppConfig.setServerUrl(localhostUrl);
return true;
}
// 4. Scan subnet lokal HP
onStatus?.call('Memindai jaringan lokal...');
final subnets = await _getLocalSubnets();
if (subnets.isNotEmpty) {
final found = await _scanSubnets(subnets, onStatus: onStatus);
if (found != null) {
await _saveCache(found);
AppConfig.setServerUrl(found);
return true;
}
}
// Gagal tetap pakai nilai AppConfig yang lama (last known)
return false;
}
// HELPERS
/// Cek apakah server ada di URL tersebut (respons apapun = server hidup).
static Future<bool> _isAlive(String serverUrl) async {
try {
final response = await http
.get(
Uri.parse('$serverUrl/api'),
headers: {'Accept': 'application/json'},
)
.timeout(_probeTimeout);
// Respons 4xx/5xx tetap berarti server bisa dijangkau
return response.statusCode > 0;
} catch (_) {
return false;
}
}
/// Ambil semua subnet IPv4 milik device (e.g., "192.168.1").
static Future<List<String>> _getLocalSubnets() async {
final subnets = <String>[];
try {
final interfaces = await NetworkInterface.list(
type: InternetAddressType.IPv4,
includeLinkLocal: false,
);
for (final iface in interfaces) {
for (final addr in iface.addresses) {
final parts = addr.address.split('.');
if (parts.length == 4 && parts[0] != '127') {
final subnet = '${parts[0]}.${parts[1]}.${parts[2]}';
if (!subnets.contains(subnet)) subnets.add(subnet);
}
}
}
} catch (_) {}
return subnets;
}
/// Scan semua host di subnet secara paralel, kembalikan URL pertama yang aktif.
static Future<String?> _scanSubnets(
List<String> subnets, {
void Function(String)? onStatus,
}) async {
final completer = Completer<String?>();
int remaining = subnets.length * 254;
if (remaining == 0) return null;
for (final subnet in subnets) {
for (int i = 1; i <= 254; i++) {
final url = 'http://$subnet.$i:$_port';
_isAlive(url).then((alive) {
if (alive && !completer.isCompleted) {
completer.complete(url);
}
remaining--;
if (remaining <= 0 && !completer.isCompleted) {
completer.complete(null);
}
});
}
}
return completer.future;
}
static Future<String?> _getCachedUrl() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_cacheKey);
}
static Future<void> _saveCache(String serverUrl) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_cacheKey, serverUrl);
}
}