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:
parent
73e8f197f2
commit
1a277067f4
|
|
@ -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
|
||||||
|
|
@ -25,6 +25,14 @@ public function login(Request $request)
|
||||||
|
|
||||||
// FIXED: Hapus guard('admin'), pakai default Auth
|
// FIXED: Hapus guard('admin'), pakai default Auth
|
||||||
if (Auth::attempt($credentials)) {
|
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();
|
$request->session()->regenerate();
|
||||||
return redirect()->intended(route('dashboard'));
|
return redirect()->intended(route('dashboard'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,31 @@
|
||||||
class FavoriteController extends Controller
|
class FavoriteController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* List favorites user
|
* List favorites user — separated by type
|
||||||
*/
|
*/
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$favorites = Favorite::with(['favoritable'])
|
$userId = $request->user()->id;
|
||||||
->where('user_id', $request->user()->id)
|
|
||||||
->orderBy('created_at', 'desc')
|
$kontrakanIds = Favorite::where('user_id', $userId)
|
||||||
->get();
|
->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([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => $favorites
|
'data' => [
|
||||||
|
'kontrakan' => $kontrakanList,
|
||||||
|
'laundry' => $laundryList,
|
||||||
|
],
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,8 +54,8 @@ public function toggleKontrakan(Request $request, $id)
|
||||||
}
|
}
|
||||||
|
|
||||||
$favorite = Favorite::where('user_id', $request->user()->id)
|
$favorite = Favorite::where('user_id', $request->user()->id)
|
||||||
->where('favoritable_type', Kontrakan::class)
|
->where('type', 'kontrakan')
|
||||||
->where('favoritable_id', $id)
|
->where('item_id', $id)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($favorite) {
|
if ($favorite) {
|
||||||
|
|
@ -56,17 +69,16 @@ public function toggleKontrakan(Request $request, $id)
|
||||||
], 200);
|
], 200);
|
||||||
} else {
|
} else {
|
||||||
// Add to favorites
|
// Add to favorites
|
||||||
$favorite = Favorite::create([
|
Favorite::create([
|
||||||
'user_id' => $request->user()->id,
|
'user_id' => $request->user()->id,
|
||||||
'favoritable_type' => Kontrakan::class,
|
'type' => 'kontrakan',
|
||||||
'favoritable_id' => $id,
|
'item_id' => $id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Kontrakan ditambahkan ke favorit',
|
'message' => 'Kontrakan ditambahkan ke favorit',
|
||||||
'is_favorited' => true,
|
'is_favorited' => true,
|
||||||
'data' => $favorite
|
|
||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -86,8 +98,8 @@ public function toggleLaundry(Request $request, $id)
|
||||||
}
|
}
|
||||||
|
|
||||||
$favorite = Favorite::where('user_id', $request->user()->id)
|
$favorite = Favorite::where('user_id', $request->user()->id)
|
||||||
->where('favoritable_type', Laundry::class)
|
->where('type', 'laundry')
|
||||||
->where('favoritable_id', $id)
|
->where('item_id', $id)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($favorite) {
|
if ($favorite) {
|
||||||
|
|
@ -101,17 +113,16 @@ public function toggleLaundry(Request $request, $id)
|
||||||
], 200);
|
], 200);
|
||||||
} else {
|
} else {
|
||||||
// Add to favorites
|
// Add to favorites
|
||||||
$favorite = Favorite::create([
|
Favorite::create([
|
||||||
'user_id' => $request->user()->id,
|
'user_id' => $request->user()->id,
|
||||||
'favoritable_type' => Laundry::class,
|
'type' => 'laundry',
|
||||||
'favoritable_id' => $id,
|
'item_id' => $id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Laundry ditambahkan ke favorit',
|
'message' => 'Laundry ditambahkan ke favorit',
|
||||||
'is_favorited' => true,
|
'is_favorited' => true,
|
||||||
'data' => $favorite
|
|
||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,11 @@ public function create(Request $request)
|
||||||
$timestamp = now()->format('Y-m-d-H-i-s');
|
$timestamp = now()->format('Y-m-d-H-i-s');
|
||||||
$backupFile = $this->backupPath . "/backup_{$timestamp}.sql";
|
$backupFile = $this->backupPath . "/backup_{$timestamp}.sql";
|
||||||
|
|
||||||
// Get database credentials
|
// Get database credentials (use config() instead of env() for config:cache compatibility)
|
||||||
$database = env('DB_DATABASE');
|
$database = config('database.connections.mysql.database');
|
||||||
$username = env('DB_USERNAME');
|
$username = config('database.connections.mysql.username');
|
||||||
$password = env('DB_PASSWORD');
|
$password = config('database.connections.mysql.password');
|
||||||
$host = env('DB_HOST');
|
$host = config('database.connections.mysql.host');
|
||||||
|
|
||||||
// Create SQL dump (Windows MySQL)
|
// Create SQL dump (Windows MySQL)
|
||||||
$command = sprintf(
|
$command = sprintf(
|
||||||
|
|
@ -98,6 +98,8 @@ public function create(Request $request)
|
||||||
|
|
||||||
public function download($backup)
|
public function download($backup)
|
||||||
{
|
{
|
||||||
|
// Sanitize: prevent path traversal
|
||||||
|
$backup = basename($backup);
|
||||||
$backupPath = $this->backupPath . '/' . $backup;
|
$backupPath = $this->backupPath . '/' . $backup;
|
||||||
|
|
||||||
if (!File::exists($backupPath)) {
|
if (!File::exists($backupPath)) {
|
||||||
|
|
@ -110,6 +112,8 @@ public function download($backup)
|
||||||
public function delete($backup)
|
public function delete($backup)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
// Sanitize: prevent path traversal
|
||||||
|
$backup = basename($backup);
|
||||||
$backupPath = $this->backupPath . '/' . $backup;
|
$backupPath = $this->backupPath . '/' . $backup;
|
||||||
|
|
||||||
if (!File::exists($backupPath)) {
|
if (!File::exists($backupPath)) {
|
||||||
|
|
@ -127,6 +131,8 @@ public function delete($backup)
|
||||||
public function restore(Request $request, $backup)
|
public function restore(Request $request, $backup)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
// Sanitize: prevent path traversal
|
||||||
|
$backup = basename($backup);
|
||||||
$backupPath = $this->backupPath . '/' . $backup;
|
$backupPath = $this->backupPath . '/' . $backup;
|
||||||
|
|
||||||
if (!File::exists($backupPath)) {
|
if (!File::exists($backupPath)) {
|
||||||
|
|
@ -154,11 +160,11 @@ public function restore(Request $request, $backup)
|
||||||
$sqlFile = $backupPath;
|
$sqlFile = $backupPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore database
|
// Restore database (use config() instead of env() for config:cache compatibility)
|
||||||
$database = env('DB_DATABASE');
|
$database = config('database.connections.mysql.database');
|
||||||
$username = env('DB_USERNAME');
|
$username = config('database.connections.mysql.username');
|
||||||
$password = env('DB_PASSWORD');
|
$password = config('database.connections.mysql.password');
|
||||||
$host = env('DB_HOST');
|
$host = config('database.connections.mysql.host');
|
||||||
|
|
||||||
$command = sprintf(
|
$command = sprintf(
|
||||||
'mysql --user=%s --password=%s --host=%s %s < "%s"',
|
'mysql --user=%s --password=%s --host=%s %s < "%s"',
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ public function index()
|
||||||
|
|
||||||
// ========== DATA TERBARU (No Cache) ==========
|
// ========== DATA TERBARU (No Cache) ==========
|
||||||
$recentKontrakan = Kontrakan::latest()
|
$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)
|
->take(5)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
|
@ -86,15 +86,15 @@ public function index()
|
||||||
->selectRaw('
|
->selectRaw('
|
||||||
AVG(harga) as avg_harga,
|
AVG(harga) as avg_harga,
|
||||||
AVG(jarak) as avg_jarak,
|
AVG(jarak) as avg_jarak,
|
||||||
AVG(luas) as avg_luas,
|
AVG(jumlah_kamar) as avg_kamar,
|
||||||
MIN(harga) as min_harga,
|
MIN(harga) as min_harga,
|
||||||
MAX(harga) as max_harga
|
MAX(harga) as max_harga
|
||||||
')
|
')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
// 5. Top Kontrakan by Luas
|
// 5. Top Kontrakan by Harga
|
||||||
$topKontrakan = Kontrakan::select('nama', 'harga', 'luas', 'jarak')
|
$topKontrakan = Kontrakan::select('nama', 'harga', 'jumlah_kamar', 'jarak')
|
||||||
->orderBy('luas', 'desc')
|
->orderBy('harga', 'desc')
|
||||||
->take(5)
|
->take(5)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
|
@ -136,7 +136,7 @@ public function index()
|
||||||
],
|
],
|
||||||
'avgHargaKontrakan' => $kontrakanStats->avg_harga ?? 0,
|
'avgHargaKontrakan' => $kontrakanStats->avg_harga ?? 0,
|
||||||
'avgJarakKontrakan' => $kontrakanStats->avg_jarak ?? 0,
|
'avgJarakKontrakan' => $kontrakanStats->avg_jarak ?? 0,
|
||||||
'avgLuasKontrakan' => $kontrakanStats->avg_luas ?? 0,
|
'avgKamarKontrakan' => $kontrakanStats->avg_kamar ?? 0,
|
||||||
'minHargaKontrakan' => $kontrakanStats->min_harga ?? 0,
|
'minHargaKontrakan' => $kontrakanStats->min_harga ?? 0,
|
||||||
'maxHargaKontrakan' => $kontrakanStats->max_harga ?? 0,
|
'maxHargaKontrakan' => $kontrakanStats->max_harga ?? 0,
|
||||||
'topKontrakan' => $topKontrakan,
|
'topKontrakan' => $topKontrakan,
|
||||||
|
|
|
||||||
|
|
@ -176,20 +176,20 @@ public function laundryExcel(Request $request)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter harga
|
// Filter harga
|
||||||
if ($request->filled('harga_min')) {
|
if ($request->filled('harga_min') || $request->filled('harga_max')) {
|
||||||
$query->whereHas('layanan', function($q) use ($request) {
|
$query->whereHas('layanan', function($q) use ($request) {
|
||||||
$q->where('harga', '>=', $request->harga_min);
|
if ($request->filled('harga_min')) {
|
||||||
});
|
$q->where('harga', '>=', $request->harga_min);
|
||||||
}
|
}
|
||||||
if ($request->filled('harga_max')) {
|
if ($request->filled('harga_max')) {
|
||||||
$query->whereHas('layanan', function($q) use ($request) {
|
$q->where('harga', '<=', $request->harga_max);
|
||||||
$q->where('harga', '<=', $request->harga_max);
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter jarak
|
// Filter jarak
|
||||||
if ($request->filled('jarak')) {
|
if ($request->filled('jarak_max')) {
|
||||||
$jarakMeter = $request->jarak * 1000;
|
$jarakMeter = $request->jarak_max * 1000;
|
||||||
$query->where('jarak', '<=', $jarakMeter);
|
$query->where('jarak', '<=', $jarakMeter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,12 +254,16 @@ public function laundryPDF(Request $request)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter harga
|
// Filter harga (harga ada di tabel layanan_laundry, bukan laundry)
|
||||||
if ($request->filled('harga_min')) {
|
if ($request->filled('harga_min') || $request->filled('harga_max')) {
|
||||||
$query->where('harga', '>=', $request->harga_min);
|
$query->whereHas('layanan', function($q) use ($request) {
|
||||||
}
|
if ($request->filled('harga_min')) {
|
||||||
if ($request->filled('harga_max')) {
|
$q->where('harga', '>=', $request->harga_min);
|
||||||
$query->where('harga', '<=', $request->harga_max);
|
}
|
||||||
|
if ($request->filled('harga_max')) {
|
||||||
|
$q->where('harga', '<=', $request->harga_max);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$laundry = $query->get();
|
$laundry = $query->get();
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,11 @@ public function setPrimaryKontrakan($id)
|
||||||
{
|
{
|
||||||
$galeri = Galeri::findOrFail($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
|
// Reset semua foto di kontrakan yang sama jadi bukan primary
|
||||||
Galeri::where('type', 'kontrakan')
|
Galeri::where('type', 'kontrakan')
|
||||||
->where('item_id', $galeri->item_id)
|
->where('item_id', $galeri->item_id)
|
||||||
|
|
@ -124,6 +129,11 @@ public function setPrimaryLaundry($id)
|
||||||
{
|
{
|
||||||
$galeri = Galeri::findOrFail($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
|
// Reset semua foto di laundry yang sama jadi bukan primary
|
||||||
Galeri::where('type', 'laundry')
|
Galeri::where('type', 'laundry')
|
||||||
->where('item_id', $galeri->item_id)
|
->where('item_id', $galeri->item_id)
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ public function store(Request $request)
|
||||||
'jarak' => $request->jarak,
|
'jarak' => $request->jarak,
|
||||||
'fasilitas' => $request->fasilitas,
|
'fasilitas' => $request->fasilitas,
|
||||||
'jumlah_kamar' => $request->jumlah_kamar,
|
'jumlah_kamar' => $request->jumlah_kamar,
|
||||||
'luas' => $request->luas ?? 0, // Default 0 jika tidak diisi
|
'bathroom_count' => $request->bathroom_count ?? 1,
|
||||||
'foto' => $filename,
|
'foto' => $filename,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -333,7 +333,7 @@ public function update(Request $request, $id)
|
||||||
'jarak' => $request->jarak,
|
'jarak' => $request->jarak,
|
||||||
'fasilitas' => $request->fasilitas,
|
'fasilitas' => $request->fasilitas,
|
||||||
'jumlah_kamar' => $request->jumlah_kamar,
|
'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,
|
'foto' => $filename,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ public function store(Request $request)
|
||||||
'tipe' => 'required|in:Benefit,Cost',
|
'tipe' => 'required|in:Benefit,Cost',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Kriteria::create($request->all());
|
Kriteria::create($request->only(['tipe_bisnis', 'nama_kriteria', 'bobot', 'tipe']));
|
||||||
|
|
||||||
// Redirect dengan parameter filter
|
// Redirect dengan parameter filter
|
||||||
return redirect()
|
return redirect()
|
||||||
|
|
@ -139,7 +139,7 @@ public function update(Request $request, Kriteria $kriterium)
|
||||||
'tipe' => 'required|in:Benefit,Cost',
|
'tipe' => 'required|in:Benefit,Cost',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$kriterium->update($request->all());
|
$kriterium->update($request->only(['tipe_bisnis', 'nama_kriteria', 'bobot', 'tipe']));
|
||||||
|
|
||||||
// Redirect dengan parameter filter
|
// Redirect dengan parameter filter
|
||||||
return redirect()
|
return redirect()
|
||||||
|
|
|
||||||
|
|
@ -36,15 +36,14 @@ public function index(Request $request)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== FILTER: RANGE HARGA (Min & Max dengan Slider) ==========
|
// ========== 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) {
|
$query->whereHas('layanan', function($q) use ($request) {
|
||||||
$q->where('harga', '>=', $request->harga_min);
|
if ($request->filled('harga_min')) {
|
||||||
});
|
$q->where('harga', '>=', $request->harga_min);
|
||||||
}
|
}
|
||||||
|
if ($request->filled('harga_max')) {
|
||||||
if ($request->filled('harga_max')) {
|
$q->where('harga', '<=', $request->harga_max);
|
||||||
$query->whereHas('layanan', function($q) use ($request) {
|
}
|
||||||
$q->where('harga', '<=', $request->harga_max);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,8 +149,8 @@ public function destroy($id)
|
||||||
{
|
{
|
||||||
$review = Review::findOrFail($id);
|
$review = Review::findOrFail($id);
|
||||||
|
|
||||||
// Cek apakah user adalah pemilik review atau admin
|
// Cek apakah user adalah pemilik review atau admin/super_admin
|
||||||
if ($review->user_id !== Auth::id() && Auth::user()->role !== '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');
|
return back()->with('error', 'Anda tidak memiliki akses untuk menghapus review ini');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -222,10 +222,7 @@ private function processData($data, $tipe, $jenisLayanan, $refLat, $refLng)
|
||||||
}
|
}
|
||||||
|
|
||||||
$item->harga_value = $layanan->harga ?? 0;
|
$item->harga_value = $layanan->harga ?? 0;
|
||||||
$item->kecepatan_value = $this->convertToHours(
|
$item->kecepatan_value = $layanan->estimasi_selesai ?? 0; // sudah dalam satuan jam
|
||||||
$layanan->kecepatan ?? 0,
|
|
||||||
$layanan->satuan_kecepatan ?? 'jam'
|
|
||||||
);
|
|
||||||
|
|
||||||
// LAUNDRY: Hitung jarak dari LOKASI USER
|
// LAUNDRY: Hitung jarak dari LOKASI USER
|
||||||
if ($item->latitude && $item->longitude) {
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +33,7 @@ public function index(Request $request)
|
||||||
if ($request->status === 'active') {
|
if ($request->status === 'active') {
|
||||||
$query->whereNull('deleted_at');
|
$query->whereNull('deleted_at');
|
||||||
} else {
|
} else {
|
||||||
$query->whereNotNull('deleted_at');
|
$query->onlyTrashed();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,15 +91,17 @@ public function update(Request $request, User $user)
|
||||||
|
|
||||||
$oldValues = $user->toArray();
|
$oldValues = $user->toArray();
|
||||||
|
|
||||||
if ($request->filled('password')) {
|
$updateData = [
|
||||||
$user->password = Hash::make($request->password);
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->update([
|
|
||||||
'name' => $validated['name'],
|
'name' => $validated['name'],
|
||||||
'email' => $validated['email'],
|
'email' => $validated['email'],
|
||||||
'role' => $validated['role'],
|
'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());
|
ActivityLog::log('update', "Memperbarui user: {$user->name}", 'User', $user->id, $oldValues, $user->toArray());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ class AdminAuth
|
||||||
{
|
{
|
||||||
public function handle($request, Closure $next)
|
public function handle($request, Closure $next)
|
||||||
{
|
{
|
||||||
if (! Auth::guard('admin')->check()) {
|
if (! Auth::check()) {
|
||||||
return redirect()->route('admin.login');
|
return redirect()->route('admin.login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,13 +146,13 @@ public static function archiveOldLogs($keepDays = 365)
|
||||||
// Dashboard Statistics
|
// Dashboard Statistics
|
||||||
public static function getSummaryStats($userId = null)
|
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 [
|
return [
|
||||||
'total_actions' => $query->count(),
|
'total_actions' => (clone $baseQuery)->count(),
|
||||||
'today_actions' => $query->whereDate('created_at', today())->count(),
|
'today_actions' => (clone $baseQuery)->whereDate('created_at', today())->count(),
|
||||||
'this_week_actions' => $query->where('created_at', '>=', now()->startOfWeek())->count(),
|
'this_week_actions' => (clone $baseQuery)->where('created_at', '>=', now()->startOfWeek())->count(),
|
||||||
'this_month_actions' => $query->whereMonth('created_at', now()->month)->count(),
|
'this_month_actions' => (clone $baseQuery)->whereMonth('created_at', now()->month)->count(),
|
||||||
'most_common_action' => self::select('action')
|
'most_common_action' => self::select('action')
|
||||||
->groupBy('action')
|
->groupBy('action')
|
||||||
->orderByRaw('count(*) desc')
|
->orderByRaw('count(*) desc')
|
||||||
|
|
@ -162,7 +162,7 @@ public static function getSummaryStats($userId = null)
|
||||||
->groupBy('action')
|
->groupBy('action')
|
||||||
->orderByRaw('count(*) desc')
|
->orderByRaw('count(*) desc')
|
||||||
->pluck('count', 'action'),
|
->pluck('count', 'action'),
|
||||||
'recent_activity' => $query->with('user')
|
'recent_activity' => (clone $baseQuery)->with('user')
|
||||||
->latest()
|
->latest()
|
||||||
->limit(10)
|
->limit(10)
|
||||||
->get()
|
->get()
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class Galeri extends Model
|
||||||
*/
|
*/
|
||||||
public function kontrakan()
|
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()
|
public function laundry()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Laundry::class, 'item_id')->where('type', 'laundry');
|
return $this->belongsTo(Laundry::class, 'item_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ public function user()
|
||||||
*/
|
*/
|
||||||
public function kontrakan()
|
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()
|
public function laundry()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Laundry::class, 'item_id')->where('type', 'laundry');
|
return $this->belongsTo(Laundry::class, 'item_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -97,4 +97,28 @@ public function activityLogs()
|
||||||
{
|
{
|
||||||
return $this->hasMany(ActivityLog::class);
|
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 |
|
|
@ -416,10 +416,6 @@ class="form-control border-start-0 @error('jumlah_kamar') is-invalid @enderror"
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">Jumlah kamar tidur yang tersedia</small>
|
<small class="text-muted">Jumlah kamar tidur yang tersedia</small>
|
||||||
</div>
|
</div>
|
||||||
<i class="bi bi-info-circle me-1"></i>
|
|
||||||
Total kamar mandi (sistem akan otomatis menghitung kenyamanan)
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Jarak - AUTO CALCULATED! -->
|
<!-- Jarak - AUTO CALCULATED! -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
|
|
|
||||||
|
|
@ -568,21 +568,6 @@ class="btn btn-outline-secondary btn-sm"
|
||||||
</div>
|
</div>
|
||||||
</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 }} m²
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Jumlah Kamar Tidur -->
|
<!-- Jumlah Kamar Tidur -->
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="d-flex align-items-start">
|
<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>
|
<strong>Rp {{ number_format($kontrakan->harga / 12, 0, ',', '.') }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3 pb-3 border-bottom">
|
<div class="d-flex justify-content-between align-items-center mb-3 pb-3 border-bottom">
|
||||||
<span class="text-muted">Harga/m²</span>
|
<span class="text-muted">Harga/Kamar</span>
|
||||||
<strong>Rp {{ number_format($kontrakan->harga / $kontrakan->jumlah_kamar, 0, ',', '.') }}</strong>
|
<strong>Rp {{ number_format($kontrakan->jumlah_kamar > 0 ? $kontrakan->harga / $kontrakan->jumlah_kamar : 0, 0, ',', '.') }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3 pb-3 border-bottom">
|
<div class="d-flex justify-content-between align-items-center mb-3 pb-3 border-bottom">
|
||||||
<span class="text-muted">Status</span>
|
<span class="text-muted">Status</span>
|
||||||
|
|
|
||||||
|
|
@ -512,22 +512,41 @@ class="form-control"
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-semibold">
|
<label class="form-label fw-semibold">
|
||||||
Kecepatan <span class="text-danger">*</span>
|
Nama Paket <span class="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="input-group">
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="number"
|
name="layanan[0][nama_paket]"
|
||||||
name="layanan[0][kecepatan]"
|
class="form-control"
|
||||||
class="form-control"
|
placeholder="Paket Reguler"
|
||||||
placeholder="24"
|
required
|
||||||
min="1"
|
>
|
||||||
required
|
</div>
|
||||||
>
|
|
||||||
<select name="layanan[0][satuan_kecepatan]" class="form-select" style="max-width: 100px;">
|
<div class="col-md-6">
|
||||||
<option value="jam">Jam</option>
|
<label class="form-label fw-semibold">
|
||||||
<option value="hari">Hari</option>
|
Estimasi Selesai (Jam) <span class="text-danger">*</span>
|
||||||
</select>
|
</label>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@
|
||||||
<!-- FOTO ADA -->
|
<!-- FOTO ADA -->
|
||||||
<div class="position-relative" style="height: 400px; overflow: hidden;">
|
<div class="position-relative" style="height: 400px; overflow: hidden;">
|
||||||
<img
|
<img
|
||||||
src="{{ asset('uploads/laundry/' . $laundry->foto) }}"
|
src="{{ asset('uploads/Laundry/' . $laundry->foto) }}"
|
||||||
alt="{{ $laundry->nama }}"
|
alt="{{ $laundry->nama }}"
|
||||||
class="w-100 h-100"
|
class="w-100 h-100"
|
||||||
style="object-fit: cover; cursor: pointer;"
|
style="object-fit: cover; cursor: pointer;"
|
||||||
|
|
@ -232,9 +232,9 @@ class="btn btn-sm btn-danger">
|
||||||
<table class="table table-sm table-bordered">
|
<table class="table table-sm table-bordered">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Jenis Layanan</th>
|
<th>Nama Paket</th>
|
||||||
<th>Harga/kg</th>
|
<th>Harga</th>
|
||||||
<th>Kecepatan</th>
|
<th>Estimasi Selesai</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -242,7 +242,7 @@ class="btn btn-sm btn-danger">
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-primary">
|
<span class="badge bg-primary">
|
||||||
{{ ucfirst($layanan->jenis_layanan) }}
|
{{ $layanan->nama_paket ?? ucfirst($layanan->jenis_layanan ?? '-') }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -252,7 +252,7 @@ class="btn btn-sm btn-danger">
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-warning text-dark">
|
<span class="badge bg-warning text-dark">
|
||||||
{{ $layanan->kecepatan }} {{ $layanan->satuan_kecepatan }}
|
{{ $layanan->estimasi_selesai ? $layanan->estimasi_selesai . ' jam' : '-' }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -318,7 +318,7 @@ class="btn btn-sm btn-danger">
|
||||||
@endif
|
@endif
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3 pb-3 border-bottom">
|
<div class="d-flex justify-content-between align-items-center mb-3 pb-3 border-bottom">
|
||||||
<span class="text-muted">Status</span>
|
<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>
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<span class="text-muted">Kategori</span>
|
<span class="text-muted">Kategori</span>
|
||||||
|
|
|
||||||
|
|
@ -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-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-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);
|
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<h6 class="mb-0 fw-bold" style="color: var(--text-primary);">Rekomendasi Terbaik</h6>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="card-body text-center p-3 p-md-4">
|
<div class="card-body text-center p-3 p-md-4">
|
||||||
@if(count($hasil) > 0)
|
@if(count($hasil) > 0)
|
||||||
@if(isset($hasil[0]['foto']) && $hasil[0]['foto'])
|
@if(isset($hasil[0]['foto']) && $hasil[0]['foto'])
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,6 @@
|
||||||
|
|
||||||
<div class="auth-footer">
|
<div class="auth-footer">
|
||||||
<p>Belum punya akun bisnis? <a href="{{ route('admin.register') }}">Daftar Sebagai Pemilik</a></p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-header {
|
.auth-header {
|
||||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 30px 20px;
|
padding: 30px 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -197,7 +197,6 @@
|
||||||
|
|
||||||
<div class="auth-footer">
|
<div class="auth-footer">
|
||||||
<p>Sudah punya akun? <a href="{{ route('admin.login') }}">Login di sini</a></p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -567,7 +567,7 @@
|
||||||
@endif
|
@endif
|
||||||
@else
|
@else
|
||||||
@if($item->foto)
|
@if($item->foto)
|
||||||
<img src="{{ asset('uploads/laundry/' . $item->foto) }}" alt="{{ $item->nama }}">
|
<img src="{{ asset('uploads/Laundry/' . $item->foto) }}" alt="{{ $item->nama }}">
|
||||||
@else
|
@else
|
||||||
<img src="https://via.placeholder.com/400x200/4facfe/ffffff?text={{ urlencode($item->nama) }}" alt="{{ $item->nama }}">
|
<img src="https://via.placeholder.com/400x200/4facfe/ffffff?text={{ urlencode($item->nama) }}" alt="{{ $item->nama }}">
|
||||||
@endif
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -17,35 +17,15 @@
|
||||||
use App\Http\Controllers\BookingController; // ← BOOKING SYSTEM (ADMIN)
|
use App\Http\Controllers\BookingController; // ← BOOKING SYSTEM (ADMIN)
|
||||||
|
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
// LANDING PAGE - Redirect ke Admin Portal
|
// LANDING PAGE - Langsung ke Login
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return redirect()->route('admin.portal');
|
return redirect()->route('admin.login');
|
||||||
})->name('welcome');
|
})->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
|
// Shortcut URL - lebih mudah diingat
|
||||||
Route::get('/pemilik', function () {
|
Route::get('/pemilik', function () {
|
||||||
return redirect()->route('admin.portal');
|
return redirect()->route('admin.login');
|
||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
|
|
@ -85,15 +65,15 @@
|
||||||
|
|
||||||
// ========== 🆕 ACTIVITY LOG ROUTES ==========
|
// ========== 🆕 ACTIVITY LOG ROUTES ==========
|
||||||
Route::prefix('admin')->name('admin.')->group(function () {
|
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::get('/activity-logs/export', [ActivityLogController::class, 'export'])->name('activity-logs.export');
|
||||||
Route::post('/activity-logs/clear', [ActivityLogController::class, 'clear'])->name('activity-logs.clear');
|
Route::post('/activity-logs/clear', [ActivityLogController::class, 'clear'])->name('activity-logs.clear');
|
||||||
|
Route::resource('activity-logs', ActivityLogController::class)->only(['index', 'show']);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== 🆕 USER MANAGEMENT ROUTES ==========
|
// ========== 🆕 USER MANAGEMENT ROUTES ==========
|
||||||
Route::prefix('admin')->name('admin.')->group(function () {
|
Route::prefix('admin')->name('admin.')->group(function () {
|
||||||
Route::resource('users', UserManagementController::class);
|
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');
|
Route::post('/users/bulk-delete', [UserManagementController::class, 'bulkDelete'])->name('users.bulk-delete');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -186,7 +166,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
// Legacy favorite routes (untuk kompatibilitas)
|
// 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');
|
Route::post('/admin/logout', [AdminAuthController::class, 'logout'])->name('admin.logout');
|
||||||
});
|
});
|
||||||
|
|
@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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**/ ?>
|
|
||||||
|
|
@ -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**/ ?>
|
|
||||||
|
|
@ -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**/ ?>
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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**/ ?>
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -762,7 +762,7 @@
|
||||||
<?php $__currentLoopData = $recentLaundry; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $l): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
|
<?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="list-group-item px-0 py-2">
|
||||||
<div class="d-flex w-100 justify-content-between align-items-start">
|
<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>
|
<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;">
|
<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)); ?>
|
<i class="bi bi-geo-alt me-1"></i><?php echo e(Str::limit($l->alamat ?? '-', 25)); ?>
|
||||||
|
|
|
||||||
|
|
@ -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**/ ?>
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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-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-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);
|
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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**/ ?>
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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**/ ?>
|
|
||||||
|
|
@ -174,7 +174,6 @@
|
||||||
|
|
||||||
<div class="auth-footer">
|
<div class="auth-footer">
|
||||||
<p>Belum punya akun bisnis? <a href="<?php echo e(route('admin.register')); ?>">Daftar Sebagai Pemilik</a></p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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**/ ?>
|
|
||||||
|
|
@ -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**/ ?>
|
|
||||||
|
|
@ -1,14 +1,27 @@
|
||||||
class AppConfig {
|
class AppConfig {
|
||||||
// Base URL - Ganti sesuai environment
|
// ============================================================================
|
||||||
// Windows Desktop: http://localhost:8000
|
// 🔧 BASE URL CONFIGURATION - AUTO-DETECTED AT RUNTIME
|
||||||
// Android Emulator: http://10.0.2.2:8000
|
// ============================================================================
|
||||||
// iOS Simulator: http://localhost:8000
|
// URL ini otomatis dideteksi saat app startup via ServerDiscoveryService.
|
||||||
// Real Device: http://192.168.18.16:8000 (IP komputer Anda)
|
// Fallback default: IP terakhir yang berhasil terhubung.
|
||||||
|
//
|
||||||
|
// ✅ Tidak perlu update manual lagi!
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
// CATATAN: Untuk real device, ganti IP sesuai dengan IP komputer Anda
|
// Default fallback (dipakai jika auto-detect gagal)
|
||||||
// Cek IP dengan: ipconfig (di Windows) atau ifconfig (di Linux/Mac)
|
static const String _defaultServer = 'http://192.168.1.154:8000';
|
||||||
static const String baseUrl = 'http://192.168.18.16:8000/api';
|
|
||||||
static const String storageUrl = 'http://192.168.18.16:8000/storage';
|
// 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
|
// Timeouts
|
||||||
static const Duration connectionTimeout = Duration(seconds: 10);
|
static const Duration connectionTimeout = Duration(seconds: 10);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -210,24 +210,34 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
final result = await _authService.login(
|
try {
|
||||||
email: _emailController.text.trim(),
|
final result = await _authService.login(
|
||||||
password: _passwordController.text,
|
email: _emailController.text.trim(),
|
||||||
);
|
password: _passwordController.text,
|
||||||
|
|
||||||
setState(() => _isLoading = false);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
if (result['success']) {
|
|
||||||
Navigator.pushReplacement(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (context) => const ImprovedHomeScreen()),
|
|
||||||
);
|
);
|
||||||
} 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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(result['message'] ?? 'Login gagal'),
|
content: Text('Terjadi kesalahan: $e'),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||||
import 'login.dart';
|
import 'login.dart';
|
||||||
import 'screens/improved_home_screen.dart';
|
import 'screens/improved_home_screen.dart';
|
||||||
import 'services/auth_service.dart';
|
import 'services/auth_service.dart';
|
||||||
|
import 'services/server_discovery_service.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
@ -110,6 +111,7 @@ class _SplashScreenState extends State<SplashScreen>
|
||||||
late AnimationController _animController;
|
late AnimationController _animController;
|
||||||
late Animation<double> _fadeAnim;
|
late Animation<double> _fadeAnim;
|
||||||
late Animation<double> _scaleAnim;
|
late Animation<double> _scaleAnim;
|
||||||
|
String _statusText = 'Memulai aplikasi...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -135,8 +137,16 @@ class _SplashScreenState extends State<SplashScreen>
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkAuth() async {
|
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 _authService.loadToken();
|
||||||
await Future.delayed(const Duration(milliseconds: 1600));
|
await Future.delayed(const Duration(milliseconds: 400));
|
||||||
|
|
||||||
if (!mounted) return;
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import '../config/app_config.dart';
|
||||||
|
|
||||||
class Kontrakan {
|
class Kontrakan {
|
||||||
final int id;
|
final int id;
|
||||||
final String nama;
|
final String nama;
|
||||||
|
|
@ -108,8 +110,7 @@ class Kontrakan {
|
||||||
if (foto!.startsWith('http')) {
|
if (foto!.startsWith('http')) {
|
||||||
return foto!;
|
return foto!;
|
||||||
}
|
}
|
||||||
const String baseUrl = 'http://192.168.18.16:8000';
|
return '${AppConfig.serverUrl}/uploads/Kontrakan/$foto';
|
||||||
return '$baseUrl/uploads/Kontrakan/$foto';
|
|
||||||
}
|
}
|
||||||
// 2. Fall back to galeri items if foto is not set
|
// 2. Fall back to galeri items if foto is not set
|
||||||
if (galeri.isNotEmpty) {
|
if (galeri.isNotEmpty) {
|
||||||
|
|
@ -121,8 +122,7 @@ class Kontrakan {
|
||||||
return primary.foto;
|
return primary.foto;
|
||||||
}
|
}
|
||||||
if (primary.foto.isNotEmpty) {
|
if (primary.foto.isNotEmpty) {
|
||||||
const String baseUrl = 'http://192.168.18.16:8000';
|
return '${AppConfig.serverUrl}/uploads/Kontrakan/${primary.foto}';
|
||||||
return '$baseUrl/uploads/Kontrakan/${primary.foto}';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 'https://via.placeholder.com/300';
|
return 'https://via.placeholder.com/300';
|
||||||
|
|
@ -168,7 +168,6 @@ class Galeri {
|
||||||
return foto;
|
return foto;
|
||||||
}
|
}
|
||||||
// Build full URL from uploads
|
// Build full URL from uploads
|
||||||
const String baseUrl = 'http://192.168.18.16:8000';
|
return '${AppConfig.serverUrl}/uploads/Kontrakan/$foto';
|
||||||
return '$baseUrl/uploads/Kontrakan/$foto';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import '../config/app_config.dart';
|
||||||
|
|
||||||
class Laundry {
|
class Laundry {
|
||||||
final int id;
|
final int id;
|
||||||
final String nama;
|
final String nama;
|
||||||
|
|
@ -127,8 +129,7 @@ class Laundry {
|
||||||
if (foto!.startsWith('http')) {
|
if (foto!.startsWith('http')) {
|
||||||
return foto!;
|
return foto!;
|
||||||
}
|
}
|
||||||
const String baseUrl = 'http://192.168.18.16:8000';
|
return '${AppConfig.serverUrl}/uploads/Laundry/$foto';
|
||||||
return '$baseUrl/uploads/Laundry/$foto';
|
|
||||||
}
|
}
|
||||||
// 2. Fall back to galeri items if foto is not set
|
// 2. Fall back to galeri items if foto is not set
|
||||||
if (galeri.isNotEmpty) {
|
if (galeri.isNotEmpty) {
|
||||||
|
|
@ -140,8 +141,7 @@ class Laundry {
|
||||||
return primary.foto;
|
return primary.foto;
|
||||||
}
|
}
|
||||||
if (primary.foto.isNotEmpty) {
|
if (primary.foto.isNotEmpty) {
|
||||||
const String baseUrl = 'http://192.168.18.16:8000';
|
return '${AppConfig.serverUrl}/uploads/Laundry/${primary.foto}';
|
||||||
return '$baseUrl/uploads/Laundry/${primary.foto}';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 'https://via.placeholder.com/300';
|
return 'https://via.placeholder.com/300';
|
||||||
|
|
@ -209,7 +209,6 @@ class GaleriLaundry {
|
||||||
return foto;
|
return foto;
|
||||||
}
|
}
|
||||||
// Build full URL from uploads
|
// Build full URL from uploads
|
||||||
const String baseUrl = 'http://192.168.18.16:8000';
|
return '${AppConfig.serverUrl}/uploads/Laundry/$foto';
|
||||||
return '$baseUrl/uploads/Laundry/$foto';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_spinkit/flutter_spinkit.dart';
|
||||||
import 'login.dart';
|
import 'login.dart';
|
||||||
import 'screens/mobile_home_screen.dart';
|
import 'screens/improved_home_screen.dart';
|
||||||
import 'services/auth_service.dart';
|
import 'services/auth_service.dart';
|
||||||
|
|
||||||
class RegisterScreen extends StatefulWidget {
|
class RegisterScreen extends StatefulWidget {
|
||||||
|
|
@ -11,6 +12,7 @@ class RegisterScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RegisterScreenState extends State<RegisterScreen> {
|
class _RegisterScreenState extends State<RegisterScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _nameController = TextEditingController();
|
final _nameController = TextEditingController();
|
||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
|
|
@ -35,6 +37,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
|
// Header
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.fromLTRB(8, 0, 16, 24),
|
padding: const EdgeInsets.fromLTRB(8, 0, 16, 24),
|
||||||
|
|
@ -53,15 +56,13 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Align(
|
||||||
children: [
|
alignment: Alignment.centerLeft,
|
||||||
IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_rounded, color: Colors.white),
|
icon: const Icon(Icons.arrow_back_rounded, color: Colors.white),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.all(18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -103,25 +104,38 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Form
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
|
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
|
||||||
child: Column(
|
child: Form(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
key: _formKey,
|
||||||
children: [
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
_buildTextField(
|
_buildTextField(
|
||||||
label: 'Nama',
|
label: 'Nama',
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
prefixIcon: Icons.person_outline,
|
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(
|
_buildTextField(
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
controller: _emailController,
|
controller: _emailController,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
prefixIcon: Icons.email_outlined,
|
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(
|
_buildTextField(
|
||||||
label: 'Password',
|
label: 'Password',
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
|
|
@ -134,14 +148,15 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||||
: Icons.visibility_off_outlined,
|
: Icons.visibility_off_outlined,
|
||||||
color: const Color(0xFF1565C0),
|
color: const Color(0xFF1565C0),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
||||||
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(
|
_buildTextField(
|
||||||
label: 'Konfirmasi Password',
|
label: 'Konfirmasi Password',
|
||||||
controller: _confirmPasswordController,
|
controller: _confirmPasswordController,
|
||||||
|
|
@ -154,48 +169,32 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||||
: Icons.visibility_off_outlined,
|
: Icons.visibility_off_outlined,
|
||||||
color: const Color(0xFF1565C0),
|
color: const Color(0xFF1565C0),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
|
||||||
setState(() {
|
|
||||||
_obscureConfirmPassword = !_obscureConfirmPassword;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
validator: (v) {
|
||||||
|
if (v != _passwordController.text) return 'Password tidak cocok';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
|
|
||||||
|
// Tombol Daftar
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: _buildActionButton(
|
||||||
|
label: 'DAFTAR',
|
||||||
onPressed: _isLoading ? null : _handleRegister,
|
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),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Link ke login
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -206,9 +205,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => Navigator.pushReplacement(
|
onTap: () => Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||||
builder: (context) => const LoginScreen(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Masuk',
|
'Masuk',
|
||||||
|
|
@ -226,9 +223,10 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
);
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTextField({
|
Widget _buildTextField({
|
||||||
|
|
@ -238,6 +236,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||||
bool obscureText = false,
|
bool obscureText = false,
|
||||||
IconData? prefixIcon,
|
IconData? prefixIcon,
|
||||||
Widget? suffixIcon,
|
Widget? suffixIcon,
|
||||||
|
String? Function(String?)? validator,
|
||||||
}) {
|
}) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -251,10 +250,11 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
keyboardType: keyboardType,
|
keyboardType: keyboardType,
|
||||||
obscureText: obscureText,
|
obscureText: obscureText,
|
||||||
|
validator: validator,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
prefixIcon: prefixIcon != null
|
prefixIcon: prefixIcon != null
|
||||||
? Icon(prefixIcon, color: const Color(0xFF1565C0), size: 20)
|
? Icon(prefixIcon, color: const Color(0xFF1565C0), size: 20)
|
||||||
|
|
@ -274,6 +274,14 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
borderSide: const BorderSide(color: Color(0xFF1565C0), width: 1.5),
|
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),
|
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 {
|
Future<void> _handleRegister() async {
|
||||||
final name = _nameController.text.trim();
|
if (!_formKey.currentState!.validate()) return;
|
||||||
final email = _emailController.text.trim();
|
|
||||||
final password = _passwordController.text;
|
|
||||||
final confirmPassword = _confirmPasswordController.text;
|
|
||||||
|
|
||||||
if (name.isEmpty) {
|
setState(() => _isLoading = true);
|
||||||
_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;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await _authService.register(
|
final result = await _authService.register(
|
||||||
name: name,
|
name: _nameController.text.trim(),
|
||||||
email: email,
|
email: _emailController.text.trim(),
|
||||||
password: password,
|
password: _passwordController.text,
|
||||||
passwordConfirmation: confirmPassword,
|
passwordConfirmation: _confirmPasswordController.text,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (result['success'] == true) {
|
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));
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (context) => const MobileHomeScreen()),
|
MaterialPageRoute(builder: (_) => const ImprovedHomeScreen()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
final message = result['message'] ?? 'Registrasi gagal';
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
_showError(message);
|
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) {
|
} catch (e) {
|
||||||
_showError('Error: $e');
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
_isLoading = false;
|
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (picked != null) {
|
if (picked != null && mounted) {
|
||||||
setState(() => _tanggalMulai = picked);
|
setState(() => _tanggalMulai = picked);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -118,15 +118,25 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
|
|
||||||
if (source == null) return;
|
if (source == null) return;
|
||||||
|
|
||||||
final picked = await _imagePicker.pickImage(
|
try {
|
||||||
source: source,
|
final picked = await _imagePicker.pickImage(
|
||||||
maxWidth: 1920,
|
source: source,
|
||||||
maxHeight: 1920,
|
maxWidth: 1920,
|
||||||
imageQuality: 85,
|
maxHeight: 1920,
|
||||||
);
|
imageQuality: 85,
|
||||||
|
);
|
||||||
|
|
||||||
if (picked != null) {
|
if (picked != null && mounted) {
|
||||||
setState(() => _paymentProofImage = File(picked.path));
|
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);
|
setState(() => _isSubmitting = true);
|
||||||
|
|
||||||
final result = await _bookingService.createBooking(
|
try {
|
||||||
kontrakanId: widget.kontrakan.id,
|
final result = await _bookingService.createBooking(
|
||||||
tanggalMulai: _tanggalMulai!,
|
kontrakanId: widget.kontrakan.id,
|
||||||
durasiBulan: _durasiBulan,
|
tanggalMulai: _tanggalMulai!,
|
||||||
catatan: _catatanController.text.isNotEmpty
|
durasiBulan: _durasiBulan,
|
||||||
? _catatanController.text
|
catatan: _catatanController.text.isNotEmpty
|
||||||
: null,
|
? _catatanController.text
|
||||||
paymentProof: _paymentProofImage,
|
: null,
|
||||||
);
|
paymentProof: _paymentProofImage,
|
||||||
|
);
|
||||||
|
|
||||||
setState(() => _isSubmitting = false);
|
if (!mounted) return;
|
||||||
|
setState(() => _isSubmitting = false);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (result['success'] == true) {
|
||||||
|
|
||||||
if (result['success'] == true) {
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
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) {
|
Widget _buildConfirmRow(String label, String value) {
|
||||||
|
|
|
||||||
|
|
@ -38,16 +38,32 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
|
|
||||||
Future<void> _loadBookings() async {
|
Future<void> _loadBookings() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
final bookings = await _bookingService.getBookingHistory();
|
try {
|
||||||
setState(() {
|
final bookings = await _bookingService.getBookingHistory();
|
||||||
_activeBookings = bookings
|
if (!mounted) return;
|
||||||
.where((b) => b.status == 'confirmed' || b.status == 'pending')
|
setState(() {
|
||||||
.toList();
|
_activeBookings = bookings
|
||||||
_pastBookings = bookings
|
.where((b) => b.status == 'confirmed' || b.status == 'pending')
|
||||||
.where((b) => b.status == 'completed' || b.status == 'cancelled')
|
.toList();
|
||||||
.toList();
|
_pastBookings = bookings
|
||||||
_isLoading = false;
|
.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 {
|
Future<void> _cancelBooking(int bookingId) async {
|
||||||
|
|
@ -91,8 +107,9 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (confirm != true) return;
|
if (confirm != true) return;
|
||||||
final result = await _bookingService.cancelBooking(bookingId);
|
try {
|
||||||
if (mounted) {
|
final result = await _bookingService.cancelBooking(bookingId);
|
||||||
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Row(
|
content: Row(
|
||||||
|
|
@ -119,6 +136,17 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (result['success'] == true) _loadBookings();
|
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 (picked == null) return;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _uploadingBookingId = booking.id);
|
setState(() => _uploadingBookingId = booking.id);
|
||||||
final result = await _bookingService.uploadPaymentProof(
|
try {
|
||||||
booking.id,
|
final result = await _bookingService.uploadPaymentProof(
|
||||||
File(picked.path),
|
booking.id,
|
||||||
);
|
File(picked.path),
|
||||||
if (mounted) {
|
);
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _uploadingBookingId = null);
|
setState(() => _uploadingBookingId = null);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
|
|
@ -250,6 +280,18 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (result['success'] == true) _loadBookings();
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,27 +29,37 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
final result = await _authService.changePassword(
|
try {
|
||||||
password: _passwordController.text,
|
final result = await _authService.changePassword(
|
||||||
passwordConfirmation: _confirmController.text,
|
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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(result['message'] ?? 'Gagal mengubah password'),
|
content: Text('Terjadi kesalahan: $e'),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -39,30 +39,40 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
final result = await _authService.updateProfile(
|
try {
|
||||||
name: _nameController.text.trim(),
|
final result = await _authService.updateProfile(
|
||||||
email: _emailController.text.trim(),
|
name: _nameController.text.trim(),
|
||||||
phone: _phoneController.text.trim().isEmpty
|
email: _emailController.text.trim(),
|
||||||
? null
|
phone: _phoneController.text.trim().isEmpty
|
||||||
: _phoneController.text.trim(),
|
? 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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(result['message'] ?? 'Gagal memperbarui profil'),
|
content: Text('Terjadi kesalahan: $e'),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -109,7 +119,9 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||||
radius: 45,
|
radius: 45,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.user.name.substring(0, 1).toUpperCase(),
|
widget.user.name.isNotEmpty
|
||||||
|
? widget.user.name.substring(0, 1).toUpperCase()
|
||||||
|
: 'U',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.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/kontrakan.dart';
|
||||||
import '../models/laundry.dart';
|
import '../models/laundry.dart';
|
||||||
import '../services/favorite_service.dart';
|
import '../services/favorite_service.dart';
|
||||||
|
|
@ -36,23 +36,46 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
Future<void> _loadFavorites() async {
|
Future<void> _loadFavorites() async {
|
||||||
setState(() => _isLoading = true);
|
if (!mounted) return;
|
||||||
// Clear image cache agar foto terbaru selalu dimuat
|
setState(() {
|
||||||
await DefaultCacheManager().emptyCache();
|
_isLoading = true;
|
||||||
PaintingBinding.instance.imageCache.clear();
|
_errorMessage = null;
|
||||||
PaintingBinding.instance.imageCache.clearLiveImages();
|
});
|
||||||
try {
|
try {
|
||||||
final result = await _favoriteService.getFavoritesWithModels();
|
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) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_kontrakanFavorites = (result['kontrakan'] as List<Kontrakan>?) ?? [];
|
_errorMessage = 'Terjadi kesalahan: $e';
|
||||||
_laundryFavorites = (result['laundry'] as List<Laundry>?) ?? [];
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
if (mounted) setState(() => _isLoading = false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,15 +116,18 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (confirm != true) return;
|
if (confirm != true) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
Map<String, dynamic> result;
|
try {
|
||||||
if (type == 'kontrakan') {
|
Map<String, dynamic> result;
|
||||||
result = await _favoriteService.toggleKontrakanFavorite(itemId);
|
if (type == 'kontrakan') {
|
||||||
} else {
|
result = await _favoriteService.toggleKontrakanFavorite(itemId);
|
||||||
result = await _favoriteService.toggleLaundryFavorite(itemId);
|
} else {
|
||||||
}
|
result = await _favoriteService.toggleLaundryFavorite(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
if (mounted && result['success'] == true) {
|
if (!mounted) return;
|
||||||
|
if (result['success'] == true) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Row(
|
content: Row(
|
||||||
|
|
@ -123,7 +149,19 @@ class _FavoritesScreenState extends State<FavoritesScreen>
|
||||||
margin: const EdgeInsets.all(16),
|
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),
|
color: Color(0xFF1565C0),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: TabBarView(
|
: _errorMessage != null
|
||||||
controller: _tabController,
|
? Center(
|
||||||
children: [_buildKontrakanList(), _buildLaundryList()],
|
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()],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,16 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
||||||
_loadData();
|
_loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadData() async {
|
Future<void> _loadData({bool clearCache = false}) async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
// Clear image cache agar foto terbaru dari server selalu dimuat
|
// Clear image cache hanya saat user eksplisit refresh (pull-to-refresh)
|
||||||
await DefaultCacheManager().emptyCache();
|
if (clearCache) {
|
||||||
PaintingBinding.instance.imageCache.clear();
|
await DefaultCacheManager().emptyCache();
|
||||||
PaintingBinding.instance.imageCache.clearLiveImages();
|
PaintingBinding.instance.imageCache.clear();
|
||||||
|
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||||
|
}
|
||||||
await Future.wait([_loadKontrakan(), _loadLaundry(), _loadFavoriteIds()]);
|
await Future.wait([_loadKontrakan(), _loadLaundry(), _loadFavoriteIds()]);
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,6 +59,7 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
||||||
try {
|
try {
|
||||||
if (!_authService.isAuthenticated) return;
|
if (!_authService.isAuthenticated) return;
|
||||||
final ids = await _favoriteService.getFavoriteIds();
|
final ids = await _favoriteService.getFavoriteIds();
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_favKontrakanIds = (ids['kontrakan'] ?? []).toSet();
|
_favKontrakanIds = (ids['kontrakan'] ?? []).toSet();
|
||||||
_favLaundryIds = (ids['laundry'] ?? []).toSet();
|
_favLaundryIds = (ids['laundry'] ?? []).toSet();
|
||||||
|
|
@ -66,6 +70,14 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleKontrakanFav(int id) async {
|
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);
|
final wasFav = _favKontrakanIds.contains(id);
|
||||||
setState(() {
|
setState(() {
|
||||||
if (wasFav) {
|
if (wasFav) {
|
||||||
|
|
@ -74,19 +86,44 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
||||||
_favKontrakanIds.add(id);
|
_favKontrakanIds.add(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
final result = await _favoriteService.toggleKontrakanFavorite(id);
|
try {
|
||||||
if (result['success'] != true && mounted) {
|
final result = await _favoriteService.toggleKontrakanFavorite(id);
|
||||||
setState(() {
|
if (result['success'] != true && mounted) {
|
||||||
if (wasFav) {
|
setState(() {
|
||||||
_favKontrakanIds.add(id);
|
if (wasFav) {
|
||||||
} else {
|
_favKontrakanIds.add(id);
|
||||||
_favKontrakanIds.remove(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 {
|
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);
|
final wasFav = _favLaundryIds.contains(id);
|
||||||
setState(() {
|
setState(() {
|
||||||
if (wasFav) {
|
if (wasFav) {
|
||||||
|
|
@ -95,21 +132,39 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
||||||
_favLaundryIds.add(id);
|
_favLaundryIds.add(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
final result = await _favoriteService.toggleLaundryFavorite(id);
|
try {
|
||||||
if (result['success'] != true && mounted) {
|
final result = await _favoriteService.toggleLaundryFavorite(id);
|
||||||
setState(() {
|
if (result['success'] != true && mounted) {
|
||||||
if (wasFav) {
|
setState(() {
|
||||||
_favLaundryIds.add(id);
|
if (wasFav) {
|
||||||
} else {
|
_favLaundryIds.add(id);
|
||||||
_favLaundryIds.remove(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 {
|
Future<void> _loadKontrakan() async {
|
||||||
try {
|
try {
|
||||||
final list = await _kontrakanService.getKontrakan();
|
final list = await _kontrakanService.getKontrakan();
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _kontrakanList = list.take(6).toList());
|
setState(() => _kontrakanList = list.take(6).toList());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Error loading kontrakan silently
|
// Error loading kontrakan silently
|
||||||
|
|
@ -119,6 +174,7 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
||||||
Future<void> _loadLaundry() async {
|
Future<void> _loadLaundry() async {
|
||||||
try {
|
try {
|
||||||
final list = await _laundryService.getLaundry();
|
final list = await _laundryService.getLaundry();
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _laundryList = list.take(6).toList());
|
setState(() => _laundryList = list.take(6).toList());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Error loading laundry silently
|
// Error loading laundry silently
|
||||||
|
|
@ -154,7 +210,7 @@ class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
|
||||||
// ────────────── HOME CONTENT ──────────────
|
// ────────────── HOME CONTENT ──────────────
|
||||||
Widget _buildHomeContent() {
|
Widget _buildHomeContent() {
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: _loadData,
|
onRefresh: () => _loadData(clearCache: true),
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// ── Gradient Header ──
|
// ── 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 ──────────────
|
// ────────────── SERVICE TILE INTEGRATED ──────────────
|
||||||
Widget _buildServiceTileIntegrated({
|
Widget _buildServiceTileIntegrated({
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ import '../services/auth_service.dart';
|
||||||
import '../services/favorite_service.dart';
|
import '../services/favorite_service.dart';
|
||||||
import 'booking_form_screen.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 {
|
class KontrakanDetailScreen extends StatefulWidget {
|
||||||
final Kontrakan kontrakan;
|
final Kontrakan kontrakan;
|
||||||
|
|
||||||
|
|
@ -17,11 +21,7 @@ class KontrakanDetailScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
double? userLat;
|
double? distance; // jarak dari kampus Polije ke kontrakan ini
|
||||||
double? userLng;
|
|
||||||
double? distance;
|
|
||||||
bool isLoadingLocation = false;
|
|
||||||
String? locationError;
|
|
||||||
bool _isFavorite = false;
|
bool _isFavorite = false;
|
||||||
bool _isFavLoading = false;
|
bool _isFavLoading = false;
|
||||||
final _favoriteService = FavoriteService();
|
final _favoriteService = FavoriteService();
|
||||||
|
|
@ -30,53 +30,96 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_checkFavorite();
|
_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 {
|
Future<void> _checkFavorite() async {
|
||||||
final result = await _favoriteService.isKontrakanFavorite(
|
try {
|
||||||
widget.kontrakan.id,
|
final result = await _favoriteService.isKontrakanFavorite(
|
||||||
);
|
widget.kontrakan.id,
|
||||||
if (mounted) setState(() => _isFavorite = result);
|
);
|
||||||
|
if (mounted) setState(() => _isFavorite = result);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Check favorite error: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleFavorite() async {
|
Future<void> _toggleFavorite() async {
|
||||||
setState(() => _isFavLoading = true);
|
setState(() => _isFavLoading = true);
|
||||||
final result = await _favoriteService.toggleKontrakanFavorite(
|
try {
|
||||||
widget.kontrakan.id,
|
final result = await _favoriteService.toggleKontrakanFavorite(
|
||||||
);
|
widget.kontrakan.id,
|
||||||
if (mounted) {
|
);
|
||||||
setState(() {
|
if (!mounted) return;
|
||||||
_isFavLoading = false;
|
|
||||||
if (result['success'] == true) {
|
if (result['success'] == true) {
|
||||||
|
setState(() {
|
||||||
_isFavorite = result['isFavorite'] ?? !_isFavorite;
|
_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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Row(
|
content: Text('Gagal: $e'),
|
||||||
children: [
|
backgroundColor: Colors.red[700],
|
||||||
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,
|
behavior: SnackBarBehavior.floating,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
margin: const EdgeInsets.all(16),
|
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(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.purple.withValues(alpha: 0.1),
|
color: const Color(0xFF1565C0).withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Colors.purple.withValues(alpha: 0.3),
|
color: const Color(0xFF1565C0).withValues(alpha: 0.3),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -434,114 +477,83 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
Row(
|
const Icon(Icons.school_rounded, color: Color(0xFF1565C0), size: 22),
|
||||||
children: [
|
const SizedBox(width: 10),
|
||||||
Icon(Icons.location_on, color: Colors.purple, size: 24),
|
const Text(
|
||||||
const SizedBox(width: 12),
|
'Jarak dari Kampus Polije',
|
||||||
Text(
|
style: TextStyle(
|
||||||
'Deteksi Lokasi Saya',
|
fontSize: 15,
|
||||||
style: const TextStyle(
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 16,
|
color: Color(0xFF1565C0),
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (isLoadingLocation)
|
|
||||||
SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.purple),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (distance != null) ...[
|
Container(
|
||||||
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(
|
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 44,
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
child: ElevatedButton.icon(
|
decoration: BoxDecoration(
|
||||||
onPressed: isLoadingLocation ? null : _detectLocation,
|
color: Colors.white,
|
||||||
icon: Icon(
|
borderRadius: BorderRadius.circular(10),
|
||||||
isLoadingLocation ? Icons.hourglass_bottom : Icons.my_location,
|
border: Border.all(
|
||||||
size: 20,
|
color: const Color(0xFF1565C0).withValues(alpha: 0.15),
|
||||||
),
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,48 +30,78 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkFavorite() async {
|
Future<void> _checkFavorite() async {
|
||||||
final result = await _favoriteService.isLaundryFavorite(widget.laundry.id);
|
try {
|
||||||
if (mounted) setState(() => _isFavorite = result);
|
final result = await _favoriteService.isLaundryFavorite(widget.laundry.id);
|
||||||
|
if (mounted) setState(() => _isFavorite = result);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Check favorite error: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleFavorite() async {
|
Future<void> _toggleFavorite() async {
|
||||||
setState(() => _isFavLoading = true);
|
setState(() => _isFavLoading = true);
|
||||||
final result = await _favoriteService.toggleLaundryFavorite(
|
try {
|
||||||
widget.laundry.id,
|
final result = await _favoriteService.toggleLaundryFavorite(
|
||||||
);
|
widget.laundry.id,
|
||||||
if (mounted) {
|
);
|
||||||
setState(() {
|
if (!mounted) return;
|
||||||
_isFavLoading = false;
|
|
||||||
if (result['success'] == true) {
|
if (result['success'] == true) {
|
||||||
|
setState(() {
|
||||||
_isFavorite = result['isFavorite'] ?? !_isFavorite;
|
_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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Row(
|
content: Text('Gagal: $e'),
|
||||||
children: [
|
backgroundColor: Colors.red[700],
|
||||||
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,
|
behavior: SnackBarBehavior.floating,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
margin: const EdgeInsets.all(16),
|
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 locationService = LocationService();
|
||||||
final isEnabled = await locationService.isLocationServiceEnabled();
|
final isEnabled = await locationService.isLocationServiceEnabled();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
setState(() {
|
setState(() {
|
||||||
locationError = 'Layanan lokasi tidak aktif';
|
locationError = 'Layanan lokasi tidak aktif';
|
||||||
|
|
@ -529,6 +560,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
|
|
||||||
final position = await locationService.getCurrentLocation();
|
final position = await locationService.getCurrentLocation();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
if (position != null) {
|
if (position != null) {
|
||||||
final dist = LocationService.calculateDistance(
|
final dist = LocationService.calculateDistance(
|
||||||
position.latitude,
|
position.latitude,
|
||||||
|
|
@ -550,6 +582,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
locationError = 'Error: ${e.toString()}';
|
locationError = 'Error: ${e.toString()}';
|
||||||
isLoadingLocation = false;
|
isLoadingLocation = false;
|
||||||
|
|
@ -700,7 +733,9 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _launchWhatsApp(String phone) async {
|
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))) {
|
if (await canLaunchUrl(Uri.parse(url))) {
|
||||||
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,38 +33,54 @@ class _LaundryListScreenState extends State<LaundryListScreen> {
|
||||||
|
|
||||||
Future<void> _loadLaundry() async {
|
Future<void> _loadLaundry() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
final list = await _laundryService.getLaundry();
|
try {
|
||||||
setState(() {
|
final list = await _laundryService.getLaundry();
|
||||||
_laundryList = list;
|
if (!mounted) return;
|
||||||
_filteredList = list;
|
setState(() {
|
||||||
_isLoading = false;
|
_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) {
|
void _filterList(String query) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (query.isEmpty) {
|
if (query.isEmpty) {
|
||||||
_filteredList = _laundryList;
|
_filteredList = List.from(_laundryList);
|
||||||
} else {
|
} else {
|
||||||
_filteredList = _laundryList.where((laundry) {
|
_filteredList = _laundryList.where((laundry) {
|
||||||
return laundry.nama.toLowerCase().contains(query.toLowerCase()) ||
|
return laundry.nama.toLowerCase().contains(query.toLowerCase()) ||
|
||||||
laundry.alamat.toLowerCase().contains(query.toLowerCase());
|
laundry.alamat.toLowerCase().contains(query.toLowerCase());
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
_applySortInPlace(_sortBy);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _sortList(String sortBy) {
|
void _sortList(String sortBy) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_sortBy = sortBy;
|
_applySortInPlace(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));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -187,7 +203,7 @@ class _LaundryListScreenState extends State<LaundryListScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Urut: ${_sortBy == "rating" ? "Rating" : "Harga"}',
|
'Urut: ${_sortBy == "rating" ? "Rating" : _sortBy == "harga" ? "Harga" : "Jarak"}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: Colors.grey[600],
|
color: Colors.grey[600],
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,10 +3,11 @@ import 'package:http/http.dart' as http;
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import '../models/kontrakan.dart';
|
import '../models/kontrakan.dart';
|
||||||
import '../models/laundry.dart';
|
import '../models/laundry.dart';
|
||||||
|
import '../models/user.dart';
|
||||||
|
import '../services/auth_service.dart';
|
||||||
import '../widgets/kontrakan_card.dart';
|
import '../widgets/kontrakan_card.dart';
|
||||||
import '../widgets/laundry_card.dart';
|
import '../widgets/laundry_card.dart';
|
||||||
|
|
||||||
|
|
@ -28,6 +29,10 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
bool _noData =
|
bool _noData =
|
||||||
false; // true = memang tidak ada data, false = filter terlalu ketat
|
false; // true = memang tidak ada data, false = filter terlalu ketat
|
||||||
|
|
||||||
|
// User info
|
||||||
|
final _authService = AuthService();
|
||||||
|
User? _currentUser;
|
||||||
|
|
||||||
// Bobot values (percentage, total must = 100)
|
// Bobot values (percentage, total must = 100)
|
||||||
// Default: profil mahasiswa
|
// Default: profil mahasiswa
|
||||||
int _bobotHarga = 50;
|
int _bobotHarga = 50;
|
||||||
|
|
@ -38,8 +43,7 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
// Jenis layanan selection for laundry
|
// Jenis layanan selection for laundry
|
||||||
String _selectedJenisLayanan = 'reguler';
|
String _selectedJenisLayanan = 'reguler';
|
||||||
|
|
||||||
// Location values
|
// Location values untuk referensi jarak (deteksi lokasi user)
|
||||||
String _referensiJarak = 'kampus';
|
|
||||||
double? _userLatitude;
|
double? _userLatitude;
|
||||||
double? _userLongitude;
|
double? _userLongitude;
|
||||||
bool _isDetectingLocation = false;
|
bool _isDetectingLocation = false;
|
||||||
|
|
@ -48,12 +52,6 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
widget.category == 'kontrakan' ? 'Jumlah Kamar' : 'Kecepatan Layanan';
|
widget.category == 'kontrakan' ? 'Jumlah Kamar' : 'Kecepatan Layanan';
|
||||||
String get _kriteria4Label =>
|
String get _kriteria4Label =>
|
||||||
widget.category == 'kontrakan' ? 'Fasilitas' : 'Variasi Layanan';
|
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 =>
|
int get _totalBobot =>
|
||||||
_bobotHarga + _bobotJarak + _bobotKriteria3 + _bobotKriteria4;
|
_bobotHarga + _bobotJarak + _bobotKriteria3 + _bobotKriteria4;
|
||||||
|
|
@ -70,6 +68,33 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
_bobotJarak = 20;
|
_bobotJarak = 20;
|
||||||
_bobotKriteria3 = 15;
|
_bobotKriteria3 = 15;
|
||||||
_bobotKriteria4 = 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
|
/// Auto-balance: when one bobot changes, redistribute the remaining
|
||||||
|
|
@ -123,17 +148,15 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
bobots[otherIdx[i]] = newOther[i];
|
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);
|
int total = bobots.reduce((a, b) => a + b);
|
||||||
if (total != 100) {
|
if (total != 100) {
|
||||||
int diff = 100 - total;
|
int diff = 100 - total;
|
||||||
int adjustIdx = otherIdx.reduce(
|
int adjustIdx = otherIdx.reduce(
|
||||||
(a, b) => bobots[a] >= bobots[b] ? a : b,
|
(a, b) => bobots[a] >= bobots[b] ? a : b,
|
||||||
);
|
);
|
||||||
int adjusted = (bobots[adjustIdx] + diff).clamp(10, 70);
|
bobots[adjustIdx] = (bobots[adjustIdx] + diff).clamp(5, 75);
|
||||||
// Snap to nearest multiple of 5
|
|
||||||
adjusted = ((adjusted / 5).round() * 5).clamp(10, 70);
|
|
||||||
bobots[adjustIdx] = adjusted;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_bobotHarga = bobots[0];
|
_bobotHarga = bobots[0];
|
||||||
|
|
@ -168,17 +191,31 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
return;
|
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(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
_noData = false;
|
_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'
|
final endpoint = widget.category == 'kontrakan'
|
||||||
? '/saw/calculate/kontrakan'
|
? '/saw/calculate/kontrakan'
|
||||||
: '/saw/calculate/laundry';
|
: '/saw/calculate/laundry';
|
||||||
|
|
@ -197,11 +234,11 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
bodyParams['jenis_layanan'] = _selectedJenisLayanan;
|
bodyParams['jenis_layanan'] = _selectedJenisLayanan;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.category == 'laundry' && _referensiJarak == 'user') {
|
if (widget.category == 'laundry' &&
|
||||||
if (_userLatitude != null && _userLongitude != null) {
|
_userLatitude != null &&
|
||||||
bodyParams['user_lat'] = _userLatitude;
|
_userLongitude != null) {
|
||||||
bodyParams['user_lng'] = _userLongitude;
|
bodyParams['user_lat'] = _userLatitude;
|
||||||
}
|
bodyParams['user_lng'] = _userLongitude;
|
||||||
}
|
}
|
||||||
|
|
||||||
final response = await http
|
final response = await http
|
||||||
|
|
@ -215,6 +252,8 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 15));
|
.timeout(const Duration(seconds: 15));
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = json.decode(response.body);
|
final data = json.decode(response.body);
|
||||||
if (data['success'] == true) {
|
if (data['success'] == true) {
|
||||||
|
|
@ -245,35 +284,37 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('SAW API Error: $e');
|
debugPrint('SAW API Error: $e');
|
||||||
debugPrint('URL: ${AppConfig.baseUrl}$endpoint');
|
debugPrint('URL: ${AppConfig.baseUrl}$endpoint');
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage =
|
_errorMessage =
|
||||||
'Tidak dapat terhubung ke server (${AppConfig.baseUrl}). Periksa koneksi internet Anda dan coba lagi.\n\nDetail: $e';
|
'Tidak dapat terhubung ke server (${AppConfig.baseUrl}). Periksa koneksi internet Anda dan coba lagi.\n\nDetail: $e';
|
||||||
_hasCalculated = true;
|
_hasCalculated = true;
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setState(() {
|
if (mounted) {
|
||||||
_isLoading = false;
|
setState(() {
|
||||||
});
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _detectUserLocation() async {
|
Future<void> _detectUserLocation() async {
|
||||||
|
// Guard: prevent re-entrant / duplicate calls
|
||||||
|
if (_isDetectingLocation) return;
|
||||||
|
|
||||||
setState(() => _isDetectingLocation = true);
|
setState(() => _isDetectingLocation = true);
|
||||||
try {
|
try {
|
||||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||||
if (!serviceEnabled) {
|
if (!serviceEnabled) {
|
||||||
if (mounted) {
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Aktifkan GPS Anda terlebih dahulu'),
|
content: Text('Aktifkan GPS Anda terlebih dahulu'),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
setState(() => _isDetectingLocation = false);
|
||||||
setState(() {
|
|
||||||
_referensiJarak = 'kampus';
|
|
||||||
_isDetectingLocation = false;
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,71 +322,62 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
if (permission == LocationPermission.denied) {
|
if (permission == LocationPermission.denied) {
|
||||||
permission = await Geolocator.requestPermission();
|
permission = await Geolocator.requestPermission();
|
||||||
if (permission == LocationPermission.denied) {
|
if (permission == LocationPermission.denied) {
|
||||||
if (mounted) {
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Izin lokasi ditolak'),
|
content: Text('Izin lokasi ditolak'),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
setState(() => _isDetectingLocation = false);
|
||||||
setState(() {
|
|
||||||
_referensiJarak = 'kampus';
|
|
||||||
_isDetectingLocation = false;
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (permission == LocationPermission.deniedForever) {
|
if (permission == LocationPermission.deniedForever) {
|
||||||
if (mounted) {
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'Izin lokasi ditolak permanen. Ubah di pengaturan.',
|
'Izin lokasi ditolak permanen. Ubah di pengaturan.',
|
||||||
),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
),
|
||||||
);
|
backgroundColor: Colors.red,
|
||||||
}
|
),
|
||||||
setState(() {
|
);
|
||||||
_referensiJarak = 'kampus';
|
setState(() => _isDetectingLocation = false);
|
||||||
_isDetectingLocation = false;
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Position position = await Geolocator.getCurrentPosition(
|
Position position = await Geolocator.getCurrentPosition(
|
||||||
desiredAccuracy: LocationAccuracy.high,
|
desiredAccuracy: LocationAccuracy.high,
|
||||||
|
).timeout(
|
||||||
|
const Duration(seconds: 15),
|
||||||
|
onTimeout: () => throw TimeoutException('Deteksi lokasi timeout'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_userLatitude = position.latitude;
|
_userLatitude = position.latitude;
|
||||||
_userLongitude = position.longitude;
|
_userLongitude = position.longitude;
|
||||||
_isDetectingLocation = false;
|
_isDetectingLocation = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mounted) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
const SnackBar(
|
||||||
const SnackBar(
|
content: Text('Lokasi berhasil dideteksi!'),
|
||||||
content: Text('Lokasi berhasil dideteksi!'),
|
backgroundColor: Colors.green,
|
||||||
backgroundColor: Colors.green,
|
duration: Duration(seconds: 2),
|
||||||
duration: Duration(seconds: 2),
|
),
|
||||||
),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Gagal mendeteksi lokasi: $e'),
|
content: Text('Gagal mendeteksi lokasi: ${e.toString().replaceAll('Exception: ', '')}'),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
setState(() => _isDetectingLocation = false);
|
||||||
setState(() {
|
|
||||||
_referensiJarak = 'kampus';
|
|
||||||
_isDetectingLocation = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -390,6 +422,8 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
_buildUserInfoCard(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
_buildMethodInfoCard(),
|
_buildMethodInfoCard(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (widget.category == 'laundry') ...[
|
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() {
|
Widget _buildMethodInfoCard() {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [_categoryColor, _categoryColor.withOpacity(0.8)],
|
colors: [_categoryColor, _categoryColor.withValues(alpha: 0.8)],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
|
|
@ -902,158 +1075,114 @@ class _RecommendationScreenState extends State<RecommendationScreen> {
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.my_location, color: _categoryColor, size: 20),
|
Icon(Icons.my_location, color: _categoryColor, size: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Expanded(
|
||||||
'Referensi Jarak',
|
child: Text(
|
||||||
style: TextStyle(
|
'Lokasi Saya',
|
||||||
fontSize: 15,
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 15,
|
||||||
color: Colors.grey[800],
|
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),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Tentukan titik referensi untuk perhitungan jarak',
|
'Jarak dihitung dari lokasi Anda saat ini',
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Container(
|
if (_isDetectingLocation)
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
Container(
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.all(10),
|
||||||
color: Colors.grey[50],
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
color: Colors.blue[50],
|
||||||
border: Border.all(color: Colors.grey[200]!),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: DropdownButtonHideUnderline(
|
child: const Row(
|
||||||
child: DropdownButton<String>(
|
children: [
|
||||||
value: _referensiJarak,
|
SizedBox(
|
||||||
isExpanded: true,
|
width: 16,
|
||||||
icon: Icon(Icons.arrow_drop_down, color: _categoryColor),
|
height: 16,
|
||||||
items: [
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
DropdownMenuItem(
|
|
||||||
value: 'kampus',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.school, size: 18, color: _categoryColor),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
const Text('Dari Kampus Polije'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
SizedBox(width: 10),
|
||||||
value: 'user',
|
Text(
|
||||||
child: Row(
|
'Mendeteksi lokasi...',
|
||||||
children: [
|
style: TextStyle(fontSize: 12),
|
||||||
Icon(
|
|
||||||
Icons.location_on,
|
|
||||||
size: 18,
|
|
||||||
color: Colors.green[700],
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
const Text('Dari Lokasi Saya'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onChanged: (val) {
|
|
||||||
setState(() {
|
|
||||||
_referensiJarak = val!;
|
|
||||||
});
|
|
||||||
if (val == 'user' && _userLatitude == null)
|
|
||||||
_detectUserLocation();
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
),
|
else if (_userLatitude != null && _userLongitude != null)
|
||||||
if (_referensiJarak == 'user') ...[
|
Container(
|
||||||
const SizedBox(height: 10),
|
padding: const EdgeInsets.all(10),
|
||||||
if (_isDetectingLocation)
|
decoration: BoxDecoration(
|
||||||
Container(
|
color: Colors.green[50],
|
||||||
padding: const EdgeInsets.all(10),
|
borderRadius: BorderRadius.circular(8),
|
||||||
decoration: BoxDecoration(
|
border: Border.all(color: Colors.green[200]!),
|
||||||
color: Colors.blue[50],
|
),
|
||||||
borderRadius: BorderRadius.circular(8),
|
child: Row(
|
||||||
),
|
children: [
|
||||||
child: const Row(
|
const Icon(
|
||||||
children: [
|
Icons.check_circle,
|
||||||
SizedBox(
|
size: 16,
|
||||||
width: 16,
|
color: Colors.green,
|
||||||
height: 16,
|
),
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
const SizedBox(width: 8),
|
||||||
),
|
Expanded(
|
||||||
SizedBox(width: 10),
|
child: Text(
|
||||||
Text(
|
'Lokasi terdeteksi: ${_userLatitude!.toStringAsFixed(6)}, ${_userLongitude!.toStringAsFixed(6)}',
|
||||||
'Mendeteksi lokasi...',
|
style: TextStyle(
|
||||||
style: TextStyle(fontSize: 12),
|
fontSize: 11,
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
color: Colors.green[700],
|
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: [
|
children: [
|
||||||
_buildBobotSummary(),
|
_buildBobotSummary(),
|
||||||
if (widget.category == 'laundry' &&
|
if (widget.category == 'laundry' &&
|
||||||
_referensiJarak == 'user' &&
|
|
||||||
_userLatitude != null)
|
_userLatitude != null)
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.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/kontrakan.dart';
|
||||||
import '../models/laundry.dart';
|
import '../models/laundry.dart';
|
||||||
import '../services/kontrakan_service.dart';
|
import '../services/kontrakan_service.dart';
|
||||||
|
|
@ -50,20 +49,22 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||||
|
|
||||||
Future<void> _loadData() async {
|
Future<void> _loadData() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
// Clear image cache agar data terbaru dari server selalu dimuat
|
try {
|
||||||
await DefaultCacheManager().emptyCache();
|
final kontrakan = await _kontrakanService.getKontrakan();
|
||||||
PaintingBinding.instance.imageCache.clear();
|
final laundry = await _laundryService.getLaundry();
|
||||||
PaintingBinding.instance.imageCache.clearLiveImages();
|
if (!mounted) return;
|
||||||
final kontrakan = await _kontrakanService.getKontrakan();
|
setState(() {
|
||||||
final laundry = await _laundryService.getLaundry();
|
_allKontrakan = kontrakan;
|
||||||
setState(() {
|
_filteredKontrakan = kontrakan;
|
||||||
_allKontrakan = kontrakan;
|
_allLaundry = laundry;
|
||||||
_filteredKontrakan = kontrakan;
|
_filteredLaundry = laundry;
|
||||||
_allLaundry = laundry;
|
_isLoading = false;
|
||||||
_filteredLaundry = laundry;
|
});
|
||||||
_isLoading = false;
|
_loadFavoriteIds();
|
||||||
});
|
} catch (e) {
|
||||||
_loadFavoriteIds();
|
if (!mounted) return;
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadFavoriteIds() async {
|
Future<void> _loadFavoriteIds() async {
|
||||||
|
|
@ -84,38 +85,48 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||||
Future<void> _toggleKontrakanFav(int id) async {
|
Future<void> _toggleKontrakanFav(int id) async {
|
||||||
final wasFav = _favKontrakanIds.contains(id);
|
final wasFav = _favKontrakanIds.contains(id);
|
||||||
setState(() {
|
setState(() {
|
||||||
if (wasFav)
|
if (wasFav) _favKontrakanIds.remove(id); else _favKontrakanIds.add(id);
|
||||||
_favKontrakanIds.remove(id);
|
|
||||||
else
|
|
||||||
_favKontrakanIds.add(id);
|
|
||||||
});
|
});
|
||||||
final result = await _favoriteService.toggleKontrakanFavorite(id);
|
try {
|
||||||
if (result['success'] != true && mounted) {
|
final result = await _favoriteService.toggleKontrakanFavorite(id);
|
||||||
setState(() {
|
if (result['success'] != true && mounted) {
|
||||||
if (wasFav)
|
setState(() {
|
||||||
_favKontrakanIds.add(id);
|
if (wasFav) _favKontrakanIds.add(id); else _favKontrakanIds.remove(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 {
|
Future<void> _toggleLaundryFav(int id) async {
|
||||||
final wasFav = _favLaundryIds.contains(id);
|
final wasFav = _favLaundryIds.contains(id);
|
||||||
setState(() {
|
setState(() {
|
||||||
if (wasFav)
|
if (wasFav) _favLaundryIds.remove(id); else _favLaundryIds.add(id);
|
||||||
_favLaundryIds.remove(id);
|
|
||||||
else
|
|
||||||
_favLaundryIds.add(id);
|
|
||||||
});
|
});
|
||||||
final result = await _favoriteService.toggleLaundryFavorite(id);
|
try {
|
||||||
if (result['success'] != true && mounted) {
|
final result = await _favoriteService.toggleLaundryFavorite(id);
|
||||||
setState(() {
|
if (result['success'] != true && mounted) {
|
||||||
if (wasFav)
|
setState(() {
|
||||||
_favLaundryIds.add(id);
|
if (wasFav) _favLaundryIds.add(id); else _favLaundryIds.remove(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();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by price
|
// Filter by price (laundry hargaKiloan is per-kg, no multiplication needed)
|
||||||
filtered = filtered.where((l) {
|
filtered = filtered.where((l) {
|
||||||
final harga = l.hargaKiloan * 10; // Approximate for 10kg
|
return l.hargaKiloan >= _priceRange.start && l.hargaKiloan <= _priceRange.end;
|
||||||
return harga >= _priceRange.start && harga <= _priceRange.end;
|
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
setState(() => _filteredLaundry = filtered);
|
setState(() => _filteredLaundry = filtered);
|
||||||
|
|
@ -280,8 +290,10 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () =>
|
onTap: () {
|
||||||
setState(() => _selectedCategory = 'Kontrakan'),
|
setState(() => _selectedCategory = 'Kontrakan');
|
||||||
|
_applyFilters();
|
||||||
|
},
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
|
@ -329,8 +341,10 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () =>
|
onTap: () {
|
||||||
setState(() => _selectedCategory = 'Laundry'),
|
setState(() => _selectedCategory = 'Laundry');
|
||||||
|
_applyLaundryFilters();
|
||||||
|
},
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,9 @@ class AuthService {
|
||||||
required String password,
|
required String password,
|
||||||
required String passwordConfirmation,
|
required String passwordConfirmation,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (_currentUser == null || _token == null) {
|
||||||
|
return {'success': false, 'message': 'Silakan login ulang'};
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final response = await http.put(
|
final response = await http.put(
|
||||||
Uri.parse('${AppConfig.baseUrl}/profile/update'),
|
Uri.parse('${AppConfig.baseUrl}/profile/update'),
|
||||||
|
|
@ -229,8 +232,8 @@ class AuthService {
|
||||||
'Authorization': 'Bearer $_token',
|
'Authorization': 'Bearer $_token',
|
||||||
},
|
},
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
'name': _currentUser?.name ?? '',
|
'name': _currentUser!.name,
|
||||||
'email': _currentUser?.email ?? '',
|
'email': _currentUser!.email,
|
||||||
'password': password,
|
'password': password,
|
||||||
'password_confirmation': passwordConfirmation,
|
'password_confirmation': passwordConfirmation,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import '../models/kontrakan.dart';
|
import '../models/kontrakan.dart';
|
||||||
|
|
@ -21,54 +22,118 @@ class FavoriteService {
|
||||||
return headers;
|
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 {
|
Future<Map<String, dynamic>> getFavorites() async {
|
||||||
|
if (!_ensureAuthenticated()) {
|
||||||
|
return {'success': false, 'message': 'Belum login', 'kontrakan': [], 'laundry': []};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await http.get(
|
final url = '${AppConfig.baseUrl}/favorites';
|
||||||
Uri.parse('${AppConfig.baseUrl}/favorites'),
|
debugPrint('[FAV] GET $url');
|
||||||
headers: _headers,
|
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);
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
if (response.statusCode == 200 && data['success'] == true) {
|
if (response.statusCode == 200 && data['success'] == true) {
|
||||||
return {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
'kontrakan': data['data']['kontrakan'] ?? [],
|
'kontrakan': data['data']?['kontrakan'] ?? [],
|
||||||
'laundry': data['data']['laundry'] ?? [],
|
'laundry': data['data']?['laundry'] ?? [],
|
||||||
};
|
};
|
||||||
} else {
|
} 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) {
|
} catch (e, stack) {
|
||||||
// Error getting favorites silently
|
debugPrint('[FAV] ❌ Exception in getFavorites: $e');
|
||||||
return {'success': false, 'kontrakan': [], 'laundry': []};
|
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 {
|
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 {
|
try {
|
||||||
final result = await getFavorites();
|
final rawKontrakan = result['kontrakan'] as List? ?? [];
|
||||||
if (result['success'] != true) return result;
|
final rawLaundry = result['laundry'] as List? ?? [];
|
||||||
|
|
||||||
final kontrakanList = (result['kontrakan'] as List)
|
debugPrint('[FAV] Parsing ${rawKontrakan.length} kontrakan, ${rawLaundry.length} laundry');
|
||||||
.map((json) => Kontrakan.fromJson(json as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final laundryList = (result['laundry'] as List)
|
final kontrakanList = <Kontrakan>[];
|
||||||
.map((json) => Laundry.fromJson(json as Map<String, dynamic>))
|
for (final json in rawKontrakan) {
|
||||||
.toList();
|
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 {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
'kontrakan': kontrakanList,
|
'kontrakan': kontrakanList,
|
||||||
'laundry': laundryList,
|
'laundry': laundryList,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e, stack) {
|
||||||
// Error parsing favorites silently
|
debugPrint('[FAV] ❌ Exception parsing favorites: $e');
|
||||||
|
debugPrint('[FAV] Stack: $stack');
|
||||||
return {
|
return {
|
||||||
'success': false,
|
'success': false,
|
||||||
|
'message': 'Gagal memproses data favorit',
|
||||||
'kontrakan': <Kontrakan>[],
|
'kontrakan': <Kontrakan>[],
|
||||||
'laundry': <Laundry>[],
|
'laundry': <Laundry>[],
|
||||||
};
|
};
|
||||||
|
|
@ -83,80 +148,121 @@ class FavoriteService {
|
||||||
return {'kontrakan': [], 'laundry': []};
|
return {'kontrakan': [], 'laundry': []};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
final kontrakanIds = <int>[];
|
||||||
'kontrakan': (result['kontrakan'] as List)
|
for (final e in (result['kontrakan'] as List? ?? [])) {
|
||||||
.map<int>((e) => (e as Map<String, dynamic>)['id'] as int? ?? 0)
|
final id = (e as Map<String, dynamic>?)?['id'];
|
||||||
.toList(),
|
if (id != null) kontrakanIds.add(id is int ? id : int.tryParse(id.toString()) ?? 0);
|
||||||
'laundry': (result['laundry'] as List)
|
}
|
||||||
.map<int>((e) => (e as Map<String, dynamic>)['id'] as int? ?? 0)
|
|
||||||
.toList(),
|
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) {
|
} catch (e) {
|
||||||
|
debugPrint('[FAV] ❌ Exception in getFavoriteIds: $e');
|
||||||
return {'kontrakan': [], 'laundry': []};
|
return {'kontrakan': [], 'laundry': []};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle kontrakan favorite status
|
// Toggle kontrakan favorite status
|
||||||
Future<Map<String, dynamic>> toggleKontrakanFavorite(int kontrakanId) async {
|
Future<Map<String, dynamic>> toggleKontrakanFavorite(int kontrakanId) async {
|
||||||
|
if (!_ensureAuthenticated()) {
|
||||||
|
return {'success': false, 'message': 'Belum login'};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await http.post(
|
final url = '${AppConfig.baseUrl}/favorites/kontrakan/$kontrakanId';
|
||||||
Uri.parse('${AppConfig.baseUrl}/favorites/kontrakan/$kontrakanId'),
|
debugPrint('[FAV] POST $url');
|
||||||
headers: _headers,
|
|
||||||
);
|
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 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 {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
'message': data['message'] ?? 'Status favorit berhasil diubah',
|
'message': data['message'] ?? 'Status favorit berhasil diubah',
|
||||||
'isFavorite': data['data']['is_favorite'] ?? false,
|
'isFavorite': data['is_favorited'] ?? false,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
'success': false,
|
'success': false,
|
||||||
'message': data['message'] ?? 'Gagal mengubah status favorit',
|
'message': data['message'] ?? 'Gagal mengubah status favorit (${response.statusCode})',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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
|
// Toggle laundry favorite status
|
||||||
Future<Map<String, dynamic>> toggleLaundryFavorite(int laundryId) async {
|
Future<Map<String, dynamic>> toggleLaundryFavorite(int laundryId) async {
|
||||||
|
if (!_ensureAuthenticated()) {
|
||||||
|
return {'success': false, 'message': 'Belum login'};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await http.post(
|
final url = '${AppConfig.baseUrl}/favorites/laundry/$laundryId';
|
||||||
Uri.parse('${AppConfig.baseUrl}/favorites/laundry/$laundryId'),
|
debugPrint('[FAV] POST $url');
|
||||||
headers: _headers,
|
|
||||||
);
|
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 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 {
|
return {
|
||||||
'success': true,
|
'success': true,
|
||||||
'message': data['message'] ?? 'Status favorit berhasil diubah',
|
'message': data['message'] ?? 'Status favorit berhasil diubah',
|
||||||
'isFavorite': data['data']['is_favorite'] ?? false,
|
'isFavorite': data['is_favorited'] ?? false,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
'success': false,
|
'success': false,
|
||||||
'message': data['message'] ?? 'Gagal mengubah status favorit',
|
'message': data['message'] ?? 'Gagal mengubah status favorit (${response.statusCode})',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {'success': false, 'message': 'Error: $e'};
|
debugPrint('[FAV] ❌ Exception toggleLaundry: $e');
|
||||||
|
return {'success': false, 'message': 'Gagal terhubung ke server'};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove favorite
|
// Remove favorite
|
||||||
Future<Map<String, dynamic>> removeFavorite(int favoriteId) async {
|
Future<Map<String, dynamic>> removeFavorite(int favoriteId) async {
|
||||||
|
if (!_ensureAuthenticated()) {
|
||||||
|
return {'success': false, 'message': 'Belum login'};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final response = await http.delete(
|
final response = await http
|
||||||
Uri.parse('${AppConfig.baseUrl}/favorites/$favoriteId'),
|
.delete(
|
||||||
headers: _headers,
|
Uri.parse('${AppConfig.baseUrl}/favorites/$favoriteId'),
|
||||||
);
|
headers: _headers,
|
||||||
|
)
|
||||||
|
.timeout(AppConfig.connectionTimeout);
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
|
|
@ -172,18 +278,18 @@ class FavoriteService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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
|
// Check if kontrakan is favorite
|
||||||
Future<bool> isKontrakanFavorite(int kontrakanId) async {
|
Future<bool> isKontrakanFavorite(int kontrakanId) async {
|
||||||
try {
|
try {
|
||||||
final favorites = await getFavorites();
|
final ids = await getFavoriteIds();
|
||||||
final kontrakanList = favorites['kontrakan'] as List<int>? ?? [];
|
return (ids['kontrakan'] ?? []).contains(kontrakanId);
|
||||||
return kontrakanList.contains(kontrakanId);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Error checking favorite silently
|
debugPrint('[FAV] ❌ Exception isKontrakanFavorite: $e');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -191,11 +297,10 @@ class FavoriteService {
|
||||||
// Check if laundry is favorite
|
// Check if laundry is favorite
|
||||||
Future<bool> isLaundryFavorite(int laundryId) async {
|
Future<bool> isLaundryFavorite(int laundryId) async {
|
||||||
try {
|
try {
|
||||||
final favorites = await getFavorites();
|
final ids = await getFavoriteIds();
|
||||||
final laundryList = favorites['laundry'] as List<int>? ?? [];
|
return (ids['laundry'] ?? []).contains(laundryId);
|
||||||
return laundryList.contains(laundryId);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Error checking favorite silently
|
debugPrint('[FAV] ❌ Exception isLaundryFavorite: $e');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,22 +30,17 @@ class KontrakanService {
|
||||||
String status = 'tersedia',
|
String status = 'tersedia',
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
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) {
|
final response = await http.get(uri, headers: _headers);
|
||||||
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);
|
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue