diff --git a/lib/app/data/models/aset_model.dart b/lib/app/data/models/aset_model.dart index ca9051d..4365cd6 100644 --- a/lib/app/data/models/aset_model.dart +++ b/lib/app/data/models/aset_model.dart @@ -5,6 +5,7 @@ class AsetModel { final String nama; final String deskripsi; final String kategori; + final String jenis; // Add this line final int harga; final int? denda; final String status; @@ -14,17 +15,21 @@ class AsetModel { final int? kuantitasTerpakai; final String? satuanUkur; - // Untuk menampung URL gambar pertama dari tabel foto_aset + // URL gambar utama (untuk backward compatibility) String? imageUrl; + + // List untuk menyimpan semua URL gambar aset + final RxList imageUrls = [].obs; // Menggunakan RxList untuk membuatnya mutable dan reaktif - RxList> satuanWaktuSewa = >[].obs; + final RxList> satuanWaktuSewa = >[].obs; AsetModel({ required this.id, required this.nama, required this.deskripsi, required this.kategori, + this.jenis = 'Sewa', // Add this line with default value required this.harga, this.denda, required this.status, @@ -42,31 +47,69 @@ class AsetModel { } } + // Menambahkan URL gambar dari JSON + void addImageUrl(String? url) { + if (url != null && url.isNotEmpty && !imageUrls.contains(url)) { + imageUrls.add(url); + // Update imageUrl untuk backward compatibility + if (imageUrl == null) { + imageUrl = url; + } + } + } + + // Menghapus URL gambar + bool removeImageUrl(String url) { + final removed = imageUrls.remove(url); + if (removed && imageUrl == url) { + imageUrl = imageUrls.isNotEmpty ? imageUrls.first : null; + } + return removed; + } + factory AsetModel.fromJson(Map json) { - return AsetModel( + final model = AsetModel( id: json['id'] ?? '', nama: json['nama'] ?? '', deskripsi: json['deskripsi'] ?? '', kategori: json['kategori'] ?? '', + jenis: json['jenis'] ?? 'Sewa', harga: json['harga'] ?? 0, denda: json['denda'], status: json['status'] ?? '', - createdAt: - json['created_at'] != null - ? DateTime.parse(json['created_at']) - : null, - updatedAt: - json['updated_at'] != null - ? DateTime.parse(json['updated_at']) - : null, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + updatedAt: json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : null, kuantitas: json['kuantitas'], kuantitasTerpakai: json['kuantitas_terpakai'], satuanUkur: json['satuan_ukur'], + imageUrl: json['foto_aset'], + initialSatuanWaktuSewa: json['satuan_waktu_sewa'] != null + ? List>.from(json['satuan_waktu_sewa']) + : null, ); + + // Add the main image URL to the list if it exists + if (json['foto_aset'] != null) { + model.addImageUrl(json['foto_aset']); + } + + // Add any additional image URLs if they exist in the JSON + if (json['foto_aset_tambahan'] != null) { + final additionalImages = List.from(json['foto_aset_tambahan']); + for (final url in additionalImages) { + model.addImageUrl(url); + } + } + + return model; } Map toJson() { - return { + final data = { 'id': id, 'nama': nama, 'deskripsi': deskripsi, @@ -80,5 +123,23 @@ class AsetModel { 'kuantitas_terpakai': kuantitasTerpakai, 'satuan_ukur': satuanUkur, }; + + // Add image URLs if they exist + if (imageUrls.isNotEmpty) { + data['foto_aset'] = imageUrl; + + // Add additional images (excluding the main image) + final additionalImages = imageUrls.where((url) => url != imageUrl).toList(); + if (additionalImages.isNotEmpty) { + data['foto_aset_tambahan'] = additionalImages; + } + } + + // Add rental time units if they exist + if (satuanWaktuSewa.isNotEmpty) { + data['satuan_waktu_sewa'] = satuanWaktuSewa.toList(); + } + + return data; } } diff --git a/lib/app/data/models/paket_model.dart b/lib/app/data/models/paket_model.dart index befd998..4b8d87c 100644 --- a/lib/app/data/models/paket_model.dart +++ b/lib/app/data/models/paket_model.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:developer' as developer; class PaketModel { final String id; @@ -6,12 +7,13 @@ class PaketModel { final String deskripsi; final double harga; final int kuantitas; - final List foto; - final List> satuanWaktuSewa; + final String status; + List foto; + List> satuanWaktuSewa; final DateTime createdAt; final DateTime updatedAt; - final String? foto_paket; // Main photo URL - final List? images; // List of photo URLs + String? foto_paket; // Main photo URL + List? images; // List of photo URLs PaketModel({ required this.id, @@ -19,13 +21,47 @@ class PaketModel { required this.deskripsi, required this.harga, required this.kuantitas, - required this.foto, - required this.satuanWaktuSewa, + this.status = 'aktif', + required List foto, + required List> satuanWaktuSewa, this.foto_paket, - this.images, + List? images, required this.createdAt, required this.updatedAt, - }); + }) : foto = List.from(foto), + satuanWaktuSewa = List.from(satuanWaktuSewa), + images = images != null ? List.from(images) : []; + + // Add copyWith method for immutability patterns + PaketModel copyWith({ + String? id, + String? nama, + String? deskripsi, + double? harga, + int? kuantitas, + String? status, + List? foto, + List>? satuanWaktuSewa, + String? foto_paket, + List? images, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return PaketModel( + id: id ?? this.id, + nama: nama ?? this.nama, + deskripsi: deskripsi ?? this.deskripsi, + harga: harga ?? this.harga, + kuantitas: kuantitas ?? this.kuantitas, + status: status ?? this.status, + foto: foto ?? List.from(this.foto), + satuanWaktuSewa: satuanWaktuSewa ?? List.from(this.satuanWaktuSewa), + foto_paket: foto_paket ?? this.foto_paket, + images: images ?? (this.images != null ? List.from(this.images!) : null), + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } // Alias for fromJson to maintain compatibility factory PaketModel.fromMap(Map json) => PaketModel.fromJson(json); @@ -63,10 +99,15 @@ class PaketModel { } } + developer.log('šŸ“¦ [PaketModel.fromJson] Raw status: ${json['status']} (type: ${json['status']?.runtimeType})'); + final status = json['status']?.toString().toLowerCase() ?? 'aktif'; + developer.log(' šŸ·ļø Processed status: $status'); + return PaketModel( id: json['id']?.toString() ?? '', nama: json['nama']?.toString() ?? '', deskripsi: json['deskripsi']?.toString() ?? '', + status: status, harga: (json['harga'] is num) ? (json['harga'] as num).toDouble() : 0.0, kuantitas: (json['kuantitas'] is num) ? (json['kuantitas'] as num).toInt() : 1, foto: fotoList, @@ -97,35 +138,7 @@ class PaketModel { 'updated_at': updatedAt.toIso8601String(), }; - // Create a copy of the model with some fields updated - PaketModel copyWith({ - String? id, - String? nama, - String? deskripsi, - double? harga, - int? kuantitas, - List? foto, - List>? satuanWaktuSewa, - String? foto_paket, - List? images, - DateTime? createdAt, - DateTime? updatedAt, - }) { - return PaketModel( - id: id ?? this.id, - nama: nama ?? this.nama, - deskripsi: deskripsi ?? this.deskripsi, - harga: harga ?? this.harga, - kuantitas: kuantitas ?? this.kuantitas, - foto: foto ?? List.from(this.foto), - satuanWaktuSewa: satuanWaktuSewa ?? List.from(this.satuanWaktuSewa), - foto_paket: foto_paket ?? this.foto_paket, - images: images ?? (this.images != null ? List.from(this.images!) : null), - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - ); - } - + // Get the first photo URL or a placeholder String get firstPhotoUrl => foto.isNotEmpty ? foto.first : ''; diff --git a/lib/app/data/models/pembayaran_model.dart b/lib/app/data/models/pembayaran_model.dart new file mode 100644 index 0000000..2775940 --- /dev/null +++ b/lib/app/data/models/pembayaran_model.dart @@ -0,0 +1,22 @@ +class PembayaranModel { + final String id; + final int totalPembayaran; + final String metodePembayaran; + final DateTime waktuPembayaran; + + PembayaranModel({ + required this.id, + required this.totalPembayaran, + required this.metodePembayaran, + required this.waktuPembayaran, + }); + + factory PembayaranModel.fromJson(Map json) { + return PembayaranModel( + id: json['id'] as String, + totalPembayaran: json['total_pembayaran'] as int, + metodePembayaran: json['metode_pembayaran'] as String, + waktuPembayaran: DateTime.parse(json['waktu_pembayaran'] as String), + ); + } +} diff --git a/lib/app/data/models/rental_booking_model.dart b/lib/app/data/models/rental_booking_model.dart index 8b13789..1b310db 100644 --- a/lib/app/data/models/rental_booking_model.dart +++ b/lib/app/data/models/rental_booking_model.dart @@ -1 +1,95 @@ +class SewaModel { + final String id; + final String userId; + final String status; + final DateTime waktuMulai; + final DateTime waktuSelesai; + final DateTime tanggalPemesanan; + final String tipePesanan; + final int kuantitas; + // Untuk tunggal + final String? asetId; + final String? asetNama; + final String? asetFoto; + // Untuk paket + final String? paketId; + final String? paketNama; + final String? paketFoto; + // Tagihan + final double totalTagihan; + // Data warga + final String wargaNama; + final String wargaNoHp; + final String wargaAvatar; + final double? denda; + final double? dibayar; + final double? paidAmount; + SewaModel({ + required this.id, + required this.userId, + required this.status, + required this.waktuMulai, + required this.waktuSelesai, + required this.tanggalPemesanan, + required this.tipePesanan, + required this.kuantitas, + this.asetId, + this.asetNama, + this.asetFoto, + this.paketId, + this.paketNama, + this.paketFoto, + required this.totalTagihan, + required this.wargaNama, + required this.wargaNoHp, + required this.wargaAvatar, + this.denda, + this.dibayar, + this.paidAmount, + }); + + factory SewaModel.fromJson(Map json) { + return SewaModel( + id: json['id'] ?? '', + userId: json['user_id'] ?? '', + status: json['status'] ?? '', + waktuMulai: DateTime.parse( + json['waktu_mulai'] ?? DateTime.now().toIso8601String(), + ), + waktuSelesai: DateTime.parse( + json['waktu_selesai'] ?? DateTime.now().toIso8601String(), + ), + tanggalPemesanan: DateTime.parse( + json['tanggal_pemesanan'] ?? DateTime.now().toIso8601String(), + ), + tipePesanan: json['tipe_pesanan'] ?? '', + kuantitas: json['kuantitas'] ?? 1, + asetId: json['aset_id'], + asetNama: json['aset_nama'], + asetFoto: json['aset_foto'], + paketId: json['paket_id'], + paketNama: json['paket_nama'], + paketFoto: json['paket_foto'], + totalTagihan: + (json['total_tagihan'] is num) + ? json['total_tagihan'].toDouble() + : double.tryParse(json['total_tagihan']?.toString() ?? '0') ?? 0, + wargaNama: json['warga_nama'] ?? '', + wargaNoHp: json['warga_no_hp'] ?? '', + wargaAvatar: json['warga_avatar'] ?? '', + denda: + (json['denda'] is num) + ? json['denda'].toDouble() + : double.tryParse(json['denda']?.toString() ?? '0'), + dibayar: + (json['dibayar'] is num) + ? json['dibayar'].toDouble() + : double.tryParse(json['dibayar']?.toString() ?? '0'), + paidAmount: + (json['paid_amount'] is num) + ? json['paid_amount'].toDouble() + : double.tryParse(json['paid_amount']?.toString() ?? '0'), + ); + } +} diff --git a/lib/app/data/providers/aset_provider.dart b/lib/app/data/providers/aset_provider.dart index 197db5a..f62188a 100644 --- a/lib/app/data/providers/aset_provider.dart +++ b/lib/app/data/providers/aset_provider.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -6,6 +8,8 @@ import '../models/foto_aset_model.dart'; import '../models/satuan_waktu_model.dart'; import '../models/satuan_waktu_sewa_model.dart'; import 'package:intl/intl.dart'; +import '../models/paket_model.dart'; +import '../providers/auth_provider.dart'; class AsetProvider extends GetxService { late final SupabaseClient client; @@ -24,8 +28,16 @@ class AsetProvider extends GetxService { .from('aset') .select('*') .eq('kategori', 'sewa') - .eq('status', 'tersedia') // Hanya yang tersedia - .order('nama', ascending: true); // Urutan berdasarkan nama + .ilike('status', 'tersedia') // Hanya yang tersedia + .order('nama', ascending: true) // Urutan berdasarkan nama + .withConverter>>( + (data) => + data.map>((item) { + // Ensure 'jenis' is set to 'Sewa' for sewa assets + item['jenis'] = 'Sewa'; + return item; + }).toList(), + ); debugPrint('Fetched ${response.length} aset'); @@ -56,8 +68,16 @@ class AsetProvider extends GetxService { .from('aset') .select('*') .eq('kategori', 'langganan') - .eq('status', 'tersedia') // Hanya yang tersedia - .order('nama', ascending: true); // Urutan berdasarkan nama + .ilike('status', 'tersedia') // Hanya yang tersedia + .order('nama', ascending: true) // Urutan berdasarkan nama + .withConverter>>( + (data) => + data.map>((item) { + // Ensure 'jenis' is set to 'Langganan' for langganan assets + item['jenis'] = 'Langganan'; + return item; + }).toList(), + ); debugPrint('Fetched ${response.length} langganan aset'); @@ -120,9 +140,26 @@ class AsetProvider extends GetxService { Future loadAssetPhotos(AsetModel aset) async { try { final photos = await getAsetPhotos(aset.id); - if (photos.isNotEmpty && - (aset.imageUrl == null || aset.imageUrl!.isEmpty)) { - aset.imageUrl = photos.first.fotoAset; + if (photos.isNotEmpty) { + // Clear existing images + aset.imageUrls.clear(); + + // Add all photos to the imageUrls list + for (final photo in photos) { + if (photo.fotoAset != null && photo.fotoAset!.isNotEmpty) { + aset.addImageUrl(photo.fotoAset); + } + } + + // Set the main image URL if it's not already set + if ((aset.imageUrl == null || aset.imageUrl!.isEmpty) && + aset.imageUrls.isNotEmpty) { + aset.imageUrl = aset.imageUrls.first; + } + + debugPrint( + 'āœ… Loaded ${aset.imageUrls.length} photos for asset ${aset.id}', + ); } } catch (e) { debugPrint('Error loading asset photos for ID ${aset.id}: $e'); @@ -172,6 +209,376 @@ class AsetProvider extends GetxService { } } + // Create a new asset + Future?> createAset( + Map asetData, + ) async { + try { + debugPrint('šŸ”„ Creating new aset with data:'); + asetData.forEach((key, value) { + debugPrint(' $key: $value'); + }); + + final response = + await client.from('aset').insert(asetData).select().single(); + + debugPrint('āœ… Aset created successfully with ID: ${response['id']}'); + return response; + } catch (e) { + debugPrint('āŒ Error creating aset: $e'); + debugPrint('āŒ Stack trace: ${StackTrace.current}'); + return null; + } + } + + // Update an existing asset + Future updateAset(String asetId, Map asetData) async { + try { + debugPrint('šŸ”„ Updating aset with ID: $asetId'); + asetData.forEach((key, value) { + debugPrint(' $key: $value'); + }); + + final response = await client + .from('aset') + .update(asetData) + .eq('id', asetId); + + debugPrint('āœ… Aset updated successfully'); + return true; + } catch (e) { + debugPrint('āŒ Error updating aset: $e'); + debugPrint('āŒ Stack trace: ${StackTrace.current}'); + return false; + } + } + + /// Adds a photo URL to the foto_aset table for a specific asset + Future addFotoAset({ + required String asetId, + required String fotoUrl, + }) async { + try { + debugPrint('šŸ’¾ Attempting to save foto to database:'); + debugPrint(' - asetId: $asetId'); + debugPrint(' - fotoUrl: $fotoUrl'); + + final data = { + 'id_aset': asetId, + 'foto_aset': fotoUrl, + 'created_at': DateTime.now().toIso8601String(), + }; + + debugPrint('šŸ“¤ Inserting into foto_aset table...'); + final response = await client.from('foto_aset').insert(data).select(); + + debugPrint('šŸ“ Database insert response: $response'); + + if (response == null) { + debugPrint('āŒ Failed to insert into foto_aset: Response is null'); + return false; + } + + if (response is List && response.isNotEmpty) { + debugPrint('āœ… Successfully added foto for aset ID: $asetId'); + return true; + } else { + debugPrint('āŒ Failed to add foto: Empty or invalid response'); + return false; + } + } catch (e, stackTrace) { + debugPrint('āŒ Error adding foto aset: $e'); + debugPrint('Stack trace: $stackTrace'); + return false; + } + } + + /// Add satuan waktu sewa for an asset + Future addSatuanWaktuSewa({ + required String asetId, + required String satuanWaktu, + required int harga, + required int maksimalWaktu, + }) async { + try { + // First, get the satuan_waktu_id from the satuan_waktu table + final response = + await client + .from('satuan_waktu') + .select('id') + .ilike('nama_satuan_waktu', satuanWaktu) + .maybeSingle(); + + if (response == null) { + debugPrint('āŒ Satuan waktu "$satuanWaktu" not found in the database'); + return false; + } + + final satuanWaktuId = response['id'] as String; + + final data = { + 'aset_id': asetId, + 'satuan_waktu_id': satuanWaktuId, + 'harga': harga, + 'maksimal_waktu': maksimalWaktu, + }; + + debugPrint('šŸ”„ Adding satuan waktu sewa:'); + data.forEach((key, value) { + debugPrint(' $key: $value'); + }); + + await client.from('satuan_waktu_sewa').insert(data); + debugPrint('āœ… Satuan waktu sewa added successfully'); + return true; + } catch (e) { + debugPrint('āŒ Error adding satuan waktu sewa: $e'); + debugPrint('āŒ Stack trace: ${StackTrace.current}'); + return false; + } + } + + // Delete all satuan waktu sewa for an asset + Future deleteSatuanWaktuSewaByAsetId(String asetId) async { + try { + await client + .from('satuan_waktu_sewa') + .delete() + .eq('aset_id', asetId); // Changed from 'id_aset' to 'aset_id' + debugPrint('āœ… Deleted satuan waktu sewa for aset ID: $asetId'); + return true; + } catch (e) { + debugPrint('āŒ Error deleting satuan waktu sewa: $e'); + return false; + } + } + + /// Uploads a file to Supabase Storage root + /// Returns the public URL of the uploaded file, or null if upload fails + Future uploadFileToStorage(File file) async { + try { + if (!await file.exists()) { + debugPrint('āŒ File does not exist: ${file.path}'); + return null; + } + + final fileName = + '${DateTime.now().millisecondsSinceEpoch}_${file.path.split(Platform.pathSeparator).last}'; + debugPrint('šŸ”„ Preparing to upload file: $fileName'); + + final uploadResponse = await client.storage + .from('foto.aset') + .upload(fileName, file, fileOptions: FileOptions(upsert: true)); + + debugPrint('šŸ“¤ Upload response: $uploadResponse'); + + final publicUrl = client.storage.from('foto.aset').getPublicUrl(fileName); + + debugPrint('āœ… File uploaded successfully. Public URL: $publicUrl'); + return publicUrl; + } catch (e, stackTrace) { + debugPrint('āŒ Error uploading file to storage: $e'); + debugPrint('Stack trace: $stackTrace'); + return null; + } + } + + /// Helper method to delete a file from Supabase Storage + Future deleteFileFromStorage(String fileUrl) async { + try { + debugPrint('šŸ”„ Preparing to delete file from storage'); + + // Extract the file path from the full URL + final uri = Uri.parse(fileUrl); + final pathSegments = uri.pathSegments; + + // Find the index of 'foto.aset' in the path + final fotoAsetIndex = pathSegments.indexWhere( + (segment) => segment == 'foto.aset', + ); + + if (fotoAsetIndex == -1 || fotoAsetIndex == pathSegments.length - 1) { + debugPrint( + 'āš ļø Invalid file URL format, cannot extract file path: $fileUrl', + ); + return false; + } + + // Get the file path relative to the bucket + final filePath = pathSegments.sublist(fotoAsetIndex + 1).join('/'); + + debugPrint('šŸ—‘ļø Deleting file from storage - Path: $filePath'); + + // Delete the file from storage + await client.storage.from('foto.aset').remove([filePath]); + + debugPrint('āœ… Successfully deleted file from storage'); + return true; + } catch (e) { + debugPrint('āŒ Error deleting file from storage: $e'); + return false; + } + } + + /// Updates the photos for an asset + /// Handles both local file uploads and existing URLs + /// Returns true if all operations were successful + Future updateFotoAset({ + required String asetId, + required List fotoUrls, + }) async { + if (fotoUrls.isEmpty) { + debugPrint('ā„¹ļø No photos to update for asset: $asetId'); + return true; + } + + try { + debugPrint('šŸ”„ Starting photo update for asset: $asetId'); + + // 1. Get existing photo URLs before deleting them + debugPrint('šŸ“‹ Fetching existing photos for asset: $asetId'); + final existingPhotos = await client + .from('foto_aset') + .select('foto_aset') + .eq('id_aset', asetId); + + // 2. Delete files from storage first + if (existingPhotos is List && existingPhotos.isNotEmpty) { + debugPrint('šŸ—‘ļø Deleting ${existingPhotos.length} files from storage'); + for (final photo in existingPhotos) { + final url = photo['foto_aset'] as String?; + if (url != null && url.isNotEmpty) { + await deleteFileFromStorage(url); + } else { + debugPrint('āš ļø Skipping invalid photo URL: $photo'); + } + } + } else { + debugPrint('ā„¹ļø No existing photos found in database'); + } + + // 3. Remove duplicates from new fotoUrls + final uniqueFotoUrls = fotoUrls.toSet().toList(); + debugPrint( + 'šŸ“ø Processing ${uniqueFotoUrls.length} unique photos (was ${fotoUrls.length})', + ); + + // 4. Delete existing photo records from database + debugPrint('šŸ—‘ļø Removing existing photo records from database'); + try { + final deleteResponse = await client + .from('foto_aset') + .delete() + .eq('id_aset', asetId); + + debugPrint('šŸ—‘ļø Database delete response: $deleteResponse'); + + // Verify deletion + final remainingPhotos = await client + .from('foto_aset') + .select() + .eq('id_aset', asetId); + + if (remainingPhotos is List && remainingPhotos.isNotEmpty) { + debugPrint( + 'āš ļø Warning: ${remainingPhotos.length} photos still exist in database after delete', + ); + } + } catch (e) { + debugPrint('āŒ Error deleting existing photo records: $e'); + // Continue with the update even if deletion fails + } + + // 5. Process each unique new photo + bool allSuccess = true; + int processedCount = 0; + + for (final fotoUrl in uniqueFotoUrls) { + if (fotoUrl.isEmpty) { + debugPrint('ā­ļø Skipping empty photo URL'); + continue; + } + + try { + debugPrint( + '\nšŸ”„ Processing photo ${processedCount + 1}/${uniqueFotoUrls.length}: ${fotoUrl.length > 50 ? '${fotoUrl.substring(0, 50)}...' : fotoUrl}', + ); + + // Check if it's a local file + if (fotoUrl.startsWith('file://') || + fotoUrl.startsWith('/') || + !fotoUrl.startsWith('http')) { + final file = File(fotoUrl.replaceFirst('file://', '')); + if (!await file.exists()) { + debugPrint('āŒ File does not exist: ${file.path}'); + allSuccess = false; + continue; + } + + debugPrint('šŸ“¤ Uploading local file...'); + final uploadedUrl = await uploadFileToStorage(file); + + if (uploadedUrl == null) { + debugPrint('āŒ Failed to upload file'); + allSuccess = false; + continue; + } + + debugPrint('šŸ’¾ Saving to database...'); + final success = await addFotoAset( + asetId: asetId, + fotoUrl: uploadedUrl, + ); + + if (success) { + processedCount++; + debugPrint('āœ… Successfully saved photo #$processedCount'); + } else { + allSuccess = false; + debugPrint('āŒ Failed to save photo URL to database'); + } + } + // Skip placeholder values + else if (fotoUrl == 'pending_upload') { + debugPrint('ā­ļø Skipping placeholder URL'); + continue; + } + // Handle existing URLs + else if (fotoUrl.startsWith('http')) { + debugPrint('🌐 Processing existing URL...'); + final success = await addFotoAset(asetId: asetId, fotoUrl: fotoUrl); + + if (success) { + processedCount++; + debugPrint('āœ… Successfully saved URL #$processedCount'); + } else { + allSuccess = false; + debugPrint('āŒ Failed to save URL to database'); + } + } else { + debugPrint('āš ļø Unrecognized URL format, skipping'); + } + } catch (e, stackTrace) { + allSuccess = false; + debugPrint('āŒ Error processing photo: $e'); + debugPrint('Stack trace: $stackTrace'); + } + } + + debugPrint('\nšŸ“Š Photo update complete'); + debugPrint('āœ… Success: $allSuccess'); + debugPrint( + 'šŸ“ø Processed: $processedCount/${uniqueFotoUrls.length} unique photos', + ); + + return allSuccess && processedCount > 0; + } catch (e) { + debugPrint('āŒ Error updating foto aset: $e'); + debugPrint('Stack trace: ${StackTrace.current}'); + return false; + } + } + // Retrieve bookings for a specific asset on a specific date Future>> getAsetBookings( String asetId, @@ -1061,7 +1468,9 @@ class AsetProvider extends GetxService { .order('created_at'); if (response != null && response.isNotEmpty) { - return response.map((item) => item['foto_aset'] as String).toList(); + return response + .map((item) => item['foto_aset'] as String) + .toList(); } return []; } catch (e) { @@ -1073,11 +1482,11 @@ class AsetProvider extends GetxService { // Get items included in a package with additional asset details Future>> getPaketItems(String paketId) async { debugPrint('šŸ”„ [1/3] Starting to fetch items for paket ID: $paketId'); - + try { // 1. First, get the basic package items (aset_id and kuantitas) debugPrint('šŸ” [2/3] Querying paket_item table for paket_id: $paketId'); - + final response = await client .from('paket_item') .select(''' @@ -1093,44 +1502,53 @@ class AsetProvider extends GetxService { debugPrint('āŒ [ERROR] Null response from paket_item query'); return []; } - + if (response.isEmpty) { - debugPrint('ā„¹ļø [INFO] No items found in paket_item for paket ID: $paketId'); + debugPrint( + 'ā„¹ļø [INFO] No items found in paket_item for paket ID: $paketId', + ); return []; } - - debugPrint('āœ… [SUCCESS] Found ${response.length} items in paket_item'); + + debugPrint( + 'āœ… [SUCCESS] Found ${response.length} items in paket_item', + ); final List> enrichedItems = []; - + // Process each item to fetch additional details - debugPrint('šŸ”„ [3/3] Processing ${response.length} items to fetch asset details'); - + debugPrint( + 'šŸ”„ [3/3] Processing ${response.length} items to fetch asset details', + ); + for (var item in response) { final String? asetId = item['aset_id']?.toString(); final int kuantitas = item['kuantitas'] ?? 1; - + debugPrint('\nšŸ” Processing item:'); debugPrint(' - Raw item data: $item'); debugPrint(' - aset_id: $asetId'); debugPrint(' - kuantitas: $kuantitas'); - + if (asetId == null || asetId.isEmpty) { debugPrint('āš ļø [WARNING] Skipping item with null/empty aset_id'); continue; } - + try { // 1. Get asset name from aset table debugPrint(' - Querying aset table for id: $asetId'); - final asetResponse = await client - .from('aset') - .select('id, nama, deskripsi') - .eq('id', asetId) - .maybeSingle(); - - debugPrint(' - Aset response: ${asetResponse?.toString() ?? 'null'}'); - + final asetResponse = + await client + .from('aset') + .select('id, nama, deskripsi') + .eq('id', asetId) + .maybeSingle(); + + debugPrint( + ' - Aset response: ${asetResponse?.toString() ?? 'null'}', + ); + if (asetResponse == null) { debugPrint('āš ļø [WARNING] No asset found with id: $asetId'); enrichedItems.add({ @@ -1139,11 +1557,11 @@ class AsetProvider extends GetxService { 'nama_aset': 'Item tidak diketahui', 'foto_aset': '', 'semua_foto': [], - 'error': 'Asset not found' + 'error': 'Asset not found', }); continue; } - + // 2. Get only the first photo from foto_aset table debugPrint(' - Querying first photo for id_aset: $asetId'); final fotoResponse = await client @@ -1152,10 +1570,10 @@ class AsetProvider extends GetxService { .eq('id_aset', asetId) .order('created_at', ascending: true) .limit(1); - + String? fotoUtama = ''; List semuaFoto = []; - + if (fotoResponse.isNotEmpty) { final firstFoto = fotoResponse.first['foto_aset']?.toString(); if (firstFoto != null && firstFoto.isNotEmpty) { @@ -1168,22 +1586,25 @@ class AsetProvider extends GetxService { } else { debugPrint(' - No photos found for asset $asetId'); } - + // 4. Combine all data final enrichedItem = { 'aset_id': asetId, 'kuantitas': kuantitas, - 'nama_aset': asetResponse['nama']?.toString() ?? 'Nama tidak tersedia', + 'nama_aset': + asetResponse['nama']?.toString() ?? 'Nama tidak tersedia', 'foto_aset': fotoUtama, 'semua_foto': semuaFoto, 'debug': { 'aset_query': asetResponse, - 'foto_count': semuaFoto.length - } + 'foto_count': semuaFoto.length, + }, }; - + + debugPrint('āœ… [ENRICHED ITEM] $enrichedItem'); + enrichedItems.add(enrichedItem); - + // Debug log debugPrint('āœ… Successfully processed item:'); debugPrint(' - Aset ID: $asetId'); @@ -1193,7 +1614,6 @@ class AsetProvider extends GetxService { if (semuaFoto.isNotEmpty) { debugPrint(' - Foto Utama: ${semuaFoto.first}'); } - } catch (e) { debugPrint('āŒ Error processing asset $asetId: $e'); // Still add the basic item even if we couldn't fetch additional details @@ -1206,10 +1626,14 @@ class AsetProvider extends GetxService { }); } } - - debugPrint('āœ… Successfully fetched ${enrichedItems.length} items with details'); + + debugPrint( + 'āœ… Successfully fetched ${enrichedItems.length} items with details:', + ); + for (var item in enrichedItems) { + debugPrint(' - $item'); + } return enrichedItems; - } catch (e, stackTrace) { debugPrint('āŒ Error getting package items for paket $paketId: $e'); debugPrint('Stack trace: $stackTrace'); @@ -1221,10 +1645,9 @@ class AsetProvider extends GetxService { Future>> getBankAccounts() async { try { final response = await client - .from('bank_accounts') + .from('akun_bank') .select('*') - .eq('is_active', true) - .order('bank_name'); + .order('nama_bank'); if (response != null && response.isNotEmpty) { return List>.from(response); @@ -1235,4 +1658,325 @@ class AsetProvider extends GetxService { return []; } } + + /// Fetch all packages with their related data (photos and rental time units) + Future> getAllPaket() async { + final stopwatch = Stopwatch()..start(); + final String debugId = DateTime.now().millisecondsSinceEpoch + .toString() + .substring(8); + + void log(String message, {bool isError = false, bool isSection = false}) { + final prefix = + isError + ? 'āŒ' + : isSection + ? 'šŸ“Œ' + : ' '; + debugPrint('[$debugId] $prefix $message'); + } + + try { + log('šŸš€ Memulai pengambilan data paket...', isSection: true); + log('šŸ“” Mengambil data paket dari database...'); + + // 1) Get all packages + final paketResponse = await client + .from('paket') + .select('*') + .order('created_at', ascending: false); + + log('šŸ“„ Diterima ${paketResponse.length} paket dari database'); + + if (paketResponse.isEmpty) { + log('ā„¹ļø Tidak ada paket yang ditemukan'); + return []; + } + + // Convert to list of PaketModel (without relations yet) + log('\nšŸ” Memproses data paket...'); + final List paketList = []; + int successCount = 0; + + for (var p in paketResponse) { + try { + final paket = PaketModel.fromMap(p as Map); + paketList.add(paket); + successCount++; + log(' āœ… Berhasil memproses paket: ${paket.id} - ${paket.nama}'); + } catch (e) { + log('āš ļø Gagal memproses paket: $e', isError: true); + log(' Data paket: $p'); + } + } + + log('\nšŸ“Š Ringkasan Pemrosesan:'); + log(' - Total data: ${paketResponse.length}'); + log(' - Berhasil: $successCount'); + log(' - Gagal: ${paketResponse.length - successCount}'); + + if (paketList.isEmpty) { + log('ā„¹ļø Tidak ada paket yang valid setelah diproses'); + return []; + } + + // Kumpulkan semua ID paket + final List paketIds = paketList.map((p) => p.id).toList(); + log('\nšŸ“¦ Mengambil data tambahan untuk ${paketList.length} paket...'); + log(' ID Paket: ${paketIds.join(', ')}'); + + // 2) Ambil semua foto untuk paket-paket ini + log('\nšŸ–¼ļø Mengambil data foto...'); + + final fotoResp = await client + .from('foto_aset') + .select('id_paket, foto_aset') + .inFilter('id_paket', paketIds); + + log(' Ditemukan ${fotoResp.length} foto'); + + // Map packageId -> List photos + final Map> mapFoto = {}; + int fotoCount = 0; + + for (var row in fotoResp) { + try { + final pid = row['id_paket']?.toString() ?? ''; + final url = row['foto_aset']?.toString() ?? ''; + if (pid.isNotEmpty && url.isNotEmpty) { + mapFoto.putIfAbsent(pid, () => []).add(url); + fotoCount++; + } else { + log(' āš ļø Data foto tidak valid: ${row.toString()}'); + } + } catch (e) { + log('āš ļø Gagal memproses data foto: $e', isError: true); + } + } + + log(' Berhasil memetakan $fotoCount foto ke ${mapFoto.length} paket'); + + // 3) Get all satuan_waktu_sewa for these packages + log('\nā±ļø Mengambil data satuan waktu sewa...'); + + final swsResp = await client + .from('satuan_waktu_sewa') + .select('paket_id, satuan_waktu_id, harga, maksimal_waktu') + .inFilter('paket_id', paketIds); + + log(' Ditemukan ${swsResp.length} entri satuan waktu sewa'); + + // Process satuan waktu sewa + final Map>> paketSatuanWaktu = {}; + int swsCount = 0; + + for (var row in swsResp) { + try { + final pid = row['paket_id']?.toString() ?? ''; + if (pid.isNotEmpty) { + final swsData = { + 'satuan_waktu_id': row['satuan_waktu_id'], + 'harga': row['harga'], + 'maksimal_waktu': row['maksimal_waktu'], + }; + paketSatuanWaktu.putIfAbsent(pid, () => []).add(swsData); + swsCount++; + } + } catch (e) { + log('āš ļø Gagal memproses satuan waktu sewa: $e', isError: true); + log(' Data: $row'); + } + } + + log( + ' Berhasil memetakan $swsCount satuan waktu ke ${paketSatuanWaktu.length} paket', + ); + + // 4) Gabungkan semua data + log('\nšŸ”— Menggabungkan data...'); + final List result = []; + int combinedCount = 0; + + for (var paket in paketList) { + final pid = paket.id; + log('\nšŸ“¦ Memproses paket: ${paket.nama} ($pid)'); + + try { + final updatedPaket = paket.copyWith(); + + // Lampirkan foto + if (mapFoto.containsKey(pid)) { + final fotoList = mapFoto[pid]!; + updatedPaket.images = List.from(fotoList); + + // Set foto utama jika belum ada + if (updatedPaket.images!.isNotEmpty && + updatedPaket.foto_paket == null) { + updatedPaket.foto_paket = updatedPaket.images!.first; + log(' šŸ“· Menambahkan ${fotoList.length} foto'); + log(' šŸ–¼ļø Foto utama: ${updatedPaket.foto_paket}'); + } + } else { + log(' ā„¹ļø Tidak ada foto untuk paket ini'); + } + + // Lampirkan satuan waktu sewa + if (paketSatuanWaktu.containsKey(pid)) { + final swsList = List>.from( + paketSatuanWaktu[pid] ?? [], + ); + updatedPaket.satuanWaktuSewa = swsList; + log(' ā±ļø Menambahkan ${swsList.length} satuan waktu sewa'); + + // Log detail harga + for (var sws in swsList.take(2)) { + // Tampilkan maksimal 2 harga + log( + ' - ${sws['harga']} / satuan waktu (ID: ${sws['satuan_waktu_id']})', + ); + } + if (swsList.length > 2) { + log(' - ...dan ${swsList.length - 2} lainnya'); + } + } else { + log(' ā„¹ļø Tidak ada satuan waktu sewa untuk paket ini'); + } + + result.add(updatedPaket); + combinedCount++; + log(' āœ… Berhasil memproses paket $pid'); + } catch (e) { + log('āš ļø Gagal memproses paket $pid: $e', isError: true); + // Tetap tambahkan paket asli jika gagal diproses + result.add(paket); + } + } + + // Ringkasan eksekusi + stopwatch.stop(); + log('\nšŸŽ‰ Selesai!', isSection: true); + log('šŸ“Š Ringkasan Eksekusi:'); + log(' - Total paket: ${paketList.length}'); + log(' - Berhasil diproses: $combinedCount/${paketList.length}'); + log(' - Total foto: $fotoCount'); + log(' - Total satuan waktu: $swsCount'); + log(' - Waktu eksekusi: ${stopwatch.elapsedMilliseconds}ms'); + log(' - ID Debug: $debugId'); + + return result; + } catch (e, stackTrace) { + log('\nāŒ ERROR KRITIS', isError: true); + log('Pesan error: $e', isError: true); + log('Stack trace: $stackTrace', isError: true); + log('ID Debug: $debugId', isError: true); + rethrow; + debugPrint('āŒ [getAllPaket] Error: $e'); + debugPrint('Stack trace: $stackTrace'); + rethrow; + } + } + + // Update tagihan_dibayar and insert pembayaran + Future processPembayaranTagihan({ + required String tagihanSewaId, + required int nominal, + required String metodePembayaran, + }) async { + try { + // 1. Get current tagihan_dibayar + final tagihan = + await client + .from('tagihan_sewa') + .select('tagihan_dibayar') + .eq('id', tagihanSewaId) + .maybeSingle(); + int currentDibayar = 0; + if (tagihan != null && tagihan['tagihan_dibayar'] != null) { + currentDibayar = + int.tryParse(tagihan['tagihan_dibayar'].toString()) ?? 0; + } + final newDibayar = currentDibayar + nominal; + + // 2. Update tagihan_dibayar + await client + .from('tagihan_sewa') + .update({'tagihan_dibayar': newDibayar}) + .eq('id', tagihanSewaId); + + // 3. Insert pembayaran + final authProvider = Get.find(); + final idPetugas = authProvider.getCurrentUserId(); + final pembayaranData = { + 'tagihan_sewa_id': tagihanSewaId, + 'metode_pembayaran': metodePembayaran, + 'total_pembayaran': nominal, + 'status': 'lunas', + 'created_at': DateTime.now().toIso8601String(), + 'id_petugas': idPetugas, + }; + await client.from('pembayaran').insert(pembayaranData); + return true; + } catch (e) { + debugPrint('āŒ Error processing pembayaran tagihan: $e'); + return false; + } + } + + // Update status of sewa_aset by ID + Future updateSewaAsetStatus({ + required String sewaAsetId, + required String status, + }) async { + try { + debugPrint('šŸ”„ Updating status of sewa_aset ID: $sewaAsetId to $status'); + final response = await client + .from('sewa_aset') + .update({'status': status}) + .eq('id', sewaAsetId); + debugPrint('āœ… Status updated for sewa_aset ID: $sewaAsetId'); + return true; + } catch (e) { + debugPrint('āŒ Error updating sewa_aset status: $e'); + return false; + } + } + + // Get all payment proof image URLs for a sewa_aset (by tagihan_sewa) + Future> getFotoPembayaranUrlsByTagihanSewaId( + String sewaAsetId, + ) async { + try { + // 1. Get tagihan_sewa by sewaAsetId + final tagihan = await getTagihanSewa(sewaAsetId); + if (tagihan == null || tagihan['id'] == null) return []; + final tagihanSewaId = tagihan['id']; + // 2. Fetch all foto_pembayaran for this tagihan_sewa_id + final List response = await client + .from('foto_pembayaran') + .select('foto_pembayaran') + .eq('tagihan_sewa_id', tagihanSewaId) + .order('created_at', ascending: false); + // 3. Extract URLs + return response + .map((row) => row['foto_pembayaran']?.toString() ?? '') + .where((url) => url.isNotEmpty) + .toList(); + } catch (e) { + debugPrint('āŒ Error fetching foto pembayaran: $e'); + return []; + } + } + + Future countSewaAsetByStatus(List statuses) async { + // Supabase expects the IN filter as a comma-separated string in parentheses + final statusString = '(${statuses.map((s) => '"$s"').join(',')})'; + final response = await client + .from('sewa_aset') + .select('id') + .filter('status', 'in', statusString); + if (response is List) { + return response.length; + } + return 0; + } } diff --git a/lib/app/main.dart b/lib/app/main.dart new file mode 100644 index 0000000..8a086bd --- /dev/null +++ b/lib/app/main.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:bumrent_app/app/data/providers/aset_provider.dart'; +import 'package:bumrent_app/main.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + Get.put(AsetProvider()); + runApp(const MyApp()); +} diff --git a/lib/app/modules/auth/controllers/auth_controller.dart b/lib/app/modules/auth/controllers/auth_controller.dart index 7838917..8c0323e 100644 --- a/lib/app/modules/auth/controllers/auth_controller.dart +++ b/lib/app/modules/auth/controllers/auth_controller.dart @@ -8,6 +8,10 @@ class AuthController extends GetxController { final emailController = TextEditingController(); final passwordController = TextEditingController(); + final formKey = GlobalKey(); + final nameController = TextEditingController(); + final confirmPasswordController = TextEditingController(); + final RxBool isConfirmPasswordVisible = false.obs; // Form fields for registration final RxString email = ''.obs; @@ -15,6 +19,7 @@ class AuthController extends GetxController { final RxString nik = ''.obs; final RxString phoneNumber = ''.obs; final RxString selectedRole = 'WARGA'.obs; // Default role + final RxString alamatLengkap = ''.obs; // Form status final RxBool isLoading = false.obs; @@ -28,6 +33,10 @@ class AuthController extends GetxController { isPasswordVisible.value = !isPasswordVisible.value; } + void toggleConfirmPasswordVisibility() { + isConfirmPasswordVisible.value = !isConfirmPasswordVisible.value; + } + // Change role selection void setRole(String? role) { if (role != null) { @@ -172,6 +181,8 @@ class AuthController extends GetxController { void onClose() { emailController.dispose(); passwordController.dispose(); + nameController.dispose(); + confirmPasswordController.dispose(); super.onClose(); } @@ -181,7 +192,8 @@ class AuthController extends GetxController { if (email.value.isEmpty || password.value.isEmpty || nik.value.isEmpty || - phoneNumber.value.isEmpty) { + phoneNumber.value.isEmpty || + alamatLengkap.value.isEmpty) { errorMessage.value = 'Semua field harus diisi'; return; } @@ -222,6 +234,7 @@ class AuthController extends GetxController { data: { 'nik': nik.value.trim(), 'phone_number': phoneNumber.value.trim(), + 'alamat_lengkap': alamatLengkap.value.trim(), 'role': selectedRole.value, }, ); diff --git a/lib/app/modules/auth/views/forgot_password_view.dart b/lib/app/modules/auth/views/forgot_password_view.dart index cc535a5..9adf1a9 100644 --- a/lib/app/modules/auth/views/forgot_password_view.dart +++ b/lib/app/modules/auth/views/forgot_password_view.dart @@ -26,12 +26,8 @@ class ForgotPasswordView extends GetView { Opacity( opacity: 0.03, child: Container( - decoration: const BoxDecoration( - image: DecorationImage( - image: AssetImage('assets/images/pattern.png'), - repeat: ImageRepeat.repeat, - scale: 4.0, - ), + decoration: BoxDecoration( + color: Colors.blue[50], // Temporary solid color ), ), ), diff --git a/lib/app/modules/auth/views/login_view.dart b/lib/app/modules/auth/views/login_view.dart index 84f06d3..a67b01f 100644 --- a/lib/app/modules/auth/views/login_view.dart +++ b/lib/app/modules/auth/views/login_view.dart @@ -30,12 +30,8 @@ class LoginView extends GetView { Opacity( opacity: 0.03, child: Container( - decoration: const BoxDecoration( - image: DecorationImage( - image: AssetImage('assets/images/pattern.png'), - repeat: ImageRepeat.repeat, - scale: 4.0, - ), + decoration: BoxDecoration( + color: Colors.blue[50], // Temporary solid color ), ), ), @@ -89,7 +85,6 @@ class LoginView extends GetView { _buildHeader(), const SizedBox(height: 40), _buildLoginCard(), - const SizedBox(height: 24), _buildRegisterLink(), const SizedBox(height: 30), ], @@ -161,7 +156,7 @@ class LoginView extends GetView { prefixIcon: Icons.email_outlined, keyboardType: TextInputType.emailAddress, ), - const SizedBox(height: 24), + const SizedBox(height: 12), // Password field _buildInputLabel('Password'), @@ -204,7 +199,6 @@ class LoginView extends GetView { ), ), ), - const SizedBox(height: 32), // Login button Obx( diff --git a/lib/app/modules/auth/views/registration_view.dart b/lib/app/modules/auth/views/registration_view.dart index 458f2b1..25c0388 100644 --- a/lib/app/modules/auth/views/registration_view.dart +++ b/lib/app/modules/auth/views/registration_view.dart @@ -187,7 +187,7 @@ class RegistrationView extends GetView { ), const SizedBox(height: 4), Text( - 'Pendaftaran hanya dapat dilakukan oleh warga dan mitra yang sudah terverivikasi. Silahkan hubungi petugas atau kunjungi kantor untuk informasi lebih lanjut.', + 'Setelah pendaftaran lengkapi data diri untuk dapat melakukan sewa', style: TextStyle( fontSize: 13, color: AppColors.textPrimary, @@ -203,121 +203,32 @@ class RegistrationView extends GetView { } Widget _buildRegistrationForm() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Email Input - _buildInputLabel('Email'), - const SizedBox(height: 8), - _buildEmailField(), - const SizedBox(height: 20), - - // Password Input - _buildInputLabel('Password'), - const SizedBox(height: 8), - _buildPasswordField(), - const SizedBox(height: 20), - - // NIK Input - _buildInputLabel('NIK'), - const SizedBox(height: 8), - _buildNikField(), - const SizedBox(height: 20), - - // Phone Number Input - _buildInputLabel('No. Hp'), - const SizedBox(height: 8), - _buildPhoneField(), - const SizedBox(height: 20), - - // Role Selection Dropdown - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Daftar Sebagai', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.black87, - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey[300]!, width: 1), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Obx( - () => DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - value: controller.selectedRole.value, - hint: const Text('Pilih Peran'), - items: [ - DropdownMenuItem( - value: 'WARGA', - child: const Text('Warga'), - ), - DropdownMenuItem( - value: 'PETUGAS_MITRA', - child: const Text('Mitra'), - ), - ], - onChanged: (value) { - controller.setRole(value); - }, - icon: const Icon(Icons.arrow_drop_down), - style: const TextStyle( - color: Colors.black87, - fontSize: 14, - ), - ), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 20), - - // Error message - Obx( - () => - controller.errorMessage.value.isNotEmpty - ? Container( - margin: const EdgeInsets.only(top: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.errorLight, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon( - Icons.error_outline, - color: AppColors.error, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - controller.errorMessage.value, - style: TextStyle( - color: AppColors.error, - fontSize: 13, - ), - ), - ), - ], - ), - ) - : const SizedBox.shrink(), - ), - ], + return Form( + key: controller.formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildInputLabel('Email'), + _buildEmailField(), + const SizedBox(height: 16), + _buildInputLabel('Password'), + _buildPasswordField(), + const SizedBox(height: 16), + _buildInputLabel('Konfirmasi Password'), + _buildConfirmPasswordField(), + const SizedBox(height: 16), + _buildInputLabel('Nama Lengkap'), + _buildNameField(), + const SizedBox(height: 16), + _buildInputLabel('No HP'), + _buildPhoneField(), + const SizedBox(height: 16), + _buildInputLabel('Alamat Lengkap'), + _buildAlamatField(), + const SizedBox(height: 16), + // Removed: NIK, No HP, and Dropdown Daftar Sebagai + ], + ), ); } @@ -415,78 +326,101 @@ class RegistrationView extends GetView { ); } - Widget _buildNikField() { - return Container( - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: AppColors.shadow, - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: TextField( - onChanged: (value) => controller.nik.value = value, - keyboardType: TextInputType.number, - style: TextStyle(fontSize: 16, color: AppColors.textPrimary), + Widget _buildConfirmPasswordField() { + return Obx( + () => TextFormField( + controller: controller.confirmPasswordController, + obscureText: !controller.isConfirmPasswordVisible.value, decoration: InputDecoration( - hintText: 'Masukkan NIK anda', - hintStyle: TextStyle(color: AppColors.textLight), - prefixIcon: Icon( - Icons.credit_card_outlined, - color: AppColors.primary, + hintText: 'Masukkan ulang password anda', + suffixIcon: IconButton( + icon: Icon( + controller.isConfirmPasswordVisible.value + ? Icons.visibility + : Icons.visibility_off, + ), + onPressed: controller.toggleConfirmPasswordVisibility, ), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric(vertical: 16), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide(color: AppColors.primary, width: 1.5), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, ), ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Konfirmasi password tidak boleh kosong'; + } + if (value != controller.passwordController.text) { + return 'Password tidak cocok'; + } + return null; + }, ), ); } - Widget _buildPhoneField() { - return Container( - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: AppColors.shadow, - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: TextField( - onChanged: (value) => controller.phoneNumber.value = value, - keyboardType: TextInputType.phone, - style: TextStyle(fontSize: 16, color: AppColors.textPrimary), - decoration: InputDecoration( - hintText: 'Masukkan nomor HP anda', - hintStyle: TextStyle(color: AppColors.textLight), - prefixIcon: Icon(Icons.phone_outlined, color: AppColors.primary), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric(vertical: 16), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide(color: AppColors.primary, width: 1.5), - ), + Widget _buildNameField() { + return TextFormField( + controller: controller.nameController, + decoration: InputDecoration( + hintText: 'Masukkan nama lengkap anda', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, ), ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Nama lengkap tidak boleh kosong'; + } + return null; + }, + ); + } + + Widget _buildPhoneField() { + return TextFormField( + keyboardType: TextInputType.phone, + decoration: InputDecoration( + hintText: 'Masukkan nomor HP anda', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + onChanged: (value) => controller.phoneNumber.value = value, + validator: (value) { + if (value == null || value.isEmpty) { + return 'No HP tidak boleh kosong'; + } + if (!value.startsWith('08') || value.length < 10) { + return 'Nomor HP tidak valid (harus diawali 08 dan minimal 10 digit)'; + } + return null; + }, + ); + } + + Widget _buildAlamatField() { + return TextFormField( + decoration: InputDecoration( + hintText: 'Masukkan alamat lengkap anda', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + onChanged: (value) => controller.alamatLengkap.value = value, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Alamat lengkap tidak boleh kosong'; + } + return null; + }, ); } diff --git a/lib/app/modules/petugas_bumdes/bindings/petugas_aset_binding.dart b/lib/app/modules/petugas_bumdes/bindings/petugas_aset_binding.dart index 62f48f9..0e99e05 100644 --- a/lib/app/modules/petugas_bumdes/bindings/petugas_aset_binding.dart +++ b/lib/app/modules/petugas_bumdes/bindings/petugas_aset_binding.dart @@ -1,6 +1,7 @@ import 'package:get/get.dart'; import '../controllers/petugas_aset_controller.dart'; import '../controllers/petugas_bumdes_dashboard_controller.dart'; +import '../../../data/providers/aset_provider.dart'; class PetugasAsetBinding extends Bindings { @override @@ -10,6 +11,7 @@ class PetugasAsetBinding extends Bindings { Get.put(PetugasBumdesDashboardController(), permanent: true); } + Get.lazyPut(() => AsetProvider()); Get.lazyPut(() => PetugasAsetController()); } } diff --git a/lib/app/modules/petugas_bumdes/bindings/petugas_detail_sewa_binding.dart b/lib/app/modules/petugas_bumdes/bindings/petugas_detail_sewa_binding.dart index 1b6f5af..2c56ec0 100644 --- a/lib/app/modules/petugas_bumdes/bindings/petugas_detail_sewa_binding.dart +++ b/lib/app/modules/petugas_bumdes/bindings/petugas_detail_sewa_binding.dart @@ -1,9 +1,14 @@ import 'package:get/get.dart'; import '../controllers/petugas_sewa_controller.dart'; +import '../../../data/providers/aset_provider.dart'; class PetugasDetailSewaBinding extends Bindings { @override void dependencies() { + // Ensure AsetProvider is registered + if (!Get.isRegistered()) { + Get.put(AsetProvider(), permanent: true); + } // Memastikan controller sudah tersedia Get.lazyPut( () => PetugasSewaController(), diff --git a/lib/app/modules/petugas_bumdes/bindings/petugas_paket_binding.dart b/lib/app/modules/petugas_bumdes/bindings/petugas_paket_binding.dart index 90d94e3..8a6273b 100644 --- a/lib/app/modules/petugas_bumdes/bindings/petugas_paket_binding.dart +++ b/lib/app/modules/petugas_bumdes/bindings/petugas_paket_binding.dart @@ -1,15 +1,25 @@ import 'package:get/get.dart'; +import 'package:bumrent_app/app/data/providers/aset_provider.dart'; import '../controllers/petugas_paket_controller.dart'; import '../controllers/petugas_bumdes_dashboard_controller.dart'; class PetugasPaketBinding extends Bindings { @override void dependencies() { + // Register AsetProvider first + if (!Get.isRegistered()) { + Get.put(AsetProvider(), permanent: true); + } + // Ensure dashboard controller is registered if (!Get.isRegistered()) { Get.put(PetugasBumdesDashboardController(), permanent: true); } - Get.lazyPut(() => PetugasPaketController()); + // Register the controller + Get.lazyPut( + () => PetugasPaketController(), + fenix: true, + ); } } diff --git a/lib/app/modules/petugas_bumdes/bindings/petugas_sewa_binding.dart b/lib/app/modules/petugas_bumdes/bindings/petugas_sewa_binding.dart index eff7112..bea1bd7 100644 --- a/lib/app/modules/petugas_bumdes/bindings/petugas_sewa_binding.dart +++ b/lib/app/modules/petugas_bumdes/bindings/petugas_sewa_binding.dart @@ -1,9 +1,14 @@ import 'package:get/get.dart'; import '../controllers/petugas_sewa_controller.dart'; +import '../../../data/providers/aset_provider.dart'; class PetugasSewaBinding extends Bindings { @override void dependencies() { + // Ensure AsetProvider is registered + if (!Get.isRegistered()) { + Get.put(AsetProvider(), permanent: true); + } Get.lazyPut(() => PetugasSewaController()); } } diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_aset_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_aset_controller.dart index 2000970..5cb73b4 100644 --- a/lib/app/modules/petugas_bumdes/controllers/petugas_aset_controller.dart +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_aset_controller.dart @@ -1,6 +1,11 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import '../../../data/providers/aset_provider.dart'; +import '../../../data/models/aset_model.dart'; class PetugasAsetController extends GetxController { + final AsetProvider _asetProvider = Get.find(); // Observable lists for asset data final asetList = >[].obs; final filteredAsetList = >[].obs; @@ -27,95 +32,100 @@ class PetugasAsetController extends GetxController { loadAsetData(); } - // Load sample asset data (would be replaced with API call in production) + // Load asset data from AsetProvider Future loadAsetData() async { - isLoading.value = true; - try { - // Simulate API call with a delay - await Future.delayed(const Duration(seconds: 1)); + isLoading.value = true; + debugPrint('PetugasAsetController: Starting to load asset data...'); - // Sample assets data - final sampleData = [ - { - 'id': '1', - 'nama': 'Meja Rapat', - 'kategori': 'Furniture', - 'jenis': 'Sewa', // Added jenis field - 'harga': 50000, - 'satuan': 'per hari', - 'stok': 10, - 'deskripsi': - 'Meja rapat kayu jati ukuran besar untuk acara pertemuan', - 'gambar': 'https://example.com/meja.jpg', - 'tersedia': true, - }, - { - 'id': '2', - 'nama': 'Kursi Taman', - 'kategori': 'Furniture', - 'jenis': 'Sewa', // Added jenis field - 'harga': 10000, - 'satuan': 'per hari', - 'stok': 50, - 'deskripsi': 'Kursi taman plastik yang nyaman untuk acara outdoor', - 'gambar': 'https://example.com/kursi.jpg', - 'tersedia': true, - }, - { - 'id': '3', - 'nama': 'Proyektor', - 'kategori': 'Elektronik', - 'jenis': 'Sewa', // Added jenis field - 'harga': 100000, - 'satuan': 'per hari', - 'stok': 5, - 'deskripsi': 'Proyektor HD dengan brightness tinggi', - 'gambar': 'https://example.com/proyektor.jpg', - 'tersedia': true, - }, - { - 'id': '4', - 'nama': 'Sound System', - 'kategori': 'Elektronik', - 'jenis': 'Langganan', // Added jenis field - 'harga': 200000, - 'satuan': 'per bulan', - 'stok': 3, - 'deskripsi': 'Sound system lengkap dengan speaker dan mixer', - 'gambar': 'https://example.com/sound.jpg', - 'tersedia': false, - }, - { - 'id': '5', - 'nama': 'Mobil Pick Up', - 'kategori': 'Kendaraan', - 'jenis': 'Langganan', // Added jenis field - 'harga': 250000, - 'satuan': 'per bulan', - 'stok': 2, - 'deskripsi': 'Mobil pick up untuk mengangkut barang', - 'gambar': 'https://example.com/pickup.jpg', - 'tersedia': true, - }, - { - 'id': '6', - 'nama': 'Internet Fiber', - 'kategori': 'Elektronik', - 'jenis': 'Langganan', // Added jenis field - 'harga': 350000, - 'satuan': 'per bulan', - 'stok': 15, - 'deskripsi': 'Paket internet fiber 100Mbps untuk kantor', - 'gambar': 'https://example.com/internet.jpg', - 'tersedia': true, - }, - ]; + // Fetch data using AsetProvider + final asetData = await _asetProvider.getSewaAsets(); + debugPrint( + 'PetugasAsetController: Fetched ${asetData.length} assets from Supabase', + ); - asetList.assignAll(sampleData); - applyFilters(); // Apply default filters - } catch (e) { - print('Error loading asset data: $e'); + if (asetData.isEmpty) { + debugPrint('PetugasAsetController: No assets found in Supabase'); + } + + final List> mappedAsets = []; + int index = 0; // Initialize index counter + for (var aset in asetData) { + String displayKategori = 'Umum'; // Placeholder for descriptive category + // Attempt to derive a more specific category from description if needed, or add to AsetModel + if (aset.deskripsi.toLowerCase().contains('meja') || + aset.deskripsi.toLowerCase().contains('kursi')) { + displayKategori = 'Furniture'; + } else if (aset.deskripsi.toLowerCase().contains('proyektor') || + aset.deskripsi.toLowerCase().contains('sound') || + aset.deskripsi.toLowerCase().contains('internet')) { + displayKategori = 'Elektronik'; + } else if (aset.deskripsi.toLowerCase().contains('mobil') || + aset.deskripsi.toLowerCase().contains('kendaraan')) { + displayKategori = 'Kendaraan'; + } + + final map = { + 'id': aset.id, + 'nama': aset.nama, + 'deskripsi': aset.deskripsi, + 'harga': + aset.satuanWaktuSewa.isNotEmpty + ? aset.satuanWaktuSewa.first['harga'] + : 0, + 'status': aset.status, + 'kategori': displayKategori, + 'jenis': aset.jenis ?? 'Sewa', // Add this line with default value + 'imageUrl': aset.imageUrl ?? 'https://via.placeholder.com/150', + 'satuan_waktu': + aset.satuanWaktuSewa.isNotEmpty + ? aset.satuanWaktuSewa.first['nama_satuan_waktu'] ?? 'Hari' + : 'Hari', + 'satuanWaktuSewa': aset.satuanWaktuSewa.toList(), + }; + + debugPrint('Mapped asset #$index: $map'); + mappedAsets.add(map); + index++; + debugPrint('Deskripsi: ${aset.deskripsi}'); + debugPrint('Kategori (from AsetModel): ${aset.kategori}'); + debugPrint('Status: ${aset.status}'); + debugPrint('Mapped Kategori for Petugas View: ${map['kategori']}'); + debugPrint('Mapped Jenis for Petugas View: ${map['jenis']}'); + debugPrint('--------------------------------'); + } + + // Populate asetList with fetched data and apply filters + debugPrint( + 'PetugasAsetController: Mapped ${mappedAsets.length} assets for display', + ); + asetList.assignAll(mappedAsets); // Make data available to UI + debugPrint( + 'PetugasAsetController: asetList now has ${asetList.length} items', + ); + + applyFilters(); // Apply initial filters + debugPrint( + 'PetugasAsetController: Applied filters. filteredAsetList has ${filteredAsetList.length} items', + ); + + debugPrint( + 'PetugasAsetController: Data loading complete. Asset list populated and filters applied.', + ); + debugPrint( + 'PetugasAsetController: First asset name: ${mappedAsets.isNotEmpty ? mappedAsets[0]['nama'] : 'No assets'}', + ); + } catch (e, stackTrace) { + debugPrint('PetugasAsetController: Error loading asset data: $e'); + debugPrint('PetugasAsetController: StackTrace: $stackTrace'); + // Optionally, show a snackbar or error message to the user + Get.snackbar( + 'Error Memuat Data', + 'Gagal mengambil data aset dari server. Silakan coba lagi nanti.', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); } finally { isLoading.value = false; } @@ -170,8 +180,10 @@ class PetugasAsetController extends GetxController { } // Change tab (Sewa or Langganan) - void changeTab(int index) { + Future changeTab(int index) async { selectedTabIndex.value = index; + // Reload data when changing tabs to ensure we have the correct data for the selected tab + await loadAsetData(); applyFilters(); } diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_bumdes_dashboard_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_bumdes_dashboard_controller.dart index 41f55c5..9ddc359 100644 --- a/lib/app/modules/petugas_bumdes/controllers/petugas_bumdes_dashboard_controller.dart +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_bumdes_dashboard_controller.dart @@ -1,6 +1,10 @@ import 'package:get/get.dart'; import '../../../data/providers/auth_provider.dart'; import '../../../routes/app_routes.dart'; +import '../../../services/sewa_service.dart'; +import '../../../services/service_manager.dart'; +import '../../../data/models/pembayaran_model.dart'; +import '../../../services/pembayaran_service.dart'; class PetugasBumdesDashboardController extends GetxController { AuthProvider? _authProvider; @@ -8,6 +12,8 @@ class PetugasBumdesDashboardController extends GetxController { // Reactive variables final userEmail = ''.obs; final currentTabIndex = 0.obs; + final avatarUrl = ''.obs; + final userName = ''.obs; // Revenue Statistics final totalPendapatanBulanIni = 'Rp 8.500.000'.obs; @@ -20,7 +26,7 @@ class PetugasBumdesDashboardController extends GetxController { final persentaseSewa = 100.obs; // Revenue Trends (last 6 months) - final trendPendapatan = [4.2, 5.1, 4.8, 6.2, 7.2, 8.5].obs; // in millions + final trendPendapatan = [].obs; // 6 bulan terakhir // Status Counters for Sewa Aset final terlaksanaCount = 5.obs; @@ -43,42 +49,128 @@ class PetugasBumdesDashboardController extends GetxController { final tagihanAktifCountSewa = 7.obs; final periksaPembayaranCountSewa = 2.obs; + // Statistik pendapatan + final totalPendapatan = 0.obs; + final pendapatanBulanIni = 0.obs; + final pendapatanBulanLalu = 0.obs; + final pendapatanTunai = 0.obs; + final pendapatanTransfer = 0.obs; + final trenPendapatan = [].obs; // 6 bulan terakhir + + // Dashboard statistics + final pembayaranStats = {}.obs; + final isStatsLoading = true.obs; + @override void onInit() { super.onInit(); try { _authProvider = Get.find(); userEmail.value = _authProvider?.currentUser?.email ?? 'Tidak ada email'; + fetchPetugasAvatar(); + fetchPetugasName(); } catch (e) { print('Error finding AuthProvider: $e'); userEmail.value = 'Tidak ada email'; } - - // In a real app, these counts would be fetched from backend - // loadStatusCounts(); - print('āœ… PetugasBumdesDashboardController initialized successfully'); + print('\u2705 PetugasBumdesDashboardController initialized successfully'); + countSewaByStatus(); + fetchPembayaranStats(); } - // Method to load status counts from backend - // Future loadStatusCounts() async { - // try { - // final response = await _asetProvider.getSewaStatusCounts(); - // if (response != null) { - // terlaksanaCount.value = response['terlaksana'] ?? 0; - // dijadwalkanCount.value = response['dijadwalkan'] ?? 0; - // aktifCount.value = response['aktif'] ?? 0; - // dibatalkanCount.value = response['dibatalkan'] ?? 0; - // menungguPembayaranCount.value = response['menunggu_pembayaran'] ?? 0; - // periksaPembayaranCount.value = response['periksa_pembayaran'] ?? 0; - // diterimaCount.value = response['diterima'] ?? 0; - // pembayaranDendaCount.value = response['pembayaran_denda'] ?? 0; - // periksaPembayaranDendaCount.value = response['periksa_pembayaran_denda'] ?? 0; - // selesaiCount.value = response['selesai'] ?? 0; - // } - // } catch (e) { - // print('Error loading status counts: $e'); - // } - // } + Future countSewaByStatus() async { + try { + final data = await SewaService().fetchAllSewa(); + menungguPembayaranCount.value = + data.where((s) => s.status == 'MENUNGGU PEMBAYARAN').length; + periksaPembayaranCount.value = + data.where((s) => s.status == 'PERIKSA PEMBAYARAN').length; + diterimaCount.value = data.where((s) => s.status == 'DITERIMA').length; + pembayaranDendaCount.value = + data.where((s) => s.status == 'PEMBAYARAN DENDA').length; + periksaPembayaranDendaCount.value = + data.where((s) => s.status == 'PERIKSA PEMBAYARAN DENDA').length; + selesaiCount.value = data.where((s) => s.status == 'SELESAI').length; + print( + 'Count for MENUNGGU PEMBAYARAN: \\${menungguPembayaranCount.value}', + ); + print('Count for PERIKSA PEMBAYARAN: \\${periksaPembayaranCount.value}'); + print('Count for DITERIMA: \\${diterimaCount.value}'); + print('Count for PEMBAYARAN DENDA: \\${pembayaranDendaCount.value}'); + print( + 'Count for PERIKSA PEMBAYARAN DENDA: \\${periksaPembayaranDendaCount.value}', + ); + print('Count for SELESAI: \\${selesaiCount.value}'); + } catch (e) { + print('Error counting sewa by status: $e'); + } + } + + Future fetchPembayaranStats() async { + isStatsLoading.value = true; + try { + final stats = await PembayaranService().fetchStats(); + pembayaranStats.value = stats; + // Set trendPendapatan from stats['trendPerMonth'] if available + if (stats['trendPerMonth'] != null) { + trendPendapatan.value = List.from(stats['trendPerMonth']); + } + print('Pembayaran stats: $stats'); + } catch (e, st) { + print('Error fetching pembayaran stats: $e\n$st'); + pembayaranStats.value = {}; + trendPendapatan.value = []; + } + isStatsLoading.value = false; + } + + Future fetchPetugasAvatar() async { + try { + final userId = _authProvider?.getCurrentUserId(); + if (userId == null) return; + final client = _authProvider!.client; + final data = + await client + .from('petugas_bumdes') + .select('avatar') + .eq('id', userId) + .maybeSingle(); + if (data != null && + data['avatar'] != null && + data['avatar'].toString().isNotEmpty) { + avatarUrl.value = data['avatar'].toString(); + } else { + avatarUrl.value = ''; + } + } catch (e) { + print('Error fetching petugas avatar: $e'); + avatarUrl.value = ''; + } + } + + Future fetchPetugasName() async { + try { + final userId = _authProvider?.getCurrentUserId(); + if (userId == null) return; + final client = _authProvider!.client; + final data = + await client + .from('petugas_bumdes') + .select('nama') + .eq('id', userId) + .maybeSingle(); + if (data != null && + data['nama'] != null && + data['nama'].toString().isNotEmpty) { + userName.value = data['nama'].toString(); + } else { + userName.value = ''; + } + } catch (e) { + print('Error fetching petugas name: $e'); + userName.value = ''; + } + } void changeTab(int index) { try { diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_paket_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_paket_controller.dart index d5a25c2..90e22b2 100644 --- a/lib/app/modules/petugas_bumdes/controllers/petugas_paket_controller.dart +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_paket_controller.dart @@ -1,24 +1,24 @@ +import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:intl/intl.dart'; +import 'package:intl/intl.dart' show NumberFormat; +import 'package:logger/logger.dart'; +import 'package:bumrent_app/app/data/models/paket_model.dart'; +import 'package:bumrent_app/app/data/providers/aset_provider.dart'; class PetugasPaketController extends GetxController { - final isLoading = false.obs; - final searchQuery = ''.obs; - final selectedCategory = 'Semua'.obs; - final sortBy = 'Terbaru'.obs; - - // Kategori untuk filter - final categories = [ - 'Semua', - 'Pesta', - 'Rapat', - 'Olahraga', - 'Pernikahan', - 'Lainnya', - ]; - - // Opsi pengurutan - final sortOptions = [ + // Dependencies + final AsetProvider _asetProvider = Get.find(); + + // State + final RxBool isLoading = false.obs; + final RxString searchQuery = ''.obs; + final RxString selectedCategory = 'Semua'.obs; + final RxString sortBy = 'Terbaru'.obs; + final RxList packages = [].obs; + final RxList filteredPackages = [].obs; + + // Sort options for the dropdown + final List sortOptions = [ 'Terbaru', 'Terlama', 'Harga Tertinggi', @@ -26,175 +26,221 @@ class PetugasPaketController extends GetxController { 'Nama A-Z', 'Nama Z-A', ]; - - // Data dummy paket - final paketList = >[].obs; - final filteredPaketList = >[].obs; - + + // For backward compatibility + final RxList> paketList = >[].obs; + final RxList> filteredPaketList = >[].obs; + + // Logger + late final Logger _logger; + @override void onInit() { super.onInit(); - loadPaketData(); - } - - // Format harga ke Rupiah - String formatPrice(int price) { - final formatter = NumberFormat.currency( - locale: 'id', - symbol: 'Rp ', - decimalDigits: 0, + + // Initialize logger + _logger = Logger( + printer: PrettyPrinter( + methodCount: 0, + errorMethodCount: 5, + colors: true, + printEmojis: true, + ), ); - return formatter.format(price); + + // Load initial data + fetchPackages(); } - - // Load data paket dummy - Future loadPaketData() async { - isLoading.value = true; - await Future.delayed(const Duration(milliseconds: 800)); // Simulasi loading - - paketList.value = [ - { - 'id': '1', - 'nama': 'Paket Pesta Ulang Tahun', - 'kategori': 'Pesta', - 'harga': 500000, - 'deskripsi': - 'Paket lengkap untuk acara ulang tahun. Termasuk 5 meja, 20 kursi, backdrop, dan sound system.', - 'tersedia': true, - 'created_at': '2023-08-10', - 'items': [ - {'nama': 'Meja Panjang', 'jumlah': 5}, - {'nama': 'Kursi Plastik', 'jumlah': 20}, - {'nama': 'Sound System', 'jumlah': 1}, - {'nama': 'Backdrop', 'jumlah': 1}, - ], - 'gambar': 'https://example.com/images/paket_ultah.jpg', - }, - { - 'id': '2', - 'nama': 'Paket Rapat Sedang', - 'kategori': 'Rapat', - 'harga': 300000, - 'deskripsi': - 'Paket untuk rapat sedang. Termasuk 1 meja rapat besar, 10 kursi, proyektor, dan screen.', - 'tersedia': true, - 'created_at': '2023-09-05', - 'items': [ - {'nama': 'Meja Rapat', 'jumlah': 1}, - {'nama': 'Kursi Kantor', 'jumlah': 10}, - {'nama': 'Proyektor', 'jumlah': 1}, - {'nama': 'Screen', 'jumlah': 1}, - ], - 'gambar': 'https://example.com/images/paket_rapat.jpg', - }, - { - 'id': '3', - 'nama': 'Paket Pesta Pernikahan', - 'kategori': 'Pernikahan', - 'harga': 1500000, - 'deskripsi': - 'Paket lengkap untuk acara pernikahan. Termasuk 20 meja, 100 kursi, sound system, dekorasi, dan tenda.', - 'tersedia': true, - 'created_at': '2023-10-12', - 'items': [ - {'nama': 'Meja Bundar', 'jumlah': 20}, - {'nama': 'Kursi Tamu', 'jumlah': 100}, - {'nama': 'Sound System Besar', 'jumlah': 1}, - {'nama': 'Tenda 10x10', 'jumlah': 2}, - {'nama': 'Set Dekorasi Pengantin', 'jumlah': 1}, - ], - 'gambar': 'https://example.com/images/paket_nikah.jpg', - }, - { - 'id': '4', - 'nama': 'Paket Olahraga Voli', - 'kategori': 'Olahraga', - 'harga': 200000, - 'deskripsi': - 'Paket perlengkapan untuk turnamen voli. Termasuk net, bola, dan tiang voli.', - 'tersedia': false, - 'created_at': '2023-07-22', - 'items': [ - {'nama': 'Net Voli', 'jumlah': 1}, - {'nama': 'Bola Voli', 'jumlah': 3}, - {'nama': 'Tiang Voli', 'jumlah': 2}, - ], - 'gambar': 'https://example.com/images/paket_voli.jpg', - }, - { - 'id': '5', - 'nama': 'Paket Pesta Anak', - 'kategori': 'Pesta', - 'harga': 350000, - 'deskripsi': - 'Paket untuk pesta ulang tahun anak-anak. Termasuk 3 meja, 15 kursi, dekorasi tema, dan sound system kecil.', - 'tersedia': true, - 'created_at': '2023-11-01', - 'items': [ - {'nama': 'Meja Anak', 'jumlah': 3}, - {'nama': 'Kursi Anak', 'jumlah': 15}, - {'nama': 'Set Dekorasi Tema', 'jumlah': 1}, - {'nama': 'Sound System Kecil', 'jumlah': 1}, - ], - 'gambar': 'https://example.com/images/paket_anak.jpg', - }, - ]; - - filterPaket(); - isLoading.value = false; - } - - // Filter paket berdasarkan search query dan kategori - void filterPaket() { - filteredPaketList.value = - paketList.where((paket) { - final matchesQuery = - paket['nama'].toString().toLowerCase().contains( - searchQuery.value.toLowerCase(), - ) || - paket['deskripsi'].toString().toLowerCase().contains( - searchQuery.value.toLowerCase(), - ); - - final matchesCategory = - selectedCategory.value == 'Semua' || - paket['kategori'] == selectedCategory.value; - - return matchesQuery && matchesCategory; - }).toList(); - - // Sort the filtered list - sortFilteredList(); - } - - // Sort the filtered list - void sortFilteredList() { - switch (sortBy.value) { - case 'Terbaru': - filteredPaketList.sort( - (a, b) => b['created_at'].compareTo(a['created_at']), - ); - break; - case 'Terlama': - filteredPaketList.sort( - (a, b) => a['created_at'].compareTo(b['created_at']), - ); - break; - case 'Harga Tertinggi': - filteredPaketList.sort((a, b) => b['harga'].compareTo(a['harga'])); - break; - case 'Harga Terendah': - filteredPaketList.sort((a, b) => a['harga'].compareTo(b['harga'])); - break; - case 'Nama A-Z': - filteredPaketList.sort((a, b) => a['nama'].compareTo(b['nama'])); - break; - case 'Nama Z-A': - filteredPaketList.sort((a, b) => b['nama'].compareTo(a['nama'])); - break; + + /// Fetch packages from the API + Future fetchPackages() async { + try { + isLoading.value = true; + _logger.i('šŸ”„ [fetchPackages] Fetching packages...'); + + final result = await _asetProvider.getAllPaket(); + + if (result.isEmpty) { + _logger.w('ā„¹ļø [fetchPackages] No packages found'); + packages.clear(); + filteredPackages.clear(); + return; + } + + packages.assignAll(result); + filteredPackages.assignAll(result); + + // Update legacy list for backward compatibility + _updateLegacyPaketList(); + + _logger.i('āœ… [fetchPackages] Successfully loaded ${result.length} packages'); + + } catch (e, stackTrace) { + _logger.e('āŒ [fetchPackages] Error fetching packages', + error: e, + stackTrace: stackTrace); + + Get.snackbar( + 'Error', + 'Gagal memuat data paket. Silakan coba lagi.', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + isLoading.value = false; } } - + + /// Update legacy paketList for backward compatibility + void _updateLegacyPaketList() { + try { + _logger.d('šŸ”„ [_updateLegacyPaketList] Updating legacy paketList...'); + + final List> legacyList = packages.map((pkg) { + return { + 'id': pkg.id, + 'nama': pkg.nama, + 'deskripsi': pkg.deskripsi, + 'harga': pkg.harga, + 'kuantitas': pkg.kuantitas, + 'status': pkg.status, // Add status to legacy mapping + 'foto': pkg.foto, + 'foto_paket': pkg.foto_paket, + 'images': pkg.images, + 'satuanWaktuSewa': pkg.satuanWaktuSewa, + 'created_at': pkg.createdAt, + 'updated_at': pkg.updatedAt, + }; + }).toList(); + + paketList.assignAll(legacyList); + filteredPaketList.assignAll(legacyList); + + _logger.d('āœ… [_updateLegacyPaketList] Updated ${legacyList.length} packages'); + + } catch (e, stackTrace) { + _logger.e('āŒ [_updateLegacyPaketList] Error updating legacy list', + error: e, + stackTrace: stackTrace); + } + } + + /// For backward compatibility + Future loadPaketData() async { + _logger.d('ā„¹ļø [loadPaketData] Using fetchPackages() instead'); + await fetchPackages(); + } + + /// Filter packages based on search query and category + void filterPaket() { + try { + _logger.d('šŸ”„ [filterPaket] Filtering packages...'); + + if (searchQuery.value.isEmpty && selectedCategory.value == 'Semua') { + filteredPackages.value = List.from(packages); + filteredPaketList.value = List.from(paketList); + } else { + // Filter new packages + filteredPackages.value = packages.where((paket) { + final matchesSearch = searchQuery.value.isEmpty || + paket.nama.toLowerCase().contains(searchQuery.value.toLowerCase()); + + // For now, we're not using categories in the new model + // You can add category filtering if needed + final matchesCategory = selectedCategory.value == 'Semua'; + + return matchesSearch && matchesCategory; + }).toList(); + + // Also update legacy list for backward compatibility + filteredPaketList.value = paketList.where((paket) { + final matchesSearch = searchQuery.value.isEmpty || + (paket['nama']?.toString() ?? '').toLowerCase() + .contains(searchQuery.value.toLowerCase()); + + // For legacy support, check if category exists + final matchesCategory = selectedCategory.value == 'Semua' || + (paket['kategori']?.toString() ?? '') == selectedCategory.value; + + return matchesSearch && matchesCategory; + }).toList(); + } + + sortFilteredList(); + _logger.d('āœ… [filterPaket] Filtered to ${filteredPackages.length} packages'); + + } catch (e, stackTrace) { + _logger.e('āŒ [filterPaket] Error filtering packages', + error: e, + stackTrace: stackTrace); + } + } + + /// Sort the filtered list based on the selected sort option + void sortFilteredList() { + try { + _logger.d('šŸ”„ [sortFilteredList] Sorting packages by ${sortBy.value}'); + + // Sort new packages + switch (sortBy.value) { + case 'Terbaru': + filteredPackages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + break; + case 'Terlama': + filteredPackages.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + break; + case 'Harga Tertinggi': + filteredPackages.sort((a, b) => b.harga.compareTo(a.harga)); + break; + case 'Harga Terendah': + filteredPackages.sort((a, b) => a.harga.compareTo(b.harga)); + break; + case 'Nama A-Z': + filteredPackages.sort((a, b) => a.nama.compareTo(b.nama)); + break; + case 'Nama Z-A': + filteredPackages.sort((a, b) => b.nama.compareTo(a.nama)); + break; + } + + // Also sort legacy list for backward compatibility + switch (sortBy.value) { + case 'Terbaru': + filteredPaketList.sort((a, b) => + ((b['created_at'] ?? '') as String).compareTo((a['created_at'] ?? '') as String)); + break; + case 'Terlama': + filteredPaketList.sort((a, b) => + ((a['created_at'] ?? '') as String).compareTo((b['created_at'] ?? '') as String)); + break; + case 'Harga Tertinggi': + filteredPaketList.sort((a, b) => + ((b['harga'] ?? 0) as int).compareTo((a['harga'] ?? 0) as int)); + break; + case 'Harga Terendah': + filteredPaketList.sort((a, b) => + ((a['harga'] ?? 0) as int).compareTo((b['harga'] ?? 0) as int)); + break; + case 'Nama A-Z': + filteredPaketList.sort((a, b) => + ((a['nama'] ?? '') as String).compareTo((b['nama'] ?? '') as String)); + break; + case 'Nama Z-A': + filteredPaketList.sort((a, b) => + ((b['nama'] ?? '') as String).compareTo((a['nama'] ?? '') as String)); + break; + } + + _logger.d('āœ… [sortFilteredList] Sorted ${filteredPackages.length} packages'); + + } catch (e, stackTrace) { + _logger.e('āŒ [sortFilteredList] Error sorting packages', + error: e, + stackTrace: stackTrace); + } + } + // Set search query dan filter paket void setSearchQuery(String query) { searchQuery.value = query; @@ -214,40 +260,134 @@ class PetugasPaketController extends GetxController { } // Tambah paket baru - void addPaket(Map paket) { - paketList.add(paket); - filterPaket(); - Get.back(); - Get.snackbar( - 'Sukses', - 'Paket baru berhasil ditambahkan', - snackPosition: SnackPosition.BOTTOM, - ); - } - - // Edit paket - void editPaket(String id, Map updatedPaket) { - final index = paketList.indexWhere((element) => element['id'] == id); - if (index >= 0) { - paketList[index] = updatedPaket; + Future addPaket(Map paketData) async { + try { + isLoading.value = true; + + // Convert to PaketModel + final newPaket = PaketModel.fromJson({ + ...paketData, + 'id': DateTime.now().millisecondsSinceEpoch.toString(), + 'created_at': DateTime.now().toIso8601String(), + 'updated_at': DateTime.now().toIso8601String(), + }); + + // Add to the list + packages.add(newPaket); + _updateLegacyPaketList(); filterPaket(); + Get.back(); Get.snackbar( 'Sukses', - 'Paket berhasil diperbarui', + 'Paket baru berhasil ditambahkan', snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, ); + + } catch (e, stackTrace) { + _logger.e('āŒ [addPaket] Error adding package', + error: e, + stackTrace: stackTrace); + + Get.snackbar( + 'Error', + 'Gagal menambahkan paket. Silakan coba lagi.', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + isLoading.value = false; + } + } + + // Edit paket + Future editPaket(String id, Map updatedData) async { + try { + isLoading.value = true; + + final index = packages.indexWhere((pkg) => pkg.id == id); + if (index >= 0) { + // Update the package + final updatedPaket = packages[index].copyWith( + nama: updatedData['nama']?.toString() ?? packages[index].nama, + deskripsi: updatedData['deskripsi']?.toString() ?? packages[index].deskripsi, + kuantitas: (updatedData['kuantitas'] is int) + ? updatedData['kuantitas'] + : (int.tryParse(updatedData['kuantitas']?.toString() ?? '0') ?? packages[index].kuantitas), + updatedAt: DateTime.now(), + ); + + packages[index] = updatedPaket; + _updateLegacyPaketList(); + filterPaket(); + + Get.back(); + Get.snackbar( + 'Sukses', + 'Paket berhasil diperbarui', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + } + } catch (e, stackTrace) { + _logger.e('āŒ [editPaket] Error updating package', + error: e, + stackTrace: stackTrace); + + Get.snackbar( + 'Error', + 'Gagal memperbarui paket. Silakan coba lagi.', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + isLoading.value = false; } } // Hapus paket - void deletePaket(String id) { - paketList.removeWhere((element) => element['id'] == id); - filterPaket(); - Get.snackbar( - 'Sukses', - 'Paket berhasil dihapus', - snackPosition: SnackPosition.BOTTOM, - ); + Future deletePaket(String id) async { + try { + isLoading.value = true; + + // Remove from the main list + packages.removeWhere((pkg) => pkg.id == id); + _updateLegacyPaketList(); + filterPaket(); + + Get.back(); + Get.snackbar( + 'Sukses', + 'Paket berhasil dihapus', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + + } catch (e, stackTrace) { + _logger.e('āŒ [deletePaket] Error deleting package', + error: e, + stackTrace: stackTrace); + + Get.snackbar( + 'Error', + 'Gagal menghapus paket. Silakan coba lagi.', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + isLoading.value = false; + } + } + + /// Format price to Rupiah currency + String formatPrice(num price) { + return 'Rp ${NumberFormat('#,##0', 'id_ID').format(price)}'; } } diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_sewa_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_sewa_controller.dart index ed22801..377f019 100644 --- a/lib/app/modules/petugas_bumdes/controllers/petugas_sewa_controller.dart +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_sewa_controller.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import '../../../services/sewa_service.dart'; +import '../../../data/models/rental_booking_model.dart'; +import '../../../data/providers/aset_provider.dart'; class PetugasSewaController extends GetxController { // Reactive variables @@ -7,7 +10,7 @@ class PetugasSewaController extends GetxController { final searchQuery = ''.obs; final orderIdQuery = ''.obs; final selectedStatusFilter = 'Semua'.obs; - final filteredSewaList = >[].obs; + final filteredSewaList = [].obs; // Filter options final List statusFilters = [ @@ -15,13 +18,19 @@ class PetugasSewaController extends GetxController { 'Menunggu Pembayaran', 'Periksa Pembayaran', 'Diterima', + 'Aktif', 'Dikembalikan', 'Selesai', 'Dibatalkan', ]; // Mock data for sewa list - final RxList> sewaList = >[].obs; + final RxList sewaList = [].obs; + + // Payment option state (per sewa) + final Map isFullPaymentMap = {}; + final Map nominalControllerMap = {}; + final Map paymentMethodMap = {}; @override void onInit() { @@ -41,25 +50,21 @@ class PetugasSewaController extends GetxController { void _updateFilteredList() { filteredSewaList.value = sewaList.where((sewa) { - // Apply search filter - final matchesSearch = sewa['nama_warga'] - .toString() - .toLowerCase() - .contains(searchQuery.value.toLowerCase()); - - // Apply order ID filter if provided - final matchesOrderId = - orderIdQuery.value.isEmpty || - sewa['order_id'].toString().toLowerCase().contains( - orderIdQuery.value.toLowerCase(), - ); + final query = searchQuery.value.toLowerCase(); + // Apply search filter: nama warga, id pesanan, atau asetId + final matchesSearch = + sewa.wargaNama.toLowerCase().contains(query) || + sewa.id.toLowerCase().contains(query) || + (sewa.asetId != null && + sewa.asetId!.toLowerCase().contains(query)); // Apply status filter if not 'Semua' final matchesStatus = selectedStatusFilter.value == 'Semua' || - sewa['status'] == selectedStatusFilter.value; + sewa.status.toUpperCase() == + selectedStatusFilter.value.toUpperCase(); - return matchesSearch && matchesOrderId && matchesStatus; + return matchesSearch && matchesStatus; }).toList(); } @@ -68,100 +73,8 @@ class PetugasSewaController extends GetxController { isLoading.value = true; try { - // Simulate API call delay - await Future.delayed(const Duration(milliseconds: 800)); - - // Populate with mock data - sewaList.assignAll([ - { - 'id': '1', - 'order_id': 'SWA-001', - 'nama_warga': 'Sukimin', - 'nama_aset': 'Mobil Pickup', - 'tanggal_mulai': '2025-02-05', - 'tanggal_selesai': '2025-02-10', - 'total_biaya': 45000, - 'status': 'Diterima', - 'photo_url': 'https://example.com/photo1.jpg', - }, - { - 'id': '2', - 'order_id': 'SWA-002', - 'nama_warga': 'Sukimin', - 'nama_aset': 'Mobil Pickup', - 'tanggal_mulai': '2025-02-15', - 'tanggal_selesai': '2025-02-20', - 'total_biaya': 30000, - 'status': 'Selesai', - 'photo_url': 'https://example.com/photo2.jpg', - }, - { - 'id': '3', - 'order_id': 'SWA-003', - 'nama_warga': 'Sukimin', - 'nama_aset': 'Mobil Pickup', - 'tanggal_mulai': '2025-02-25', - 'tanggal_selesai': '2025-03-01', - 'total_biaya': 35000, - 'status': 'Menunggu Pembayaran', - 'photo_url': 'https://example.com/photo3.jpg', - }, - { - 'id': '4', - 'order_id': 'SWA-004', - 'nama_warga': 'Sukimin', - 'nama_aset': 'Mobil Pickup', - 'tanggal_mulai': '2025-03-05', - 'tanggal_selesai': '2025-03-08', - 'total_biaya': 20000, - 'status': 'Periksa Pembayaran', - 'photo_url': 'https://example.com/photo4.jpg', - }, - { - 'id': '5', - 'order_id': 'SWA-005', - 'nama_warga': 'Sukimin', - 'nama_aset': 'Mobil Pickup', - 'tanggal_mulai': '2025-03-12', - 'tanggal_selesai': '2025-03-14', - 'total_biaya': 15000, - 'status': 'Dibatalkan', - 'photo_url': 'https://example.com/photo5.jpg', - }, - { - 'id': '6', - 'order_id': 'SWA-006', - 'nama_warga': 'Sukimin', - 'nama_aset': 'Mobil Pickup', - 'tanggal_mulai': '2025-03-18', - 'tanggal_selesai': '2025-03-20', - 'total_biaya': 25000, - 'status': 'Pembayaran Denda', - 'photo_url': 'https://example.com/photo6.jpg', - }, - { - 'id': '7', - 'order_id': 'SWA-007', - 'nama_warga': 'Sukimin', - 'nama_aset': 'Mobil Pickup', - 'tanggal_mulai': '2025-03-25', - 'tanggal_selesai': '2025-03-28', - 'total_biaya': 40000, - 'status': 'Periksa Denda', - 'photo_url': 'https://example.com/photo7.jpg', - }, - { - 'id': '8', - 'order_id': 'SWA-008', - 'nama_warga': 'Sukimin', - 'nama_aset': 'Mobil Pickup', - 'tanggal_mulai': '2025-04-02', - 'tanggal_selesai': '2025-04-05', - 'total_biaya': 10000, - 'status': 'Dikembalikan', - 'photo_url': 'https://example.com/photo8.jpg', - }, - ]); + final data = await SewaService().fetchAllSewa(); + sewaList.assignAll(data); } catch (e) { print('Error loading sewa data: $e'); } finally { @@ -196,10 +109,11 @@ class PetugasSewaController extends GetxController { sewaList.where((sewa) { bool matchesStatus = selectedStatusFilter.value == 'Semua' || - sewa['status'] == selectedStatusFilter.value; + sewa.status.toUpperCase() == + selectedStatusFilter.value.toUpperCase(); bool matchesSearch = searchQuery.value.isEmpty || - sewa['nama_warga'].toLowerCase().contains( + sewa.wargaNama.toLowerCase().contains( searchQuery.value.toLowerCase(), ); return matchesStatus && matchesSearch; @@ -213,102 +127,367 @@ class PetugasSewaController extends GetxController { // Get color based on status Color getStatusColor(String status) { - switch (status) { - case 'Menunggu Pembayaran': - return Colors.orange; - case 'Periksa Pembayaran': - return Colors.amber.shade700; - case 'Diterima': - return Colors.blue; - case 'Pembayaran Denda': - return Colors.deepOrange; - case 'Periksa Denda': - return Colors.red.shade600; - case 'Dikembalikan': - return Colors.teal; - case 'Sedang Disewa': + switch (status.toUpperCase()) { + case 'MENUNGGU PEMBAYARAN': + return Colors.orangeAccent; + case 'PERIKSA PEMBAYARAN': + return Colors.amber; + case 'DITERIMA': + return Colors.blueAccent; + case 'AKTIF': return Colors.green; - case 'Selesai': + case 'PEMBAYARAN DENDA': + return Colors.deepOrangeAccent; + case 'PERIKSA PEMBAYARAN DENDA': + return Colors.redAccent; + case 'DIKEMBALIKAN': + return Colors.teal; + case 'SELESAI': return Colors.purple; - case 'Dibatalkan': + case 'DIBATALKAN': return Colors.red; default: return Colors.grey; } } + // Get icon based on status + IconData getStatusIcon(String status) { + switch (status) { + case 'MENUNGGU PEMBAYARAN': + return Icons.payments_outlined; + case 'PERIKSA PEMBAYARAN': + return Icons.fact_check_outlined; + case 'DITERIMA': + return Icons.check_circle_outlined; + case 'AKTIF': + return Icons.play_circle_outline; + case 'PEMBYARAN DENDA': + return Icons.money_off_csred_outlined; + case 'PERIKSA PEMBAYARAN DENDA': + return Icons.assignment_late_outlined; + case 'DIKEMBALIKAN': + return Icons.assignment_return_outlined; + case 'SELESAI': + return Icons.task_alt_outlined; + case 'DIBATALKAN': + return Icons.cancel_outlined; + default: + return Icons.help_outline_rounded; + } + } + // Handle sewa approval (from "Periksa Pembayaran" to "Diterima") void approveSewa(String id) { - final index = sewaList.indexWhere((sewa) => sewa['id'] == id); + final index = sewaList.indexWhere((sewa) => sewa.id == id); if (index != -1) { - final sewa = Map.from(sewaList[index]); - final currentStatus = sewa['status']; - - if (currentStatus == 'Periksa Pembayaran') { - sewa['status'] = 'Diterima'; - } else if (currentStatus == 'Periksa Denda') { - sewa['status'] = 'Selesai'; - } else if (currentStatus == 'Menunggu Pembayaran') { - sewa['status'] = 'Periksa Pembayaran'; + final sewa = sewaList[index]; + final currentStatus = sewa.status; + String? newStatus; + if (currentStatus == 'PERIKSA PEMBAYARAN') { + newStatus = 'DITERIMA'; + } else if (currentStatus == 'PERIKSA PEMBAYARAN DENDA') { + newStatus = 'SELESAI'; + } else if (currentStatus == 'MENUNGGU PEMBAYARAN') { + newStatus = 'PERIKSA PEMBAYARAN'; + } + if (newStatus != null) { + sewaList[index] = SewaModel( + id: sewa.id, + userId: sewa.userId, + status: newStatus, + waktuMulai: sewa.waktuMulai, + waktuSelesai: sewa.waktuSelesai, + tanggalPemesanan: sewa.tanggalPemesanan, + tipePesanan: sewa.tipePesanan, + kuantitas: sewa.kuantitas, + asetId: sewa.asetId, + asetNama: sewa.asetNama, + asetFoto: sewa.asetFoto, + paketId: sewa.paketId, + paketNama: sewa.paketNama, + paketFoto: sewa.paketFoto, + totalTagihan: sewa.totalTagihan, + wargaNama: sewa.wargaNama, + wargaNoHp: sewa.wargaNoHp, + wargaAvatar: sewa.wargaAvatar, + ); + sewaList.refresh(); } - - sewaList[index] = sewa; - sewaList.refresh(); } } // Handle sewa rejection or cancellation void rejectSewa(String id) { - final index = sewaList.indexWhere((sewa) => sewa['id'] == id); + final index = sewaList.indexWhere((sewa) => sewa.id == id); if (index != -1) { - final sewa = Map.from(sewaList[index]); - sewa['status'] = 'Dibatalkan'; - sewaList[index] = sewa; + final sewa = sewaList[index]; + sewaList[index] = SewaModel( + id: sewa.id, + userId: sewa.userId, + status: 'Dibatalkan', + waktuMulai: sewa.waktuMulai, + waktuSelesai: sewa.waktuSelesai, + tanggalPemesanan: sewa.tanggalPemesanan, + tipePesanan: sewa.tipePesanan, + kuantitas: sewa.kuantitas, + asetId: sewa.asetId, + asetNama: sewa.asetNama, + asetFoto: sewa.asetFoto, + paketId: sewa.paketId, + paketNama: sewa.paketNama, + paketFoto: sewa.paketFoto, + totalTagihan: sewa.totalTagihan, + wargaNama: sewa.wargaNama, + wargaNoHp: sewa.wargaNoHp, + wargaAvatar: sewa.wargaAvatar, + ); sewaList.refresh(); } } // Request payment for penalty void requestPenaltyPayment(String id) { - final index = sewaList.indexWhere((sewa) => sewa['id'] == id); + final index = sewaList.indexWhere((sewa) => sewa.id == id); if (index != -1) { - final sewa = Map.from(sewaList[index]); - sewa['status'] = 'Pembayaran Denda'; - sewaList[index] = sewa; + final sewa = sewaList[index]; + sewaList[index] = SewaModel( + id: sewa.id, + userId: sewa.userId, + status: 'Pembayaran Denda', + waktuMulai: sewa.waktuMulai, + waktuSelesai: sewa.waktuSelesai, + tanggalPemesanan: sewa.tanggalPemesanan, + tipePesanan: sewa.tipePesanan, + kuantitas: sewa.kuantitas, + asetId: sewa.asetId, + asetNama: sewa.asetNama, + asetFoto: sewa.asetFoto, + paketId: sewa.paketId, + paketNama: sewa.paketNama, + paketFoto: sewa.paketFoto, + totalTagihan: sewa.totalTagihan, + wargaNama: sewa.wargaNama, + wargaNoHp: sewa.wargaNoHp, + wargaAvatar: sewa.wargaAvatar, + ); sewaList.refresh(); } } // Mark penalty payment as requiring inspection void markPenaltyForInspection(String id) { - final index = sewaList.indexWhere((sewa) => sewa['id'] == id); + final index = sewaList.indexWhere((sewa) => sewa.id == id); if (index != -1) { - final sewa = Map.from(sewaList[index]); - sewa['status'] = 'Periksa Denda'; - sewaList[index] = sewa; + final sewa = sewaList[index]; + sewaList[index] = SewaModel( + id: sewa.id, + userId: sewa.userId, + status: 'Periksa Denda', + waktuMulai: sewa.waktuMulai, + waktuSelesai: sewa.waktuSelesai, + tanggalPemesanan: sewa.tanggalPemesanan, + tipePesanan: sewa.tipePesanan, + kuantitas: sewa.kuantitas, + asetId: sewa.asetId, + asetNama: sewa.asetNama, + asetFoto: sewa.asetFoto, + paketId: sewa.paketId, + paketNama: sewa.paketNama, + paketFoto: sewa.paketFoto, + totalTagihan: sewa.totalTagihan, + wargaNama: sewa.wargaNama, + wargaNoHp: sewa.wargaNoHp, + wargaAvatar: sewa.wargaAvatar, + ); sewaList.refresh(); } } // Handle sewa completion - void completeSewa(String id) { - final index = sewaList.indexWhere((sewa) => sewa['id'] == id); + void completeSewa(String id) async { + final index = sewaList.indexWhere((sewa) => sewa.id == id); if (index != -1) { - final sewa = Map.from(sewaList[index]); - sewa['status'] = 'Selesai'; - sewaList[index] = sewa; + final sewa = sewaList[index]; + sewaList[index] = SewaModel( + id: sewa.id, + userId: sewa.userId, + status: 'Selesai', + waktuMulai: sewa.waktuMulai, + waktuSelesai: sewa.waktuSelesai, + tanggalPemesanan: sewa.tanggalPemesanan, + tipePesanan: sewa.tipePesanan, + kuantitas: sewa.kuantitas, + asetId: sewa.asetId, + asetNama: sewa.asetNama, + asetFoto: sewa.asetFoto, + paketId: sewa.paketId, + paketNama: sewa.paketNama, + paketFoto: sewa.paketFoto, + totalTagihan: sewa.totalTagihan, + wargaNama: sewa.wargaNama, + wargaNoHp: sewa.wargaNoHp, + wargaAvatar: sewa.wargaAvatar, + ); sewaList.refresh(); + // Update status in database + final asetProvider = Get.find(); + await asetProvider.updateSewaAsetStatus( + sewaAsetId: id, + status: 'SELESAI', + ); } } // Mark rental as returned - void markAsReturned(String id) { - final index = sewaList.indexWhere((sewa) => sewa['id'] == id); + Future markAsReturned(String id) async { + final index = sewaList.indexWhere((sewa) => sewa.id == id); if (index != -1) { - final sewa = Map.from(sewaList[index]); - sewa['status'] = 'Dikembalikan'; - sewaList[index] = sewa; + final sewa = sewaList[index]; + sewaList[index] = SewaModel( + id: sewa.id, + userId: sewa.userId, + status: 'Dikembalikan', + waktuMulai: sewa.waktuMulai, + waktuSelesai: sewa.waktuSelesai, + tanggalPemesanan: sewa.tanggalPemesanan, + tipePesanan: sewa.tipePesanan, + kuantitas: sewa.kuantitas, + asetId: sewa.asetId, + asetNama: sewa.asetNama, + asetFoto: sewa.asetFoto, + paketId: sewa.paketId, + paketNama: sewa.paketNama, + paketFoto: sewa.paketFoto, + totalTagihan: sewa.totalTagihan, + wargaNama: sewa.wargaNama, + wargaNoHp: sewa.wargaNoHp, + wargaAvatar: sewa.wargaAvatar, + ); sewaList.refresh(); + // Update status in database + final asetProvider = Get.find(); + final result = await asetProvider.updateSewaAsetStatus( + sewaAsetId: id, + status: 'DIKEMBALIKAN', + ); + if (!result) { + Get.snackbar( + 'Gagal', + 'Gagal mengubah status sewa di database', + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + } + + // Ambil detail item paket (nama aset & kuantitas) + Future>> getPaketItems(String paketId) async { + final asetProvider = Get.find(); + debugPrint('[DEBUG] getPaketItems called with paketId: $paketId'); + try { + final items = await asetProvider.getPaketItems(paketId); + debugPrint('[DEBUG] getPaketItems result for paketId $paketId:'); + for (var item in items) { + debugPrint(' - item: ${item.toString()}'); + } + return items; + } catch (e, stack) { + debugPrint('[ERROR] getPaketItems failed for paketId $paketId: $e'); + debugPrint('[ERROR] Stacktrace: $stack'); + return []; + } + } + + RxBool getIsFullPayment(String sewaId) { + if (!isFullPaymentMap.containsKey(sewaId)) { + isFullPaymentMap[sewaId] = false.obs; + } + return isFullPaymentMap[sewaId]!; + } + + TextEditingController getNominalController(String sewaId) { + if (!nominalControllerMap.containsKey(sewaId)) { + final controller = TextEditingController(text: '0'); + nominalControllerMap[sewaId] = controller; + } + return nominalControllerMap[sewaId]!; + } + + void setFullPayment(String sewaId, bool value, num totalTagihan) { + getIsFullPayment(sewaId).value = value; + if (value) { + getNominalController(sewaId).text = totalTagihan.toString(); + } + } + + RxString getPaymentMethod(String sewaId) { + if (!paymentMethodMap.containsKey(sewaId)) { + paymentMethodMap[sewaId] = 'Tunai'.obs; + } + return paymentMethodMap[sewaId]!; + } + + void setPaymentMethod(String sewaId, String method) { + getPaymentMethod(sewaId).value = method; + } + + Future getTagihanSewaIdBySewaAsetId(String sewaAsetId) async { + final asetProvider = Get.find(); + final tagihan = await asetProvider.getTagihanSewa(sewaAsetId); + if (tagihan != null && tagihan['id'] != null) { + return tagihan['id'] as String; + } + return null; + } + + Future confirmPembayaranTagihan({ + required String sewaAsetId, + required int nominal, + required String metodePembayaran, + }) async { + final tagihanSewaId = await getTagihanSewaIdBySewaAsetId(sewaAsetId); + if (tagihanSewaId == null) { + Get.snackbar( + 'Gagal', + 'Tagihan sewa tidak ditemukan', + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + final asetProvider = Get.find(); + // Cek status sewa_aset saat ini + final sewaAsetData = await asetProvider.getSewaAsetWithAsetData(sewaAsetId); + if (sewaAsetData != null && + (sewaAsetData['status']?.toString()?.toUpperCase() == + 'PERIKSA PEMBAYARAN')) { + // Ubah status menjadi MENUNGGU PEMBAYARAN + await asetProvider.updateSewaAsetStatus( + sewaAsetId: sewaAsetId, + status: 'MENUNGGU PEMBAYARAN', + ); + } + final result = await asetProvider.processPembayaranTagihan( + tagihanSewaId: tagihanSewaId, + nominal: nominal, + metodePembayaran: metodePembayaran, + ); + if (result) { + Get.snackbar( + 'Sukses', + 'Pembayaran berhasil diproses', + backgroundColor: Colors.green, + colorText: Colors.white, + ); + } else { + Get.snackbar( + 'Gagal', + 'Pembayaran gagal diproses', + backgroundColor: Colors.red, + colorText: Colors.white, + ); } } } diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_tambah_aset_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_tambah_aset_controller.dart index ee4a79e..bc5aa9a 100644 --- a/lib/app/modules/petugas_bumdes/controllers/petugas_tambah_aset_controller.dart +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_tambah_aset_controller.dart @@ -1,7 +1,187 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:bumrent_app/app/data/models/aset_model.dart'; +import 'package:bumrent_app/app/data/providers/aset_provider.dart'; class PetugasTambahAsetController extends GetxController { + // Flag to check if in edit mode + final isEditing = false.obs; + String? assetId; // To store the ID of the asset being edited + + @override + Future onInit() async { + super.onInit(); + + try { + // Handle edit mode and load data if needed + final args = Get.arguments; + debugPrint('[DEBUG] PetugasTambahAsetController initialized with args: $args'); + + if (args != null && args is Map) { + isEditing.value = args['isEditing'] ?? false; + debugPrint('[DEBUG] isEditing set to: ${isEditing.value}'); + + if (isEditing.value) { + // Get asset ID from arguments + final assetId = args['assetId']?.toString() ?? ''; + debugPrint('[DEBUG] Edit mode: Loading asset with ID: $assetId'); + + if (assetId.isNotEmpty) { + // Store the asset ID and load asset data + this.assetId = assetId; + debugPrint('[DEBUG] Asset ID set to: $assetId'); + + // Load asset data and await completion + await _loadAssetData(assetId); + } else { + debugPrint('[ERROR] Edit mode but no assetId provided in arguments'); + Get.snackbar( + 'Error', + 'ID Aset tidak ditemukan', + snackPosition: SnackPosition.BOTTOM, + ); + // Optionally navigate back if in edit mode without an ID + Future.delayed(Duration.zero, () => Get.back()); + } + } else { + // Set default values for new asset + debugPrint('[DEBUG] Add new asset mode'); + quantityController.text = '1'; + unitOfMeasureController.text = 'Unit'; + } + } else { + // Default values for new asset when no arguments are passed + debugPrint('[DEBUG] No arguments passed, defaulting to add new asset mode'); + quantityController.text = '1'; + unitOfMeasureController.text = 'Unit'; + } + } catch (e, stackTrace) { + debugPrint('[ERROR] Error in onInit: $e'); + debugPrint('Stack trace: $stackTrace'); + // Ensure loading is set to false even if there's an error + isLoading.value = false; + + Get.snackbar( + 'Error', + 'Terjadi kesalahan saat memuat data', + snackPosition: SnackPosition.BOTTOM, + ); + } + + // Listen to field changes for validation + nameController.addListener(validateForm); + descriptionController.addListener(validateForm); + quantityController.addListener(validateForm); + pricePerHourController.addListener(validateForm); + pricePerDayController.addListener(validateForm); + } + + final AsetProvider _asetProvider = Get.find(); + final isLoading = false.obs; + + Future _loadAssetData(String assetId) async { + try { + isLoading.value = true; + debugPrint('[DEBUG] Fetching asset data for ID: $assetId'); + + // Fetch asset data from Supabase + final aset = await _asetProvider.getAsetById(assetId); + + if (aset == null) { + throw Exception('Aset tidak ditemukan'); + } + + debugPrint('[DEBUG] Successfully fetched asset data: ${aset.toJson()}'); + + // Populate form fields with the fetched data + nameController.text = aset.nama ?? ''; + descriptionController.text = aset.deskripsi ?? ''; + quantityController.text = (aset.kuantitas ?? 1).toString(); + + // Ensure the status matches one of the available options exactly + final status = aset.status?.toLowerCase() ?? 'tersedia'; + if (status == 'tersedia') { + selectedStatus.value = 'Tersedia'; + } else if (status == 'pemeliharaan') { + selectedStatus.value = 'Pemeliharaan'; + } else { + // Default to 'Tersedia' if status is not recognized + selectedStatus.value = 'Tersedia'; + } + + // Handle time options and pricing + if (aset.satuanWaktuSewa != null && aset.satuanWaktuSewa!.isNotEmpty) { + // Reset time options + timeOptions.forEach((key, value) => value.value = false); + + // Process each satuan waktu sewa + for (var sws in aset.satuanWaktuSewa) { + final satuan = sws['nama_satuan_waktu']?.toString().toLowerCase() ?? ''; + final harga = sws['harga'] as int? ?? 0; + final maksimalWaktu = sws['maksimal_waktu'] as int? ?? 24; + + if (satuan.contains('jam')) { + timeOptions['Per Jam']?.value = true; + pricePerHourController.text = harga.toString(); + maxHourController.text = maksimalWaktu.toString(); + } else if (satuan.contains('hari')) { + timeOptions['Per Hari']?.value = true; + pricePerDayController.text = harga.toString(); + maxDayController.text = maksimalWaktu.toString(); + } + } + } + + // Clear existing images + selectedImages.clear(); + networkImageUrls.clear(); + + // Get all image URLs from the model + final allImageUrls = aset.imageUrls.toList(); + + // If no imageUrls but has imageUrl, use that as fallback (backward compatibility) + if (allImageUrls.isEmpty && aset.imageUrl != null && aset.imageUrl!.isNotEmpty) { + allImageUrls.add(aset.imageUrl!); + } + + // Add all images to the lists + for (final imageUrl in allImageUrls) { + if (imageUrl != null && imageUrl.isNotEmpty) { + try { + // For network images, we'll store the URL in networkImageUrls + // and create a dummy XFile with the URL as path for backward compatibility + final dummyFile = XFile(imageUrl); + selectedImages.add(dummyFile); + networkImageUrls.add(imageUrl); + debugPrint('Added network image: $imageUrl'); + } catch (e) { + debugPrint('Error adding network image: $e'); + } + } + } + + debugPrint('Total ${networkImageUrls.length} images loaded for asset $assetId'); + debugPrint('[DEBUG] Successfully loaded asset data for ID: $assetId'); + } catch (e, stackTrace) { + debugPrint('[ERROR] Failed to load asset data: $e'); + debugPrint('Stack trace: $stackTrace'); + + Get.snackbar( + 'Error', + 'Gagal memuat data aset: ${e.toString()}', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + + // Optionally navigate back if there's an error + Future.delayed(const Duration(seconds: 2), () => Get.back()); + } finally { + isLoading.value = false; + } + } // Form controllers final nameController = TextEditingController(); final descriptionController = TextEditingController(); @@ -23,27 +203,17 @@ class PetugasTambahAsetController extends GetxController { final categoryOptions = ['Sewa', 'Langganan']; final statusOptions = ['Tersedia', 'Pemeliharaan']; - // Images - final selectedImages = [].obs; + // List to store selected images + final RxList selectedImages = [].obs; + // List to store network image URLs + final RxList networkImageUrls = [].obs; + final _picker = ImagePicker(); // Form validation final isFormValid = false.obs; final isSubmitting = false.obs; - @override - void onInit() { - super.onInit(); - // Set default values - quantityController.text = '1'; - unitOfMeasureController.text = 'Unit'; - // Listen to field changes for validation - nameController.addListener(validateForm); - descriptionController.addListener(validateForm); - quantityController.addListener(validateForm); - pricePerHourController.addListener(validateForm); - pricePerDayController.addListener(validateForm); - } @override void onClose() { @@ -85,21 +255,144 @@ class PetugasTambahAsetController extends GetxController { if (!anySelected) { timeOptions[option]?.value = true; } - + validateForm(); } - // Add image to the list (in a real app, this would handle file upload) - void addImage(String imagePath) { - selectedImages.add(imagePath); - validateForm(); + + // Create a new asset in Supabase + Future _createAsset( + Map assetData, + List> satuanWaktuSewa, + ) async { + try { + // Create the asset in the 'aset' table + final response = await _asetProvider.createAset(assetData); + + if (response == null || response['id'] == null) { + debugPrint('āŒ Failed to create asset: No response or ID from server'); + return null; + } + + final String assetId = response['id'].toString(); + debugPrint('āœ… Asset created with ID: $assetId'); + + // Add satuan waktu sewa + for (var sws in satuanWaktuSewa) { + final success = await _asetProvider.addSatuanWaktuSewa( + asetId: assetId, + satuanWaktu: sws['satuan_waktu'], + harga: sws['harga'], + maksimalWaktu: sws['maksimal_waktu'], + ); + + if (!success) { + debugPrint('āŒ Failed to add satuan waktu sewa: $sws'); + } + } + + return assetId; + } catch (e, stackTrace) { + debugPrint('āŒ Error creating asset: $e'); + debugPrint('Stack trace: $stackTrace'); + rethrow; + } } - // Remove image from the list + // Update an existing asset in Supabase + Future _updateAsset( + String assetId, + Map assetData, + List> satuanWaktuSewa, + ) async { + try { + debugPrint('\nšŸ”„ Starting update for asset ID: $assetId'); + + // 1. Extract and remove foto_aset from assetData as it's not in the aset table + final fotoAsetUrl = assetData['foto_aset']; + assetData.remove('foto_aset'); + debugPrint('šŸ“ Asset data prepared for update (without foto_aset)'); + + // 2. Update the main asset data (without foto_aset) + debugPrint('šŸ”„ Updating main asset data...'); + final success = await _asetProvider.updateAset(assetId, assetData); + if (!success) { + debugPrint('āŒ Failed to update asset with ID: $assetId'); + return false; + } + debugPrint('āœ… Successfully updated main asset data'); + + // 3. Update satuan waktu sewa + debugPrint('\nšŸ”„ Updating rental time units...'); + // First, delete existing satuan waktu sewa + await _asetProvider.deleteSatuanWaktuSewaByAsetId(assetId); + + // Then add the new ones + for (var sws in satuanWaktuSewa) { + debugPrint(' - Adding: ${sws['satuan_waktu']} (${sws['harga']} IDR)'); + await _asetProvider.addSatuanWaktuSewa( + asetId: assetId, + satuanWaktu: sws['satuan_waktu'], + harga: sws['harga'] as int, + maksimalWaktu: sws['maksimal_waktu'] as int, + ); + } + debugPrint('āœ… Successfully updated rental time units'); + + // 4. Update photos in the foto_aset table if any exist + if (selectedImages.isNotEmpty || networkImageUrls.isNotEmpty) { + // Combine network URLs and local file paths + final List allImageUrls = [ + ...networkImageUrls, + ...selectedImages.map((file) => file.path), + ]; + + debugPrint('\nšŸ–¼ļø Processing photos for asset $assetId'); + debugPrint(' - Network URLs: ${networkImageUrls.length}'); + debugPrint(' - Local files: ${selectedImages.length}'); + debugPrint(' - Total unique photos: ${allImageUrls.toSet().length} (before deduplication)'); + + try { + // Use updateFotoAset which handles both uploading new photos and updating the database + final photoSuccess = await _asetProvider.updateFotoAset( + asetId: assetId, + fotoUrls: allImageUrls, + ); + + if (!photoSuccess) { + debugPrint('āš ļø Some photos might not have been updated for asset $assetId'); + // We don't fail the whole update if photo update fails + // as the main asset data has been saved successfully + } else { + debugPrint('āœ… Successfully updated photos for asset $assetId'); + } + } catch (e, stackTrace) { + debugPrint('āŒ Error updating photos: $e'); + debugPrint('Stack trace: $stackTrace'); + // Continue with the update even if photo update fails + } + } else { + debugPrint('ā„¹ļø No photos to update'); + } + + debugPrint('\nāœ… Asset update completed successfully for ID: $assetId'); + return true; + + } catch (e, stackTrace) { + debugPrint('āŒ Error updating asset: $e'); + debugPrint('Stack trace: $stackTrace'); + rethrow; + } + } + + // Remove an image from the selected images list void removeImage(int index) { if (index >= 0 && index < selectedImages.length) { + // Remove from both lists if they have an entry at this index + if (index < networkImageUrls.length) { + networkImageUrls.removeAt(index); + } selectedImages.removeAt(index); - validateForm(); } } @@ -133,62 +426,130 @@ class PetugasTambahAsetController extends GetxController { basicValid && perHourValid && perDayValid && anyTimeOptionSelected; } - // Submit form and save asset + // Submit form and save or update asset Future saveAsset() async { if (!isFormValid.value) return; isSubmitting.value = true; try { - // In a real app, this would make an API call to save the asset - await Future.delayed(const Duration(seconds: 1)); // Mock API call - - // Prepare asset data - final assetData = { + // Prepare the basic asset data + final Map assetData = { 'nama': nameController.text, 'deskripsi': descriptionController.text, - 'kategori': selectedCategory.value, + 'kategori': 'sewa', // Default to 'sewa' category 'status': selectedStatus.value, 'kuantitas': int.parse(quantityController.text), - 'satuan_ukur': unitOfMeasureController.text, - 'opsi_waktu_sewa': - timeOptions.entries - .where((entry) => entry.value.value) - .map((entry) => entry.key) - .toList(), - 'harga_per_jam': - timeOptions['Per Jam']!.value - ? int.parse(pricePerHourController.text) - : null, - 'max_jam': - timeOptions['Per Jam']!.value && maxHourController.text.isNotEmpty - ? int.parse(maxHourController.text) - : null, - 'harga_per_hari': - timeOptions['Per Hari']!.value - ? int.parse(pricePerDayController.text) - : null, - 'max_hari': - timeOptions['Per Hari']!.value && maxDayController.text.isNotEmpty - ? int.parse(maxDayController.text) - : null, - 'gambar': selectedImages, + 'satuan_ukur': 'unit', // Default unit of measure }; - // Log the data (in a real app, this would be sent to an API) - print('Asset data: $assetData'); + // Handle time options and pricing + final List> satuanWaktuSewa = []; + + if (timeOptions['Per Jam']?.value == true) { + final hargaPerJam = int.tryParse(pricePerHourController.text) ?? 0; + final maxJam = int.tryParse(maxHourController.text) ?? 24; + + if (hargaPerJam <= 0) { + throw Exception('Harga per jam harus lebih dari 0'); + } + + satuanWaktuSewa.add({ + 'satuan_waktu': 'jam', + 'harga': hargaPerJam, + 'maksimal_waktu': maxJam, + }); + } + + if (timeOptions['Per Hari']?.value == true) { + final hargaPerHari = int.tryParse(pricePerDayController.text) ?? 0; + final maxHari = int.tryParse(maxDayController.text) ?? 30; + + if (hargaPerHari <= 0) { + throw Exception('Harga per hari harus lebih dari 0'); + } + + satuanWaktuSewa.add({ + 'satuan_waktu': 'hari', + 'harga': hargaPerHari, + 'maksimal_waktu': maxHari, + }); + } - // Return to the asset list page - Get.back(); + // Validate that at least one time option is selected + if (satuanWaktuSewa.isEmpty) { + throw Exception('Pilih setidaknya satu opsi waktu sewa (jam/hari)'); + } - // Show success message - Get.snackbar( - 'Berhasil', - 'Aset berhasil ditambahkan', - backgroundColor: Colors.green, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, - ); + // Handle image uploads + List imageUrls = []; + + if (networkImageUrls.isNotEmpty) { + // Use existing network URLs + imageUrls = List.from(networkImageUrls); + } else if (selectedImages.isNotEmpty) { + // For local files, we'll upload them to Supabase Storage + // Store the file paths for now, they'll be uploaded in the provider + imageUrls = selectedImages.map((file) => file.path).toList(); + debugPrint('Found ${imageUrls.length} local images to upload'); + } else if (!isEditing.value) { + // For new assets, require at least one image + throw Exception('Harap unggah setidaknya satu gambar'); + } + + // Ensure at least one image is provided for new assets + if (imageUrls.isEmpty && !isEditing.value) { + throw Exception('Harap unggah setidaknya satu gambar'); + } + + // Create or update the asset + bool success; + String? createdAssetId; + + if (isEditing.value && (assetId?.isNotEmpty ?? false)) { + // Update existing asset + debugPrint('šŸ”„ Updating asset with ID: $assetId'); + success = await _updateAsset(assetId!, assetData, satuanWaktuSewa); + + // Update all photos if we have any + if (success && imageUrls.isNotEmpty) { + await _asetProvider.updateFotoAset( + asetId: assetId!, + fotoUrls: imageUrls, + ); + } + } else { + // Create new asset + debugPrint('šŸ”„ Creating new asset'); + createdAssetId = await _createAsset(assetData, satuanWaktuSewa); + success = createdAssetId != null; + + // Add all photos for new asset + if (success && createdAssetId != null && imageUrls.isNotEmpty) { + await _asetProvider.updateFotoAset( + asetId: createdAssetId, + fotoUrls: imageUrls, + ); + } + } + + if (success) { + // Show success message + Get.snackbar( + 'Sukses', + isEditing.value ? 'Aset berhasil diperbarui' : 'Aset berhasil ditambahkan', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + duration: const Duration(seconds: 3), + ); + + // Navigate back with success after a short delay + await Future.delayed(const Duration(seconds: 1)); + Get.back(result: true); + } else { + throw Exception('Gagal menyimpan aset'); + } } catch (e) { // Show error message Get.snackbar( @@ -203,8 +564,68 @@ class PetugasTambahAsetController extends GetxController { } } + + // Example method to upload images (to be implemented with your backend) + // Future> _uploadImages(List images) async { + // List urls = []; + // for (var image in images) { + // // Upload image to your server and get the URL + // // final url = await yourApiService.uploadImage(File(image.path)); + // // urls.add(url); + // urls.add('https://example.com/path/to/uploaded/image.jpg'); // Mock URL + // } + // return urls; + // } + + // Pick image from camera + Future pickImageFromCamera() async { + try { + final XFile? image = await _picker.pickImage( + source: ImageSource.camera, + imageQuality: 80, + maxWidth: 1024, + maxHeight: 1024, + ); + if (image != null) { + selectedImages.add(image); + } + } catch (e) { + Get.snackbar( + 'Error', + 'Gagal mengambil gambar dari kamera: $e', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + + // Pick image from gallery + Future pickImageFromGallery() async { + try { + final List? images = await _picker.pickMultiImage( + imageQuality: 80, + maxWidth: 1024, + maxHeight: 1024, + ); + if (images != null && images.isNotEmpty) { + selectedImages.addAll(images); + } + } catch (e) { + Get.snackbar( + 'Error', + 'Gagal memilih gambar dari galeri: $e', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + // For demonstration purposes: add sample image void addSampleImage() { - addImage('assets/images/sample_asset_${selectedImages.length + 1}.jpg'); + // In a real app, this would open the image picker + selectedImages.add(XFile('assets/images/sample_asset_${selectedImages.length + 1}.jpg')); + validateForm(); } } diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_tambah_paket_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_tambah_paket_controller.dart index 25c79bf..37a9bb5 100644 --- a/lib/app/modules/petugas_bumdes/controllers/petugas_tambah_paket_controller.dart +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_tambah_paket_controller.dart @@ -1,5 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:bumrent_app/app/data/models/paket_model.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:bumrent_app/app/data/providers/aset_provider.dart'; +import 'dart:io'; +import 'package:uuid/uuid.dart'; class PetugasTambahPaketController extends GetxController { // Form controllers @@ -10,14 +16,14 @@ class PetugasTambahPaketController extends GetxController { // Dropdown and toggle values final selectedCategory = 'Bulanan'.obs; - final selectedStatus = 'Aktif'.obs; + final selectedStatus = 'Tersedia'.obs; // Category options final categoryOptions = ['Bulanan', 'Tahunan', 'Premium', 'Bisnis']; - final statusOptions = ['Aktif', 'Nonaktif']; + final statusOptions = ['Tersedia', 'Pemeliharaan']; // Images - final selectedImages = [].obs; + final selectedImages = [].obs; // For package name and description final packageNameController = TextEditingController(); @@ -31,21 +37,85 @@ class PetugasTambahPaketController extends GetxController { // For asset selection final RxList> availableAssets = >[].obs; - final Rx selectedAsset = Rx(null); + final Rx selectedAsset = Rx(null); final RxBool isLoadingAssets = false.obs; // Form validation final isFormValid = false.obs; final isSubmitting = false.obs; + // New RxBool for editing + final isEditing = false.obs; + + final timeOptions = {'Per Jam': true.obs, 'Per Hari': false.obs}; + final pricePerHourController = TextEditingController(); + final maxHourController = TextEditingController(); + final pricePerDayController = TextEditingController(); + final maxDayController = TextEditingController(); + + final _picker = ImagePicker(); + + final isFormChanged = false.obs; + Map initialFormData = {}; + + final AsetProvider _asetProvider = Get.put(AsetProvider()); + @override void onInit() { super.onInit(); + // Ambil flag isEditing dari arguments + isEditing.value = + Get.arguments != null && Get.arguments['isEditing'] == true; + + if (isEditing.value) { + final paketArg = Get.arguments['paket']; + String? paketId; + if (paketArg != null) { + if (paketArg is Map && paketArg['id'] != null) { + paketId = paketArg['id'].toString(); + } else if (paketArg is PaketModel && paketArg.id != null) { + paketId = paketArg.id.toString(); + } + } + if (paketId != null) { + fetchPaketDetail(paketId); + } + } + // Listen to field changes for validation - nameController.addListener(validateForm); - descriptionController.addListener(validateForm); - priceController.addListener(validateForm); + nameController.addListener(() { + validateForm(); + checkFormChanged(); + }); + descriptionController.addListener(() { + validateForm(); + checkFormChanged(); + }); + priceController.addListener(() { + validateForm(); + checkFormChanged(); + }); + itemQuantityController.addListener(() { + validateForm(); + checkFormChanged(); + }); + pricePerHourController.addListener(() { + validateForm(); + checkFormChanged(); + }); + maxHourController.addListener(() { + validateForm(); + checkFormChanged(); + }); + pricePerDayController.addListener(() { + validateForm(); + checkFormChanged(); + }); + maxDayController.addListener(() { + validateForm(); + checkFormChanged(); + }); // Load available assets when the controller initializes fetchAvailableAssets(); @@ -61,6 +131,10 @@ class PetugasTambahPaketController extends GetxController { packageNameController.dispose(); packageDescriptionController.dispose(); packagePriceController.dispose(); + pricePerHourController.dispose(); + maxHourController.dispose(); + pricePerDayController.dispose(); + maxDayController.dispose(); super.onClose(); } @@ -68,18 +142,21 @@ class PetugasTambahPaketController extends GetxController { void setCategory(String category) { selectedCategory.value = category; validateForm(); + checkFormChanged(); } // Change selected status void setStatus(String status) { selectedStatus.value = status; validateForm(); + checkFormChanged(); } // Add image to the list (in a real app, this would handle file upload) void addImage(String imagePath) { selectedImages.add(imagePath); validateForm(); + checkFormChanged(); } // Remove image from the list @@ -87,34 +164,43 @@ class PetugasTambahPaketController extends GetxController { if (index >= 0 && index < selectedImages.length) { selectedImages.removeAt(index); validateForm(); + checkFormChanged(); } } - // Fetch available assets from the API or local data - void fetchAvailableAssets() { + // Fetch available assets from Supabase and filter out already selected ones + Future fetchAvailableAssets() async { isLoadingAssets.value = true; - - // This is a mock implementation - replace with actual API call - Future.delayed(const Duration(seconds: 1), () { - availableAssets.value = [ - {'id': 1, 'nama': 'Laptop Dell XPS', 'stok': 5}, - {'id': 2, 'nama': 'Proyektor Epson', 'stok': 3}, - {'id': 3, 'nama': 'Meja Kantor', 'stok': 10}, - {'id': 4, 'nama': 'Kursi Ergonomis', 'stok': 15}, - {'id': 5, 'nama': 'Printer HP LaserJet', 'stok': 2}, - {'id': 6, 'nama': 'AC Panasonic 1PK', 'stok': 8}, - ]; + try { + final allAssets = await _asetProvider.getSewaAsets(); + final selectedAsetIds = + packageItems.map((item) => item['asetId'].toString()).toSet(); + // Only show assets not yet selected + availableAssets.value = + allAssets + .where((aset) => !selectedAsetIds.contains(aset.id)) + .map( + (aset) => { + 'id': aset.id, + 'nama': aset.nama, + 'stok': aset.kuantitas, + }, + ) + .toList(); + } catch (e) { + availableAssets.value = []; + } finally { isLoadingAssets.value = false; - }); + } } // Set the selected asset - void setSelectedAsset(int? assetId) { + void setSelectedAsset(String? assetId) { selectedAsset.value = assetId; } // Get remaining stock for an asset (considering current selections) - int getRemainingStock(int assetId) { + int getRemainingStock(String assetId) { // Find the asset in available assets final asset = availableAssets.firstWhere( (item) => item['id'] == assetId, @@ -129,7 +215,7 @@ class PetugasTambahPaketController extends GetxController { // Calculate how many of this asset are already in the package int alreadySelected = 0; for (var item in packageItems) { - if (item['asetId'] == assetId) { + if (item['asetId'].toString() == assetId) { alreadySelected += item['jumlah'] as int; } } @@ -204,6 +290,8 @@ class PetugasTambahPaketController extends GetxController { backgroundColor: Colors.green, colorText: Colors.white, ); + + checkFormChanged(); } // Update an existing package item @@ -301,11 +389,16 @@ class PetugasTambahPaketController extends GetxController { backgroundColor: Colors.green, colorText: Colors.white, ); + + checkFormChanged(); } // Remove an item from the package void removeItem(int index) { - packageItems.removeAt(index); + if (index >= 0 && index < packageItems.length) { + packageItems.removeAt(index); + checkFormChanged(); + } Get.snackbar( 'Dihapus', 'Item berhasil dihapus dari paket', @@ -319,10 +412,7 @@ class PetugasTambahPaketController extends GetxController { void validateForm() { // Basic validation bool basicValid = - nameController.text.isNotEmpty && - descriptionController.text.isNotEmpty && - priceController.text.isNotEmpty && - int.tryParse(priceController.text) != null; + nameController.text.isNotEmpty && descriptionController.text.isNotEmpty; // Package should have at least one item bool hasItems = packageItems.isNotEmpty; @@ -337,39 +427,204 @@ class PetugasTambahPaketController extends GetxController { isSubmitting.value = true; try { - // In a real app, this would make an API call to save the package - await Future.delayed(const Duration(seconds: 1)); // Mock API call + final supabase = Supabase.instance.client; + if (isEditing.value) { + // --- UPDATE LOGIC --- + final paketArg = Get.arguments['paket']; + final String paketId = + paketArg is Map && paketArg['id'] != null + ? paketArg['id'].toString() + : (paketArg is PaketModel && paketArg.id != null + ? paketArg.id.toString() + : ''); + if (paketId.isEmpty) throw Exception('ID paket tidak ditemukan'); - // Prepare package data - final paketData = { - 'nama': nameController.text, - 'deskripsi': descriptionController.text, - 'kategori': selectedCategory.value, - 'status': selectedStatus.value == 'Aktif', - 'harga': int.parse(priceController.text), - 'gambar': selectedImages, - 'items': packageItems, - }; + // 1. Update data utama paket + await supabase + .from('paket') + .update({ + 'nama': nameController.text, + 'deskripsi': descriptionController.text, + 'status': selectedStatus.value.toLowerCase(), + }) + .eq('id', paketId); - // Log the data (in a real app, this would be sent to an API) - print('Package data: $paketData'); + // 2. Update paket_item: hapus semua, insert ulang + await supabase.from('paket_item').delete().eq('paket_id', paketId); + for (var item in packageItems) { + await supabase.from('paket_item').insert({ + 'paket_id': paketId, + 'aset_id': item['asetId'], + 'kuantitas': item['jumlah'], + }); + } - // Return to the package list page - Get.back(); + // 3. Update satuan_waktu_sewa: hapus semua, insert ulang + await supabase + .from('satuan_waktu_sewa') + .delete() + .eq('paket_id', paketId); + // Fetch satuan_waktu UUIDs + final satuanWaktuList = await supabase + .from('satuan_waktu') + .select('id, nama_satuan_waktu'); + String? jamId; + String? hariId; + for (var sw in satuanWaktuList) { + final nama = (sw['nama_satuan_waktu'] ?? '').toString().toLowerCase(); + if (nama.contains('jam')) jamId = sw['id']; + if (nama.contains('hari')) hariId = sw['id']; + } + if (timeOptions['Per Jam']?.value == true && jamId != null) { + await supabase.from('satuan_waktu_sewa').insert({ + 'paket_id': paketId, + 'satuan_waktu_id': jamId, + 'harga': int.tryParse(pricePerHourController.text) ?? 0, + 'maksimal_waktu': int.tryParse(maxHourController.text) ?? 0, + }); + } + if (timeOptions['Per Hari']?.value == true && hariId != null) { + await supabase.from('satuan_waktu_sewa').insert({ + 'paket_id': paketId, + 'satuan_waktu_id': hariId, + 'harga': int.tryParse(pricePerDayController.text) ?? 0, + 'maksimal_waktu': int.tryParse(maxDayController.text) ?? 0, + }); + } - // Show success message - Get.snackbar( - 'Berhasil', - 'Paket berhasil ditambahkan', - backgroundColor: Colors.green, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, - ); + // 4. Update foto_aset + // a. Ambil foto lama dari DB + final oldPhotos = await supabase + .from('foto_aset') + .select('foto_aset') + .eq('id_paket', paketId); + final oldPhotoUrls = + oldPhotos + .map((e) => e['foto_aset']?.toString()) + .whereType() + .toSet(); + final newPhotoUrls = + selectedImages + .map((img) => img is String ? img : (img.path ?? '')) + .where((e) => e.isNotEmpty) + .toSet(); + // b. Hapus foto yang dihapus user (dari DB dan storage) + final removedPhotos = oldPhotoUrls.difference(newPhotoUrls); + for (final url in removedPhotos) { + await supabase + .from('foto_aset') + .delete() + .eq('foto_aset', url) + .eq('id_paket', paketId); + await _asetProvider.deleteFileFromStorage(url); + } + // c. Tambah foto baru (upload jika perlu, insert ke DB) + for (final img in selectedImages) { + String url = ''; + if (img is String && img.startsWith('http')) { + url = img; + } else if (img is XFile) { + final uploaded = await _asetProvider.uploadFileToStorage( + File(img.path), + ); + if (uploaded != null) url = uploaded; + } + if (url.isNotEmpty && !oldPhotoUrls.contains(url)) { + await supabase.from('foto_aset').insert({ + 'id_paket': paketId, + 'foto_aset': url, + }); + } + } + + // Sukses + Get.back(); + Get.snackbar( + 'Berhasil', + 'Paket berhasil diperbarui', + backgroundColor: Colors.green, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } else { + // --- ADD LOGIC --- + final uuid = Uuid(); + final String paketId = uuid.v4(); + // 1. Insert ke tabel paket + await supabase.from('paket').insert({ + 'id': paketId, + 'nama': nameController.text, + 'deskripsi': descriptionController.text, + 'status': selectedStatus.value.toLowerCase(), + }); + // 2. Insert ke paket_item + for (var item in packageItems) { + await supabase.from('paket_item').insert({ + 'paket_id': paketId, + 'aset_id': item['asetId'], + 'kuantitas': item['jumlah'], + }); + } + // 3. Insert ke satuan_waktu_sewa (ambil UUID satuan waktu) + final satuanWaktuList = await supabase + .from('satuan_waktu') + .select('id, nama_satuan_waktu'); + String? jamId; + String? hariId; + for (var sw in satuanWaktuList) { + final nama = (sw['nama_satuan_waktu'] ?? '').toString().toLowerCase(); + if (nama.contains('jam')) jamId = sw['id']; + if (nama.contains('hari')) hariId = sw['id']; + } + if (timeOptions['Per Jam']?.value == true && jamId != null) { + await supabase.from('satuan_waktu_sewa').insert({ + 'paket_id': paketId, + 'satuan_waktu_id': jamId, + 'harga': int.tryParse(pricePerHourController.text) ?? 0, + 'maksimal_waktu': int.tryParse(maxHourController.text) ?? 0, + }); + } + if (timeOptions['Per Hari']?.value == true && hariId != null) { + await supabase.from('satuan_waktu_sewa').insert({ + 'paket_id': paketId, + 'satuan_waktu_id': hariId, + 'harga': int.tryParse(pricePerDayController.text) ?? 0, + 'maksimal_waktu': int.tryParse(maxDayController.text) ?? 0, + }); + } + // 4. Insert ke foto_aset (upload jika perlu) + for (final img in selectedImages) { + String url = ''; + if (img is String && img.startsWith('http')) { + url = img; + } else if (img is XFile) { + final uploaded = await _asetProvider.uploadFileToStorage( + File(img.path), + ); + if (uploaded != null) url = uploaded; + } + if (url.isNotEmpty) { + await supabase.from('foto_aset').insert({ + 'id_paket': paketId, + 'foto_aset': url, + }); + } + } + // Sukses + Get.back(); + Get.snackbar( + 'Berhasil', + 'Paket berhasil ditambahkan', + backgroundColor: Colors.green, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } } catch (e) { // Show error message Get.snackbar( 'Gagal', - 'Terjadi kesalahan: ${e.toString()}', + 'Terjadi kesalahan: \\${e.toString()}', backgroundColor: Colors.red, colorText: Colors.white, snackPosition: SnackPosition.BOTTOM, @@ -390,4 +645,215 @@ class PetugasTambahPaketController extends GetxController { selectedImages.add('https://example.com/sample_image.jpg'); validateForm(); } + + void toggleTimeOption(String option) { + timeOptions[option]?.value = !(timeOptions[option]?.value ?? false); + // Ensure at least one option is selected + bool anySelected = false; + timeOptions.forEach((key, value) { + if (value.value) anySelected = true; + }); + if (!anySelected) { + timeOptions[option]?.value = true; + } + validateForm(); + checkFormChanged(); + } + + Future fetchPaketDetail(String paketId) async { + try { + debugPrint('[DEBUG] Fetching paket detail for id: $paketId'); + final supabase = Supabase.instance.client; + // 1) Ambil data paket utama + final paketData = + await supabase + .from('paket') + .select('id, nama, deskripsi, status') + .eq('id', paketId) + .single(); + debugPrint('[DEBUG] Paket data: ' + paketData.toString()); + + // 2) Ambil paket_item + final paketItemData = await supabase + .from('paket_item') + .select('id, paket_id, aset_id, kuantitas') + .eq('paket_id', paketId); + debugPrint('[DEBUG] Paket item data: ' + paketItemData.toString()); + + // 3) Ambil satuan_waktu_sewa + final swsData = await supabase + .from('satuan_waktu_sewa') + .select('id, paket_id, satuan_waktu_id, harga, maksimal_waktu') + .eq('paket_id', paketId); + debugPrint('[DEBUG] Satuan waktu sewa data: ' + swsData.toString()); + + // 4) Ambil semua satuan_waktu_id dari swsData + final swIds = swsData.map((e) => e['satuan_waktu_id']).toSet().toList(); + final swData = + swIds.isNotEmpty + ? await supabase + .from('satuan_waktu') + .select('id, nama_satuan_waktu') + .inFilter('id', swIds) + : []; + debugPrint('[DEBUG] Satuan waktu data: ' + swData.toString()); + final Map satuanWaktuMap = { + for (var sw in swData) sw['id']: sw['nama_satuan_waktu'], + }; + + // 5) Ambil foto_aset + final fotoData = await supabase + .from('foto_aset') + .select('id_paket, foto_aset') + .eq('id_paket', paketId); + debugPrint('[DEBUG] Foto aset data: ' + fotoData.toString()); + + // 6) Kumpulkan semua aset_id dari paketItemData + final asetIds = paketItemData.map((e) => e['aset_id']).toSet().toList(); + final asetData = + asetIds.isNotEmpty + ? await supabase + .from('aset') + .select('id, nama, kuantitas') + .inFilter('id', asetIds) + : []; + debugPrint('[DEBUG] Aset data: ' + asetData.toString()); + final Map asetMap = {for (var a in asetData) a['id']: a}; + + // Prefill field controller + nameController.text = paketData['nama']?.toString() ?? ''; + descriptionController.text = paketData['deskripsi']?.toString() ?? ''; + // Status mapping + final statusDb = + (paketData['status']?.toString().toLowerCase() ?? 'tersedia'); + selectedStatus.value = + statusDb == 'pemeliharaan' ? 'Pemeliharaan' : 'Tersedia'; + + // Foto + selectedImages.clear(); + if (fotoData.isNotEmpty) { + for (var foto in fotoData) { + final url = foto['foto_aset']?.toString(); + if (url != null && url.isNotEmpty) { + selectedImages.add(url); + } + } + } + + // Item paket + packageItems.clear(); + for (var item in paketItemData) { + final aset = asetMap[item['aset_id']]; + packageItems.add({ + 'asetId': item['aset_id'], + 'nama': aset != null ? aset['nama'] : '', + 'jumlah': item['kuantitas'], + 'stok': aset != null ? aset['kuantitas'] : 0, + }); + } + + // Opsi waktu & harga sewa + // Reset + timeOptions['Per Jam']?.value = false; + timeOptions['Per Hari']?.value = false; + pricePerHourController.clear(); + maxHourController.clear(); + pricePerDayController.clear(); + maxDayController.clear(); + for (var sws in swsData) { + final satuanNama = + satuanWaktuMap[sws['satuan_waktu_id']]?.toString().toLowerCase() ?? + ''; + if (satuanNama.contains('jam')) { + timeOptions['Per Jam']?.value = true; + pricePerHourController.text = (sws['harga'] ?? '').toString(); + maxHourController.text = (sws['maksimal_waktu'] ?? '').toString(); + } else if (satuanNama.contains('hari')) { + timeOptions['Per Hari']?.value = true; + pricePerDayController.text = (sws['harga'] ?? '').toString(); + maxDayController.text = (sws['maksimal_waktu'] ?? '').toString(); + } + } + + // Simpan snapshot initialFormData setelah prefill + initialFormData = { + 'nama': nameController.text, + 'deskripsi': descriptionController.text, + 'status': selectedStatus.value, + 'images': List.from(selectedImages), + 'items': List.from(packageItems), + 'perJam': timeOptions['Per Jam']?.value ?? false, + 'perHari': timeOptions['Per Hari']?.value ?? false, + 'hargaJam': pricePerHourController.text, + 'maxJam': maxHourController.text, + 'hargaHari': pricePerDayController.text, + 'maxHari': maxDayController.text, + }; + isFormChanged.value = false; + } catch (e, st) { + debugPrint('[ERROR] Gagal fetch paket detail: $e'); + } + } + + Future pickImageFromCamera() async { + try { + final XFile? image = await _picker.pickImage( + source: ImageSource.camera, + imageQuality: 80, + maxWidth: 1024, + maxHeight: 1024, + ); + if (image != null) { + selectedImages.add(image); + } + } catch (e) { + Get.snackbar( + 'Error', + 'Gagal mengambil gambar dari kamera: $e', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + + Future pickImageFromGallery() async { + try { + final List? images = await _picker.pickMultiImage( + imageQuality: 80, + maxWidth: 1024, + maxHeight: 1024, + ); + if (images != null && images.isNotEmpty) { + for (final img in images) { + selectedImages.add(img); + } + } + } catch (e) { + Get.snackbar( + 'Error', + 'Gagal memilih gambar dari galeri: $e', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + + void checkFormChanged() { + final current = { + 'nama': nameController.text, + 'deskripsi': descriptionController.text, + 'status': selectedStatus.value, + 'images': List.from(selectedImages), + 'items': List.from(packageItems), + 'perJam': timeOptions['Per Jam']?.value ?? false, + 'perHari': timeOptions['Per Hari']?.value ?? false, + 'hargaJam': pricePerHourController.text, + 'maxJam': maxHourController.text, + 'hargaHari': pricePerDayController.text, + 'maxHari': maxDayController.text, + }; + isFormChanged.value = current.toString() != initialFormData.toString(); + } } diff --git a/lib/app/modules/petugas_bumdes/views/petugas_aset_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_aset_view.dart index c6a0c35..71c20d2 100644 --- a/lib/app/modules/petugas_bumdes/views/petugas_aset_view.dart +++ b/lib/app/modules/petugas_bumdes/views/petugas_aset_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../controllers/petugas_aset_controller.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import '../../../theme/app_colors_petugas.dart'; import '../widgets/petugas_bumdes_bottom_navbar.dart'; import '../widgets/petugas_side_navbar.dart'; @@ -23,26 +24,12 @@ class _PetugasAsetViewState extends State void initState() { super.initState(); controller = Get.find(); - _tabController = TabController(length: 2, vsync: this); - - // Listen to tab changes and update controller - _tabController.addListener(() { - if (!_tabController.indexIsChanging) { - controller.changeTab(_tabController.index); - } - }); - - // Listen to controller tab changes and update TabController - ever(controller.selectedTabIndex, (index) { - if (_tabController.index != index) { - _tabController.animateTo(index); - } - }); + // Initialize with default tab (sewa) + controller.changeTab(0); } @override void dispose() { - _tabController.dispose(); super.dispose(); } @@ -82,7 +69,7 @@ class _PetugasAsetViewState extends State body: Column( children: [ _buildSearchBar(), - _buildTabBar(), + const SizedBox(height: 16), Expanded(child: _buildAssetList()), ], ), @@ -93,7 +80,13 @@ class _PetugasAsetViewState extends State ), ), floatingActionButton: FloatingActionButton.extended( - onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_ASET), + onPressed: () { + // Navigate to PetugasTambahAsetView in add mode + Get.toNamed( + Routes.PETUGAS_TAMBAH_ASET, + arguments: {'isEditing': false, 'assetData': null}, + ); + }, backgroundColor: AppColorsPetugas.babyBlueBright, icon: Icon(Icons.add, color: AppColorsPetugas.blueGrotto), label: Text( @@ -144,60 +137,19 @@ class _PetugasAsetViewState extends State ); } - Widget _buildTabBar() { - return Container( - margin: const EdgeInsets.fromLTRB(16, 16, 16, 0), - decoration: BoxDecoration( - color: AppColorsPetugas.babyBlueLight, - borderRadius: BorderRadius.circular(12), - ), - child: TabBar( - controller: _tabController, - labelColor: Colors.white, - unselectedLabelColor: AppColorsPetugas.textSecondary, - indicatorSize: TabBarIndicatorSize.tab, - indicator: BoxDecoration( - color: AppColorsPetugas.blueGrotto, - borderRadius: BorderRadius.circular(12), - ), - dividerColor: Colors.transparent, - tabs: const [ - Tab( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.shopping_cart, size: 18), - SizedBox(width: 8), - Text('Sewa', style: TextStyle(fontWeight: FontWeight.w600)), - ], - ), - ), - ), - Tab( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.subscriptions, size: 18), - SizedBox(width: 8), - Text( - 'Langganan', - style: TextStyle(fontWeight: FontWeight.w600), - ), - ], - ), - ), - ), - ], - ), - ); - } + // Tab bar has been removed as per requirements Widget _buildAssetList() { return Obx(() { + debugPrint('_buildAssetList: isLoading=${controller.isLoading.value}'); + debugPrint( + '_buildAssetList: filteredAsetList length=${controller.filteredAsetList.length}', + ); + if (controller.filteredAsetList.isNotEmpty) { + debugPrint( + '_buildAssetList: First item name=${controller.filteredAsetList[0]['nama']}', + ); + } if (controller.isLoading.value) { return Center( child: CircularProgressIndicator( @@ -255,10 +207,15 @@ class _PetugasAsetViewState extends State color: AppColorsPetugas.blueGrotto, child: ListView.builder( padding: const EdgeInsets.all(16), - itemCount: controller.filteredAsetList.length, + itemCount: controller.filteredAsetList.length + 1, itemBuilder: (context, index) { - final aset = controller.filteredAsetList[index]; - return _buildAssetCard(context, aset); + if (index < controller.filteredAsetList.length) { + final aset = controller.filteredAsetList[index]; + return _buildAssetCard(context, aset); + } else { + // Blank space at the end + return const SizedBox(height: 80); + } }, ), ); @@ -266,7 +223,31 @@ class _PetugasAsetViewState extends State } Widget _buildAssetCard(BuildContext context, Map aset) { - final isAvailable = aset['tersedia'] == true; + debugPrint('\n--- Building Asset Card ---'); + debugPrint('Asset data: $aset'); + + // Extract and validate all asset properties with proper null safety + final status = + aset['status']?.toString().toLowerCase() ?? 'tidak_diketahui'; + final isAvailable = status == 'tersedia'; + final imageUrl = aset['imageUrl']?.toString() ?? ''; + final harga = + aset['harga'] is int + ? aset['harga'] as int + : (int.tryParse(aset['harga']?.toString() ?? '0') ?? 0); + final satuanWaktu = + aset['satuan_waktu']?.toString().capitalizeFirst ?? 'Hari'; + final nama = aset['nama']?.toString().trim() ?? 'Nama tidak tersedia'; + final kategori = aset['kategori']?.toString().trim() ?? 'Umum'; + final orderId = aset['order_id']?.toString() ?? ''; + + // Debug prints for development + debugPrint('Image URL: $imageUrl'); + debugPrint('Harga: $harga'); + debugPrint('Satuan Waktu: $satuanWaktu'); + debugPrint('Nama: $nama'); + debugPrint('Kategori: $kategori'); + debugPrint('Status: $status (Available: $isAvailable)'); return Container( margin: const EdgeInsets.only(bottom: 12), @@ -290,21 +271,46 @@ class _PetugasAsetViewState extends State child: Row( children: [ // Asset image - Container( + SizedBox( width: 80, height: 80, - decoration: BoxDecoration( - color: AppColorsPetugas.babyBlueLight, + child: ClipRRect( borderRadius: const BorderRadius.only( topLeft: Radius.circular(12), bottomLeft: Radius.circular(12), ), - ), - child: Center( - child: Icon( - _getAssetIcon(aset['kategori']), - color: AppColorsPetugas.navyBlue, - size: 32, + child: CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: + (context, url) => Container( + color: AppColorsPetugas.babyBlueLight, + child: Center( + child: Icon( + _getAssetIcon( + kategori, + ), // Show category icon as placeholder + color: AppColorsPetugas.navyBlue.withOpacity( + 0.5, + ), + size: 32, + ), + ), + ), + errorWidget: + (context, url, error) => Container( + color: AppColorsPetugas.babyBlueLight, + child: Center( + child: Icon( + Icons + .broken_image, // Or your preferred error icon + color: AppColorsPetugas.navyBlue.withOpacity( + 0.5, + ), + size: 32, + ), + ), + ), ), ), ), @@ -323,8 +329,8 @@ class _PetugasAsetViewState extends State mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - aset['nama'], - style: TextStyle( + nama, + style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 14, color: AppColorsPetugas.navyBlue, @@ -333,12 +339,63 @@ class _PetugasAsetViewState extends State overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), - Text( - '${controller.formatPrice(aset['harga'])} ${aset['satuan']}', - style: TextStyle( - fontSize: 12, - color: AppColorsPetugas.textSecondary, - ), + // Harga dan satuan waktu (multi-line, tampilkan semua dari satuanWaktuSewa) + Builder( + builder: (context) { + final satuanWaktuList = + (aset['satuanWaktuSewa'] is List) + ? List>.from( + aset['satuanWaktuSewa'], + ) + : []; + final validSatuanWaktu = + satuanWaktuList + .where( + (sw) => + (sw['harga'] ?? 0) > 0 && + (sw['nama_satuan_waktu'] != + null && + (sw['nama_satuan_waktu'] + as String) + .isNotEmpty), + ) + .toList(); + + if (validSatuanWaktu.isNotEmpty) { + return Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: + validSatuanWaktu.map((sw) { + final harga = sw['harga'] ?? 0; + final satuan = + sw['nama_satuan_waktu'] ?? ''; + return Text( + '${controller.formatPrice(harga)} / $satuan', + style: TextStyle( + fontSize: 12, + color: + AppColorsPetugas + .textSecondary, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ); + }).toList(), + ); + } else { + // fallback: harga tunggal + return Text( + '${controller.formatPrice(aset['harga'] ?? 0)} / ${aset['satuan_waktu']?.toString().capitalizeFirst ?? 'Hari'}', + style: TextStyle( + fontSize: 12, + color: AppColorsPetugas.textSecondary, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ); + } + }, ), ], ), @@ -383,11 +440,36 @@ class _PetugasAsetViewState extends State children: [ // Edit icon GestureDetector( - onTap: - () => _showAddEditAssetDialog( - context, - aset: aset, - ), + onTap: () { + // Navigate to PetugasTambahAsetView in edit mode with only the asset ID + final assetId = + aset['id']?.toString() ?? + ''; // Changed from 'id_aset' to 'id' + debugPrint( + '[DEBUG] Navigating to edit asset with ID: $assetId', + ); + debugPrint( + '[DEBUG] Full asset data: $aset', + ); // Log full asset data for debugging + + if (assetId.isEmpty) { + debugPrint('[ERROR] Asset ID is empty!'); + Get.snackbar( + 'Error', + 'ID Aset tidak valid', + snackPosition: SnackPosition.BOTTOM, + ); + return; + } + + Get.toNamed( + Routes.PETUGAS_TAMBAH_ASET, + arguments: { + 'isEditing': true, + 'assetId': assetId, + }, + ); + }, child: Container( padding: const EdgeInsets.all(5), decoration: BoxDecoration( diff --git a/lib/app/modules/petugas_bumdes/views/petugas_bumdes_dashboard_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_bumdes_dashboard_view.dart index fd2ef86..7f48e1e 100644 --- a/lib/app/modules/petugas_bumdes/views/petugas_bumdes_dashboard_view.dart +++ b/lib/app/modules/petugas_bumdes/views/petugas_bumdes_dashboard_view.dart @@ -5,6 +5,7 @@ import '../controllers/petugas_bumdes_dashboard_controller.dart'; import '../widgets/petugas_bumdes_bottom_navbar.dart'; import '../widgets/petugas_side_navbar.dart'; import '../../../theme/app_colors_petugas.dart'; +import '../../../utils/format_utils.dart'; class PetugasBumdesDashboardView extends GetView { @@ -23,12 +24,7 @@ class PetugasBumdesDashboardView backgroundColor: AppColorsPetugas.navyBlue, foregroundColor: Colors.white, elevation: 0, - actions: [ - IconButton( - icon: const Icon(Icons.logout), - onPressed: () => _showLogoutConfirmation(context), - ), - ], + // actions: [], ), drawer: PetugasSideNavbar(controller: controller), drawerEdgeDragWidth: 60, @@ -118,8 +114,6 @@ class PetugasBumdesDashboardView ), _buildRevenueStatistics(), const SizedBox(height: 16), - _buildRevenueSources(), - const SizedBox(height: 16), _buildRevenueTrend(), // Add some padding at the bottom for better scrolling @@ -156,25 +150,51 @@ class PetugasBumdesDashboardView children: [ Row( children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 8, - offset: const Offset(0, 3), + Obx(() { + final avatar = controller.avatarUrl.value; + if (avatar.isNotEmpty) { + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + avatar, + width: 48, + height: 48, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => Container( + width: 48, + height: 48, + color: Colors.white.withOpacity(0.2), + child: const Icon( + Icons.person, + color: Colors.white, + size: 30, + ), + ), ), - ], - ), - child: const Icon( - Icons.person, - color: Colors.white, - size: 30, - ), - ), + ); + } else { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: const Icon( + Icons.person, + color: Colors.white, + size: 30, + ), + ); + } + }), const SizedBox(width: 16), Expanded( child: Column( @@ -208,15 +228,17 @@ class PetugasBumdesDashboardView ), ), const SizedBox(height: 4), - Obx( - () => Text( - controller.userEmail.value, + Obx(() { + final name = controller.userName.value; + final email = controller.userEmail.value; + return Text( + name.isNotEmpty ? name : email, style: const TextStyle( fontSize: 14, color: Colors.white70, ), - ), - ), + ); + }), ], ), ), @@ -642,19 +664,24 @@ class PetugasBumdesDashboardView ), ), const SizedBox(height: 10), - Obx( - () => Text( - controller.totalPendapatanBulanIni.value, + Obx(() { + final stats = controller.pembayaranStats; + final total = stats['totalThisMonth'] ?? 0.0; + return Text( + formatRupiah(total), style: TextStyle( fontSize: 28, fontWeight: FontWeight.bold, color: AppColorsPetugas.success, ), - ), - ), + ); + }), const SizedBox(height: 6), - Obx( - () => Row( + Obx(() { + final stats = controller.pembayaranStats; + final percent = stats['percentComparedLast'] ?? 0.0; + final isPositive = percent >= 0; + return Row( children: [ Container( padding: const EdgeInsets.symmetric( @@ -663,7 +690,7 @@ class PetugasBumdesDashboardView ), decoration: BoxDecoration( color: - controller.isKenaikanPositif.value + isPositive ? AppColorsPetugas.success.withOpacity( 0.1, ) @@ -676,23 +703,23 @@ class PetugasBumdesDashboardView mainAxisSize: MainAxisSize.min, children: [ Icon( - controller.isKenaikanPositif.value + isPositive ? Icons.arrow_upward : Icons.arrow_downward, size: 14, color: - controller.isKenaikanPositif.value + isPositive ? AppColorsPetugas.success : AppColorsPetugas.error, ), const SizedBox(width: 4), Text( - controller.persentaseKenaikan.value, + '${percent.toStringAsFixed(1)}%', style: TextStyle( fontSize: 13, fontWeight: FontWeight.bold, color: - controller.isKenaikanPositif.value + isPositive ? AppColorsPetugas.success : AppColorsPetugas.error, ), @@ -709,8 +736,8 @@ class PetugasBumdesDashboardView ), ), ], - ), - ), + ); + }), ], ), ), @@ -747,12 +774,29 @@ class PetugasBumdesDashboardView return Row( children: [ Expanded( - child: _buildRevenueQuickInfo( - 'Pendapatan Sewa', - controller.pendapatanSewa.value, - AppColorsPetugas.navyBlue, - Icons.shopping_cart_outlined, - ), + child: Obx(() { + final stats = controller.pembayaranStats; + final totalTunai = stats['totalTunai'] ?? 0.0; + return _buildRevenueQuickInfo( + 'Tunai', + formatRupiah(totalTunai), + AppColorsPetugas.navyBlue, + Icons.payments, + ); + }), + ), + const SizedBox(width: 12), + Expanded( + child: Obx(() { + final stats = controller.pembayaranStats; + final totalTransfer = stats['totalTransfer'] ?? 0.0; + return _buildRevenueQuickInfo( + 'Transfer', + formatRupiah(totalTransfer), + AppColorsPetugas.blueGrotto, + Icons.account_balance, + ); + }), ), ], ); @@ -811,81 +855,6 @@ class PetugasBumdesDashboardView ); } - Widget _buildRevenueSources() { - return Card( - elevation: 2, - shadowColor: AppColorsPetugas.shadowColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Sumber Pendapatan', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: AppColorsPetugas.navyBlue, - ), - ), - const SizedBox(height: 20), - Row( - children: [ - // Revenue Donut Chart - Expanded( - flex: 2, - child: Column( - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColorsPetugas.navyBlue.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: [ - Text( - 'Sewa Aset', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: AppColorsPetugas.navyBlue, - ), - ), - const SizedBox(height: 8), - Obx( - () => Text( - controller.pendapatanSewa.value, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppColorsPetugas.navyBlue, - ), - ), - ), - const SizedBox(height: 8), - Text( - '100% dari total pendapatan', - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade700, - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ], - ), - ), - ); - } - Widget _buildRevenueTrend() { final months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun']; @@ -912,6 +881,9 @@ class PetugasBumdesDashboardView child: Obx(() { // Get the trend data from controller final List trendData = controller.trendPendapatan; + if (trendData.isEmpty) { + return Center(child: Text('Tidak ada data')); + } final double maxValue = trendData.reduce( (curr, next) => curr > next ? curr : next, ); @@ -925,28 +897,28 @@ class PetugasBumdesDashboardView crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - '${maxValue.toStringAsFixed(1)}M', + formatRupiah(maxValue), style: TextStyle( fontSize: 10, color: AppColorsPetugas.textSecondary, ), ), Text( - '${(maxValue * 0.75).toStringAsFixed(1)}M', + formatRupiah(maxValue * 0.75), style: TextStyle( fontSize: 10, color: AppColorsPetugas.textSecondary, ), ), Text( - '${(maxValue * 0.5).toStringAsFixed(1)}M', + formatRupiah(maxValue * 0.5), style: TextStyle( fontSize: 10, color: AppColorsPetugas.textSecondary, ), ), Text( - '${(maxValue * 0.25).toStringAsFixed(1)}M', + formatRupiah(maxValue * 0.25), style: TextStyle( fontSize: 10, color: AppColorsPetugas.textSecondary, diff --git a/lib/app/modules/petugas_bumdes/views/petugas_detail_sewa_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_detail_sewa_view.dart index c7dc47d..45d5f9a 100644 --- a/lib/app/modules/petugas_bumdes/views/petugas_detail_sewa_view.dart +++ b/lib/app/modules/petugas_bumdes/views/petugas_detail_sewa_view.dart @@ -2,64 +2,150 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../controllers/petugas_sewa_controller.dart'; import '../../../theme/app_colors_petugas.dart'; +import '../../../data/models/rental_booking_model.dart'; +import 'package:intl/intl.dart'; +import '../../../data/providers/aset_provider.dart'; +import 'package:image_picker/image_picker.dart'; +import 'dart:io'; +import 'package:supabase_flutter/supabase_flutter.dart'; -class PetugasDetailSewaView extends StatelessWidget { - final Map sewa; - final PetugasSewaController controller = Get.find(); - +class PetugasDetailSewaView extends StatefulWidget { + final SewaModel sewa; PetugasDetailSewaView({Key? key, required this.sewa}) : super(key: key); + @override + State createState() => _PetugasDetailSewaViewState(); +} + +class _PetugasDetailSewaViewState extends State { + late SewaModel sewa; + final PetugasSewaController controller = Get.find(); + final Rx selectedDamageImage = Rx(null); + final Rx hasImage = false.obs; + + @override + void initState() { + super.initState(); + sewa = widget.sewa; + } + + Future refreshSewaData() async { + // Fetch the latest sewa detail from Supabase + final updated = await AsetProvider().getSewaAsetWithAsetData(sewa.id); + if (updated != null) { + // Fetch tagihan_sewa + final tagihan = await AsetProvider().getTagihanSewa(sewa.id); + // Fetch warga_desa + String userId = updated['user_id'] ?? ''; + Map? warga; + if (userId.isNotEmpty) { + final wargaList = + await AsetProvider().client + .from('warga_desa') + .select('user_id, nama_lengkap, no_hp, avatar') + .eq('user_id', userId) + .maybeSingle(); + warga = wargaList; + } + // Merge all data for SewaModel + final merged = { + ...updated, + if (tagihan != null) ...{ + 'total_tagihan': tagihan['total_tagihan'], + 'denda': tagihan['denda'], + 'dibayar': tagihan['tagihan_dibayar'], + }, + if (warga != null) ...{ + 'warga_nama': warga['nama_lengkap'], + 'warga_no_hp': warga['no_hp'], + 'warga_avatar': warga['avatar'], + }, + }; + // Ensure asset/package name and image are present + if ((merged['tipe_pesanan'] == 'tunggal' || + merged['tipe_pesanan'] == null) && + merged['aset_id'] != null) { + final aset = updated['aset_detail'] ?? {}; + merged['aset_nama'] = aset['nama'] ?? ''; + // Always fetch foto_aset for aset_id + final fotoResp = + await AsetProvider().client + .from('foto_aset') + .select('foto_aset') + .eq('id_aset', merged['aset_id']) + .limit(1) + .maybeSingle(); + merged['aset_foto'] = fotoResp != null ? fotoResp['foto_aset'] : ''; + } else if (merged['tipe_pesanan'] == 'paket' && + merged['paket_id'] != null) { + // Fetch paket name and photo if missing + final paketId = merged['paket_id']; + final paketResp = + await AsetProvider().client + .from('paket') + .select('id, nama') + .eq('id', paketId) + .maybeSingle(); + merged['paket_nama'] = paketResp != null ? paketResp['nama'] : ''; + // Fetch first photo for paket + final fotoResp = + await AsetProvider().client + .from('foto_aset') + .select('foto_aset') + .eq('id_paket', paketId) + .limit(1) + .maybeSingle(); + merged['paket_foto'] = fotoResp != null ? fotoResp['foto_aset'] : ''; + } + setState(() { + sewa = SewaModel.fromJson(merged); + }); + } + } + @override Widget build(BuildContext context) { - final statusColor = controller.getStatusColor(sewa['status']); - final status = sewa['status']; + final statusColor = controller.getStatusColor(sewa.status); + final status = sewa.status; // Get appropriate icon for status - IconData statusIcon; - switch (status) { - case 'Menunggu Pembayaran': - statusIcon = Icons.payments_outlined; - break; - case 'Periksa Pembayaran': - statusIcon = Icons.fact_check_outlined; - break; - case 'Diterima': - statusIcon = Icons.check_circle_outlined; - break; - case 'Pembayaran Denda': - statusIcon = Icons.money_off_csred_outlined; - break; - case 'Periksa Denda': - statusIcon = Icons.assignment_late_outlined; - break; - case 'Selesai': - statusIcon = Icons.task_alt_outlined; - break; - case 'Dibatalkan': - statusIcon = Icons.cancel_outlined; - break; - default: - statusIcon = Icons.help_outline_rounded; + IconData statusIcon = controller.getStatusIcon(status); + + // Flag untuk membedakan tipe pesanan + final bool isAset = sewa.tipePesanan == 'tunggal'; + final bool isPaket = sewa.tipePesanan == 'paket'; + + // Pilih nama aset/paket + final String namaAsetAtauPaket = + isAset + ? (sewa.asetNama ?? '-') + : (isPaket ? (sewa.paketNama ?? '-') : '-'); + // Pilih foto aset/paket jika ingin digunakan + final String? fotoAsetAtauPaket = + isAset ? sewa.asetFoto : (isPaket ? sewa.paketFoto : null); + + // Trigger fetch paket detail dan log debug saat halaman dibuka (hanya jika paket) + if (isPaket && sewa.paketId != null && sewa.paketId!.isNotEmpty) { + controller.getPaketItems(sewa.paketId!).then((items) { + debugPrint('[DEBUG] --- Paket Detail (on page open) ---'); + for (var item in items) { + debugPrint( + '[DEBUG] Paket Detail Item: nama_aset=${item['nama_aset']}, kuantitas=${item['kuantitas']}', + ); + } + }); } return Scaffold( backgroundColor: Colors.grey.shade50, appBar: AppBar( title: Text( - 'Detail Sewa #${sewa['order_id']}', + 'Detail Sewa #${sewa.id}', style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18), ), backgroundColor: AppColorsPetugas.navyBlue, foregroundColor: Colors.white, elevation: 0, - actions: [ - IconButton( - icon: const Icon(Icons.file_download_outlined), - onPressed: () => _showDownloadOptions(context), - tooltip: 'Unduh Bukti', - ), - _buildActionMenu(context), - ], ), body: CustomScrollView( slivers: [ @@ -108,7 +194,7 @@ class PetugasDetailSewaView extends StatelessWidget { // Price Tag Text( - controller.formatPrice(sewa['total_biaya']), + controller.formatPrice(sewa.totalTagihan), style: TextStyle( fontSize: 26, fontWeight: FontWeight.bold, @@ -119,24 +205,24 @@ class PetugasDetailSewaView extends StatelessWidget { const SizedBox(height: 8), // Order ID and Date Range - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.calendar_today_rounded, - size: 14, - color: AppColorsPetugas.textSecondary, - ), - const SizedBox(width: 6), - Text( - '${sewa['tanggal_mulai']} - ${sewa['tanggal_selesai']}', - style: TextStyle( - fontSize: 14, - color: AppColorsPetugas.textSecondary, - ), - ), - ], - ), + // Row( + // mainAxisAlignment: MainAxisAlignment.center, + // children: [ + // Icon( + // Icons.calendar_today_rounded, + // size: 14, + // color: AppColorsPetugas.textSecondary, + // ), + // const SizedBox(width: 6), + // Text( + // '${sewa.waktuMulai.toIso8601String().substring(0, 10)} - ${sewa.waktuSelesai.toIso8601String().substring(0, 10)}', + // style: TextStyle( + // fontSize: 14, + // color: AppColorsPetugas.textSecondary, + // ), + // ), + // ], + // ), ], ), ), @@ -162,16 +248,28 @@ class PetugasDetailSewaView extends StatelessWidget { CircleAvatar( radius: 24, backgroundColor: AppColorsPetugas.babyBlueLight, - child: Text( - sewa['nama_warga'] - .substring(0, 1) - .toUpperCase(), - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: AppColorsPetugas.blueGrotto, - ), - ), + backgroundImage: + (sewa.wargaAvatar != null && + sewa.wargaAvatar.isNotEmpty) + ? NetworkImage(sewa.wargaAvatar) + : null, + child: + (sewa.wargaAvatar == null || + sewa.wargaAvatar.isEmpty) + ? Text( + (sewa.wargaNama != null && + sewa.wargaNama.isNotEmpty) + ? sewa.wargaNama + .substring(0, 1) + .toUpperCase() + : '-', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.blueGrotto, + ), + ) + : null, ), const SizedBox(width: 12), Expanded( @@ -179,7 +277,7 @@ class PetugasDetailSewaView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - sewa['nama_warga'], + sewa.wargaNama, style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -196,9 +294,9 @@ class PetugasDetailSewaView extends StatelessWidget { ), const SizedBox(width: 4), Text( - '0812-3456-7890', // Placeholder + sewa.wargaNoHp, style: TextStyle( - fontSize: 13, + fontSize: 14, color: AppColorsPetugas.textSecondary, ), ), @@ -218,24 +316,42 @@ class PetugasDetailSewaView extends StatelessWidget { // Asset info Row( children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppColorsPetugas.babyBlueLight, + if (fotoAsetAtauPaket != null && + fotoAsetAtauPaket.isNotEmpty) + ClipRRect( borderRadius: BorderRadius.circular(10), + child: Image.network( + fotoAsetAtauPaket, + width: 40, + height: 40, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => Icon( + Icons.inventory_2_outlined, + size: 28, + color: AppColorsPetugas.blueGrotto, + ), + ), + ) + else + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueLight, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.inventory_2_outlined, + size: 20, + color: AppColorsPetugas.blueGrotto, + ), ), - child: Icon( - Icons.inventory_2_outlined, - size: 20, - color: AppColorsPetugas.blueGrotto, - ), - ), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - sewa['nama_aset'], + namaAsetAtauPaket, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -244,7 +360,7 @@ class PetugasDetailSewaView extends StatelessWidget { ), const SizedBox(height: 2), Text( - '1 unit', + '${sewa.kuantitas} unit', style: TextStyle( fontSize: 13, color: AppColorsPetugas.textSecondary, @@ -258,6 +374,102 @@ class PetugasDetailSewaView extends StatelessWidget { ), ), + // Jika paket, tampilkan detail paket di bawah asset info card + if (isPaket) + Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8), + child: _buildInfoCard( + title: 'Detail Paket', + titleIcon: Icons.inventory_2_outlined, + child: FutureBuilder>>( + future: controller.getPaketItems(sewa.paketId ?? ''), + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + if (snapshot.hasError) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + child: Text( + 'Gagal memuat detail paket', + style: TextStyle(color: Colors.red), + ), + ); + } + final items = snapshot.data ?? []; + if (items.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Text('Tidak ada item dalam paket'), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Nama Aset', + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppColorsPetugas.textPrimary, + ), + ), + Text( + 'Kuantitas', + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppColorsPetugas.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 8), + ...items.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + item['nama_aset'] ?? '-', + style: TextStyle( + color: + AppColorsPetugas + .textSecondary, + ), + ), + ), + Text( + item['kuantitas'].toString(), + style: TextStyle( + color: + AppColorsPetugas.textSecondary, + ), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ), + const SizedBox(height: 16), // Rental Details Card @@ -269,23 +481,70 @@ class PetugasDetailSewaView extends StatelessWidget { _buildDetailRow( icon: Icons.calendar_today_rounded, label: 'Tanggal Mulai', - value: sewa['tanggal_mulai'], + value: DateFormat( + 'dd-MM-yyyy HH:mm:ss', + ).format(sewa.waktuMulai), ), _buildDetailRow( icon: Icons.event_rounded, label: 'Tanggal Selesai', - value: sewa['tanggal_selesai'], + value: DateFormat( + 'dd-MM-yyyy HH:mm:ss', + ).format(sewa.waktuSelesai), ), - _buildDetailRow( - icon: Icons.timer_rounded, - label: 'Durasi', - value: '7 hari', // Placeholder - ), - _buildDetailRow( - icon: Icons.schedule_rounded, - label: 'Status', - value: status, - valueColor: statusColor, + FutureBuilder?>( + future: AsetProvider().getTagihanSewa(sewa.id), + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.waiting) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + ), + child: Row( + children: [ + Icon( + Icons.timer_rounded, + size: 16, + color: AppColorsPetugas.textSecondary, + ), + const SizedBox(width: 12), + Text( + 'Durasi', + style: TextStyle( + fontSize: 14, + color: AppColorsPetugas.textSecondary, + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 16, + height: 16, + child: const CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ], + ), + ); + } + if (snapshot.hasError || snapshot.data == null) { + return _buildDetailRow( + icon: Icons.timer_rounded, + label: 'Durasi', + value: '-', + ); + } + final tagihan = snapshot.data!; + final durasi = tagihan['durasi']?.toString() ?? '-'; + final satuan = + tagihan['satuan_waktu']?.toString() ?? ''; + return _buildDetailRow( + icon: Icons.timer_rounded, + label: 'Durasi', + value: durasi != '-' ? '$durasi $satuan' : '-', + ); + }, ), ], ), @@ -299,428 +558,429 @@ class PetugasDetailSewaView extends StatelessWidget { titleIcon: Icons.receipt_long_rounded, child: Column( children: [ - _buildDetailRow( - icon: Icons.inventory_2_outlined, - label: 'Tagihan Sewa', - value: controller.formatPrice(sewa['total_biaya']), + FutureBuilder?>( + future: AsetProvider().getTagihanSewa(sewa.id), + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.waiting) { + return _buildDetailRow( + icon: Icons.inventory_2_outlined, + label: 'Tagihan Awal', + value: 'Memuat...', + ); + } + if (snapshot.hasError || + !snapshot.hasData || + snapshot.data == null) { + return _buildDetailRow( + icon: Icons.inventory_2_outlined, + label: 'Tagihan Awal', + value: '-', + ); + } + final tagihan = snapshot.data!; + final tagihanAwal = tagihan['tagihan_awal'] ?? 0; + final totalTagihan = tagihan['total_tagihan'] ?? 0; + final dibayar = tagihan['tagihan_dibayar'] ?? 0; + return Column( + children: [ + _buildDetailRow( + icon: Icons.inventory_2_outlined, + label: 'Tagihan Awal', + value: controller.formatPrice(tagihanAwal), + ), + _buildDetailRow( + icon: Icons.warning_amber_rounded, + label: 'Denda', + value: controller.formatPrice( + tagihan['denda'] ?? 0, + ), + valueColor: Colors.red[700], + valueBold: true, + ), + _buildDetailRow( + icon: Icons.payments, + label: 'Dibayar', + value: controller.formatPrice(dibayar), + ), + _buildDetailRow( + icon: Icons.attach_money, + label: 'Total Tagihan', + value: controller.formatPrice(totalTagihan), + ), + ], + ); + }, ), - _buildDetailRow( - icon: Icons.warning_amber_rounded, - label: 'Denda', - value: controller.formatPrice(sewa['denda'] ?? 0), - ), - _buildDetailRow( - icon: Icons.payments_outlined, - label: 'Tagihan Dibayar', - value: controller.formatPrice(sewa['dibayar'] ?? 0), - valueColor: AppColorsPetugas.blueGrotto, - valueBold: true, - ), - // Add Total row when status is "Menunggu Pembayaran" - if (status == 'Menunggu Pembayaran') - _buildDetailRow( - icon: Icons.summarize_rounded, - label: 'Total', - value: controller.formatPrice( - (sewa['total_biaya'] ?? 0) + - (sewa['denda'] ?? 0) - - (sewa['dibayar'] ?? 0), - ), - valueColor: AppColorsPetugas.navyBlue, - valueBold: true, - ), ], ), ), - // Payment Options (only for Menunggu Pembayaran status) - if (status == 'Menunggu Pembayaran') ...[ + // Pisahkan detail denda + if (sewa.denda != null && sewa.denda != 0) ...[ const SizedBox(height: 16), - _buildPaymentOptionsCard(), + FutureBuilder?>( + future: AsetProvider().getTagihanSewa(sewa.id), + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.waiting) { + return _buildInfoCard( + title: 'Detail Denda', + titleIcon: Icons.warning_amber_rounded, + child: const Center( + child: CircularProgressIndicator(), + ), + ); + } + if (snapshot.hasError || + !snapshot.hasData || + snapshot.data == null) { + return _buildInfoCard( + title: 'Detail Denda', + titleIcon: Icons.warning_amber_rounded, + child: const Text('Tidak ada detail denda'), + ); + } + final tagihan = snapshot.data!; + return _buildInfoCard( + title: 'Detail Denda', + titleIcon: Icons.warning_amber_rounded, + child: _buildDendaDetailCard(tagihan), + ); + }, + ), ], - // Payment Proof and Options (for Periksa Pembayaran status) - if (status == 'Periksa Pembayaran') ...[ - const SizedBox(height: 16), - _buildInfoCard( - title: 'Bukti Pembayaran', - titleIcon: Icons.receipt_rounded, + // Bukti Pembayaran dengan TabBar + const SizedBox(height: 16), + _buildInfoCard( + title: 'Bukti Pembayaran', + titleIcon: Icons.receipt_rounded, + child: DefaultTabController( + length: 2, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 8), - Container( - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColorsPetugas.babyBlue, + Align( + alignment: Alignment.center, + child: Container( + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.asset( - 'assets/images/bukti_transfer.jpg', - fit: BoxFit.cover, + child: TabBar( + isScrollable: true, + labelColor: AppColorsPetugas.navyBlue, + unselectedLabelColor: + AppColorsPetugas.textSecondary, + indicator: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.grey.shade300, + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.06), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + labelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + unselectedLabelStyle: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + indicatorSize: TabBarIndicatorSize.label, + overlayColor: MaterialStateProperty.all( + Colors.transparent, + ), + padding: EdgeInsets.zero, + tabs: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 8, + ), + child: const Text('Sewa'), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 8, + ), + child: const Text('Denda'), + ), + ], ), ), ), - const SizedBox(height: 16), - Center( - child: ElevatedButton.icon( - onPressed: () { - // Open image in fullscreen or larger view - // Implement image viewer here - Get.snackbar( - 'Lihat Bukti Transfer', - 'Membuka bukti transfer dalam tampilan penuh', - backgroundColor: AppColorsPetugas.blueGrotto, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, - ); - }, - icon: const Icon(Icons.zoom_in), - label: const Text('Lihat Bukti Transfer'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColorsPetugas.babyBlue - .withOpacity(0.8), - foregroundColor: AppColorsPetugas.navyBlue, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, + SizedBox( + height: 180, + child: TabBarView( + children: [ + // Tab Sewa: tampilkan foto pembayaran tagihan awal + FutureBuilder>( + future: _fetchBuktiPembayaranSewa(sewa.id), + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator(), + ); + } + if (snapshot.hasError || + !snapshot.hasData || + snapshot.data!.isEmpty) { + return const Center( + child: Text( + 'Tidak ada bukti pembayaran sewa', + ), + ); + } + final images = snapshot.data!; + return ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: images.length, + separatorBuilder: + (_, __) => const SizedBox(width: 12), + itemBuilder: (context, idx) { + return ClipRRect( + borderRadius: BorderRadius.circular( + 12, + ), + child: GestureDetector( + onTap: () { + showDialog( + context: context, + builder: + (_) => Stack( + children: [ + Dialog( + backgroundColor: + Colors.black, + insetPadding: + EdgeInsets.zero, + child: InteractiveViewer( + child: Center( + child: Image.network( + images[idx], + fit: + BoxFit + .contain, + errorBuilder: + ( + c, + e, + s, + ) => Container( + color: + Colors + .grey[900], + child: const Icon( + Icons + .broken_image, + color: + Colors.white, + size: + 80, + ), + ), + ), + ), + ), + ), + Positioned( + top: 24, + right: 24, + child: IconButton( + icon: const Icon( + Icons.close, + color: + Colors.white, + size: 32, + ), + onPressed: + () => + Navigator.of( + context, + ).pop(), + ), + ), + ], + ), + ); + }, + child: Image.network( + images[idx], + width: 140, + height: 140, + fit: BoxFit.cover, + errorBuilder: + (c, e, s) => Container( + width: 140, + height: 140, + color: Colors.grey[200], + child: const Icon( + Icons.broken_image, + size: 40, + ), + ), + ), + ), + ); + }, + ); + }, ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + // Tab Denda: tampilkan foto pembayaran denda + FutureBuilder>( + future: _fetchBuktiPembayaranDenda(sewa.id), + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.waiting) { + return const Center( + child: CircularProgressIndicator(), + ); + } + if (snapshot.hasError || + !snapshot.hasData || + snapshot.data!.isEmpty) { + return const Center( + child: Text( + 'Tidak ada bukti pembayaran denda', + ), + ); + } + final images = snapshot.data!; + return ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: images.length, + separatorBuilder: + (_, __) => const SizedBox(width: 12), + itemBuilder: (context, idx) { + return ClipRRect( + borderRadius: BorderRadius.circular( + 12, + ), + child: GestureDetector( + onTap: () { + showDialog( + context: context, + builder: + (_) => Stack( + children: [ + Dialog( + backgroundColor: + Colors.black, + insetPadding: + EdgeInsets.zero, + child: InteractiveViewer( + child: Center( + child: Image.network( + images[idx], + fit: + BoxFit + .contain, + errorBuilder: + ( + c, + e, + s, + ) => Container( + color: + Colors + .grey[900], + child: const Icon( + Icons + .broken_image, + color: + Colors.white, + size: + 80, + ), + ), + ), + ), + ), + ), + Positioned( + top: 24, + right: 24, + child: IconButton( + icon: const Icon( + Icons.close, + color: + Colors.white, + size: 32, + ), + onPressed: + () => + Navigator.of( + context, + ).pop(), + ), + ), + ], + ), + ); + }, + child: Image.network( + images[idx], + width: 140, + height: 140, + fit: BoxFit.cover, + errorBuilder: + (c, e, s) => Container( + width: 140, + height: 140, + color: Colors.grey[200], + child: const Icon( + Icons.broken_image, + size: 40, + ), + ), + ), + ), + ); + }, + ); + }, ), - ), + ], ), ), ], ), ), + ), + + // Opsi pembayaran tepat setelah bukti pembayaran jika status MENUNGGU PEMBAYARAN + if (status == 'MENUNGGU PEMBAYARAN') ...[ const SizedBox(height: 16), - _buildPaymentOptionsCard(), + _buildPaymentOptionsCard(sewa: sewa), ], - // Penalty Details and Payment Options (for Pembayaran Denda status) - if (status == 'Pembayaran Denda') ...[ + // Payment Options (for specific statuses selain MENUNGGU PEMBAYARAN) + if (status == 'PERIKSA PEMBAYARAN') ...[ const SizedBox(height: 16), - _buildInfoCard( - title: 'Detail Denda', - titleIcon: Icons.warning_amber_rounded, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildDetailRow( - icon: Icons.text_snippet_outlined, - label: 'Alasan Denda', - value: 'Kerusakan pada aset saat pengembalian', - ), - _buildDetailRow( - icon: Icons.calendar_today_rounded, - label: 'Tanggal Pelaporan', - value: '20 Maret 2025', - ), - _buildDetailRow( - icon: Icons.money_outlined, - label: 'Nominal Denda', - value: controller.formatPrice(25000), - valueColor: Colors.deepOrange, - valueBold: true, - ), - const SizedBox(height: 16), - Text( - 'Bukti Kerusakan', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColorsPetugas.textPrimary, - ), - ), - const SizedBox(height: 8), - Container( - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColorsPetugas.babyBlue, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.asset( - 'assets/images/kerusakan.jpg', - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 150, - color: AppColorsPetugas.babyBlueBright, - child: Center( - child: Column( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Icon( - Icons.image_not_supported_outlined, - size: 40, - color: Colors.grey, - ), - const SizedBox(height: 8), - Text( - 'Gambar tidak tersedia', - style: TextStyle( - color: - AppColorsPetugas - .textSecondary, - ), - ), - ], - ), - ), - ); - }, - ), - ), - ), - const SizedBox(height: 16), - Center( - child: ElevatedButton.icon( - onPressed: () { - Get.snackbar( - 'Lihat Bukti Kerusakan', - 'Membuka gambar dalam tampilan penuh', - backgroundColor: AppColorsPetugas.blueGrotto, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, - ); - }, - icon: const Icon(Icons.zoom_in), - label: const Text('Lihat Bukti Kerusakan'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColorsPetugas.babyBlue - .withOpacity(0.8), - foregroundColor: AppColorsPetugas.navyBlue, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ], - ), - ), + _buildPaymentOptionsCard(sewa: sewa, isPenalty: false), + ] else if (status == 'PEMBAYARAN DENDA' || + status == 'PERIKSA PEMBAYARAN DENDA') ...[ const SizedBox(height: 16), - _buildPaymentOptionsCard(isPenalty: true), - ], - - // Penalty Details, Payment Proof, and Payment Options (for Periksa Denda status) - if (status == 'Periksa Denda') ...[ - const SizedBox(height: 16), - _buildInfoCard( - title: 'Detail Denda', - titleIcon: Icons.warning_amber_rounded, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildDetailRow( - icon: Icons.text_snippet_outlined, - label: 'Alasan Denda', - value: 'Kerusakan pada aset saat pengembalian', - ), - _buildDetailRow( - icon: Icons.calendar_today_rounded, - label: 'Tanggal Pelaporan', - value: '20 Maret 2025', - ), - _buildDetailRow( - icon: Icons.money_outlined, - label: 'Nominal Denda', - value: controller.formatPrice(25000), - valueColor: Colors.deepOrange, - valueBold: true, - ), - _buildDetailRow( - icon: Icons.calendar_month_rounded, - label: 'Tanggal Pembayaran', - value: '22 Maret 2025', - ), - const SizedBox(height: 16), - Text( - 'Bukti Kerusakan', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColorsPetugas.textPrimary, - ), - ), - const SizedBox(height: 8), - Container( - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColorsPetugas.babyBlue, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.asset( - 'assets/images/kerusakan.jpg', - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 150, - color: AppColorsPetugas.babyBlueBright, - child: Center( - child: Column( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Icon( - Icons.image_not_supported_outlined, - size: 40, - color: Colors.grey, - ), - const SizedBox(height: 8), - Text( - 'Gambar tidak tersedia', - style: TextStyle( - color: - AppColorsPetugas - .textSecondary, - ), - ), - ], - ), - ), - ); - }, - ), - ), - ), - const SizedBox(height: 16), - Center( - child: ElevatedButton.icon( - onPressed: () { - Get.snackbar( - 'Lihat Bukti Kerusakan', - 'Membuka gambar dalam tampilan penuh', - backgroundColor: AppColorsPetugas.blueGrotto, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, - ); - }, - icon: const Icon(Icons.zoom_in), - label: const Text('Lihat Bukti Kerusakan'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColorsPetugas.babyBlue - .withOpacity(0.8), - foregroundColor: AppColorsPetugas.navyBlue, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - _buildInfoCard( - title: 'Bukti Pembayaran Denda', - titleIcon: Icons.receipt_rounded, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - Container( - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColorsPetugas.babyBlue, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.asset( - 'assets/images/bukti_transfer.jpg', - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 150, - color: AppColorsPetugas.babyBlueBright, - child: Center( - child: Column( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Icon( - Icons.image_not_supported_outlined, - size: 40, - color: Colors.grey, - ), - const SizedBox(height: 8), - Text( - 'Gambar tidak tersedia', - style: TextStyle( - color: - AppColorsPetugas - .textSecondary, - ), - ), - ], - ), - ), - ); - }, - ), - ), - ), - const SizedBox(height: 16), - Center( - child: ElevatedButton.icon( - onPressed: () { - Get.snackbar( - 'Lihat Bukti Transfer', - 'Membuka bukti transfer dalam tampilan penuh', - backgroundColor: AppColorsPetugas.blueGrotto, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, - ); - }, - icon: const Icon(Icons.zoom_in), - label: const Text('Lihat Bukti Transfer'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColorsPetugas.babyBlue - .withOpacity(0.8), - foregroundColor: AppColorsPetugas.navyBlue, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - _buildPaymentOptionsCard( - isPenalty: true, - isVerifying: true, - ), + _buildPaymentOptionsCard(sewa: sewa, isPenalty: true), ], const SizedBox(height: 100), // Space for bottom bar @@ -730,7 +990,120 @@ class PetugasDetailSewaView extends StatelessWidget { ), ], ), - bottomNavigationBar: _buildBottomActionBar(), + bottomNavigationBar: + (sewa.status == 'DIKEMBALIKAN') + ? Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () { + _showAddPenaltyDialog(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepOrange, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Tambah Denda'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () async { + controller.completeSewa(sewa.id); + await refreshSewaData(); + Get.snackbar( + 'Sewa Selesai', + 'Status sewa telah diubah menjadi SELESAI', + backgroundColor: Colors.green, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Selesaikan Sewa'), + ), + ), + ], + ), + ) + : (sewa.status == 'AKTIF') + ? Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () async { + await controller.markAsReturned(sewa.id); + await refreshSewaData(); + Get.snackbar( + 'Pengembalian Dikonfirmasi', + 'Status sewa telah diubah menjadi DIKEMBALIKAN', + backgroundColor: Colors.teal, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.teal, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Konfirmasi Pengembalian'), + ), + ), + ) + : ((sewa.status == 'MENUNGGU PEMBAYARAN' || + sewa.status == 'PERIKSA PEMBAYARAN')) + ? Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: () async { + controller.rejectSewa(sewa.id); + await refreshSewaData(); + Get.snackbar( + 'Sewa Dibatalkan', + 'Status sewa telah diubah menjadi DIBATALKAN', + backgroundColor: Colors.red, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Batalkan Sewa'), + ), + ), + // Jika ada tombol lain, tambahkan di sini (misal, opsi pembayaran) + ], + ), + ) + : null, ); } @@ -828,7 +1201,7 @@ class PetugasDetailSewaView extends StatelessWidget { } Widget _buildActionMenu(BuildContext context) { - final status = sewa['status']; + final status = sewa.status; // Determine available actions based on status List> menuItems = []; @@ -946,7 +1319,7 @@ class PetugasDetailSewaView extends StatelessWidget { switch (value) { case 'check_payment': // Update status to "Periksa Pembayaran" - controller.approveSewa(sewa['id']); // Reusing existing method + controller.approveSewa(sewa.id); // Reusing existing method Get.back(); Get.snackbar( 'Status Diubah', @@ -959,7 +1332,7 @@ class PetugasDetailSewaView extends StatelessWidget { case 'approve': // Update status to "Diterima" - controller.approveSewa(sewa['id']); + controller.approveSewa(sewa.id); Get.back(); Get.snackbar( 'Pengajuan Diterima', @@ -972,7 +1345,7 @@ class PetugasDetailSewaView extends StatelessWidget { case 'request_penalty': // Update status to "Pembayaran Denda" - controller.requestPenaltyPayment(sewa['id']); + controller.requestPenaltyPayment(sewa.id); Get.back(); Get.snackbar( 'Permintaan Denda', @@ -985,7 +1358,7 @@ class PetugasDetailSewaView extends StatelessWidget { case 'check_penalty': // Update status to "Periksa Denda" - controller.markPenaltyForInspection(sewa['id']); + controller.markPenaltyForInspection(sewa.id); Get.back(); Get.snackbar( 'Status Diubah', @@ -998,7 +1371,7 @@ class PetugasDetailSewaView extends StatelessWidget { case 'complete': // Update status to "Selesai" - controller.completeSewa(sewa['id']); + controller.completeSewa(sewa.id); Get.back(); Get.snackbar( 'Sewa Selesai', @@ -1011,7 +1384,7 @@ class PetugasDetailSewaView extends StatelessWidget { case 'cancel': // Update status to "Dibatalkan" - controller.rejectSewa(sewa['id']); + controller.rejectSewa(sewa.id); Get.back(); Get.snackbar( 'Sewa Dibatalkan', @@ -1025,7 +1398,7 @@ class PetugasDetailSewaView extends StatelessWidget { } Widget? _buildBottomActionBar() { - final status = sewa['status']; + final status = sewa.status; // Button text and actions based on status String? buttonText; @@ -1117,7 +1490,7 @@ class PetugasDetailSewaView extends StatelessWidget { ), onTap: () { Get.back(); - controller.markAsReturned(sewa['id']); + controller.markAsReturned(sewa.id); Get.back(); Get.snackbar( 'Aset Dikembalikan', @@ -1150,7 +1523,7 @@ class PetugasDetailSewaView extends StatelessWidget { ), onTap: () { Get.back(); - controller.requestPenaltyPayment(sewa['id']); + controller.requestPenaltyPayment(sewa.id); Get.back(); Get.snackbar( 'Denda Diterapkan', @@ -1183,7 +1556,7 @@ class PetugasDetailSewaView extends StatelessWidget { buttonIcon = Icons.task_alt_outlined; buttonColor = Colors.purple; onPressed = () { - controller.completeSewa(sewa['id']); + controller.completeSewa(sewa.id); Get.back(); Get.snackbar( 'Sewa Selesai', @@ -1231,7 +1604,7 @@ class PetugasDetailSewaView extends StatelessWidget { Expanded( child: ElevatedButton.icon( onPressed: () { - controller.completeSewa(sewa['id']); + controller.completeSewa(sewa.id); Get.back(); Get.snackbar( 'Sewa Selesai', @@ -1463,9 +1836,11 @@ class PetugasDetailSewaView extends StatelessWidget { Widget _buildPaymentOptionsCard({ bool isPenalty = false, bool isVerifying = false, + required SewaModel sewa, }) { - final status = sewa['status']; - final isFullPayment = true.obs; + final status = sewa.status; + final isFullPayment = controller.getIsFullPayment(sewa.id); + final nominalController = controller.getNominalController(sewa.id); // Set title based on context String cardTitle = 'Opsi Pembayaran'; @@ -1482,29 +1857,45 @@ class PetugasDetailSewaView extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Full Payment Option (Radio Button) - Only show when not verifying + // Full Payment Option (Button) - Only show when not verifying if (!isVerifying) - Obx( - () => Row( - children: [ - Radio( - value: true, - groupValue: isFullPayment.value, - onChanged: (value) { - isFullPayment.value = true; - // Set payment amount to full rental fee - }, - activeColor: AppColorsPetugas.blueGrotto, + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + nominalController.text = sewa.totalTagihan.toString(); + }, + icon: Icon( + Icons.payments_outlined, + color: AppColorsPetugas.blueGrotto, ), - Text( + label: Text( isPenalty ? 'Pembayaran Denda Penuh' : 'Pembayaran Penuh', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, - color: AppColorsPetugas.textPrimary, + color: AppColorsPetugas.blueGrotto, ), ), - ], + style: OutlinedButton.styleFrom( + side: BorderSide( + color: AppColorsPetugas.blueGrotto, + width: 2, + ), + backgroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + foregroundColor: AppColorsPetugas.blueGrotto, + ), + ), ), ), @@ -1520,165 +1911,125 @@ class PetugasDetailSewaView extends StatelessWidget { const SizedBox(height: 12), // Payment Method Options - Row( - children: [ - Expanded( - child: InkWell( - onTap: - !isVerifying - ? () { - // Handle cash payment selection - } - : null, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: - isVerifying && sewa['payment_method'] != 'cash' - ? Colors.white - : AppColorsPetugas.babyBlueBright, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: - isVerifying && sewa['payment_method'] == 'cash' - ? AppColorsPetugas.blueGrotto - : AppColorsPetugas.babyBlue, - width: - isVerifying && sewa['payment_method'] == 'cash' - ? 2 - : 1, + Obx(() { + final selectedMethod = controller.getPaymentMethod(sewa.id).value; + return Row( + children: [ + Expanded( + child: InkWell( + onTap: + !isVerifying + ? () { + controller.setPaymentMethod(sewa.id, 'Tunai'); + } + : null, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + selectedMethod == 'Tunai' + ? AppColorsPetugas.blueGrotto + : AppColorsPetugas.babyBlue, + width: selectedMethod == 'Tunai' ? 2 : 1, + ), ), - ), - child: Column( - children: [ - Icon( - Icons.payments_outlined, - color: AppColorsPetugas.blueGrotto, - size: 24, - ), - const SizedBox(height: 8), - Text( - 'Tunai', - style: TextStyle( - fontSize: 14, - fontWeight: - isVerifying && sewa['payment_method'] == 'cash' - ? FontWeight.w600 - : FontWeight.w500, - color: AppColorsPetugas.textPrimary, + child: Column( + children: [ + Icon( + Icons.payments_outlined, + color: AppColorsPetugas.blueGrotto, + size: 24, ), - ), - ], + const SizedBox(height: 8), + Text( + 'Tunai', + style: TextStyle( + fontSize: 14, + fontWeight: + selectedMethod == 'Tunai' + ? FontWeight.w600 + : FontWeight.w500, + color: AppColorsPetugas.textPrimary, + ), + ), + ], + ), ), ), ), - ), - const SizedBox(width: 12), - Expanded( - child: InkWell( - onTap: - !isVerifying - ? () { - // Handle transfer payment selection - } - : null, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: - isVerifying && sewa['payment_method'] != 'transfer' - ? Colors.white - : AppColorsPetugas.babyBlueBright, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: - isVerifying && sewa['payment_method'] == 'transfer' - ? AppColorsPetugas.blueGrotto - : AppColorsPetugas.babyBlue, - width: - isVerifying && sewa['payment_method'] == 'transfer' - ? 2 - : 1, + const SizedBox(width: 12), + Expanded( + child: InkWell( + onTap: + !isVerifying + ? () { + controller.setPaymentMethod(sewa.id, 'Transfer'); + } + : null, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + selectedMethod == 'Transfer' + ? AppColorsPetugas.blueGrotto + : AppColorsPetugas.babyBlue, + width: selectedMethod == 'Transfer' ? 2 : 1, + ), ), - ), - child: Column( - children: [ - Icon( - Icons.account_balance_outlined, - color: AppColorsPetugas.blueGrotto, - size: 24, - ), - const SizedBox(height: 8), - Text( - 'Transfer', - style: TextStyle( - fontSize: 14, - fontWeight: - isVerifying && - sewa['payment_method'] == 'transfer' - ? FontWeight.w600 - : FontWeight.w500, - color: AppColorsPetugas.textPrimary, + child: Column( + children: [ + Icon( + Icons.account_balance_outlined, + color: AppColorsPetugas.blueGrotto, + size: 24, ), - ), - ], + const SizedBox(height: 8), + Text( + 'Transfer', + style: TextStyle( + fontSize: 14, + fontWeight: + selectedMethod == 'Transfer' + ? FontWeight.w600 + : FontWeight.w500, + color: AppColorsPetugas.textPrimary, + ), + ), + ], + ), ), ), ), - ), - ], - ), + ], + ); + }), const SizedBox(height: 20), - // Amount Input Field - Text( - isPenalty - ? isVerifying - ? 'Nominal Denda Dibayarkan' - : 'Nominal Pembayaran Denda' - : isVerifying - ? 'Nominal Dibayarkan' - : 'Nominal Pembayaran', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: AppColorsPetugas.textPrimary, - ), - ), - const SizedBox(height: 8), - Obx( - () => TextFormField( - keyboardType: TextInputType.number, - initialValue: - isFullPayment.value - ? isPenalty - ? '25000' // Hardcoded penalty amount - : ((sewa['total_biaya'] ?? 0) + - (sewa['denda'] ?? 0) - - (sewa['dibayar'] ?? 0)) - .toString() - : isVerifying - ? (sewa['paid_amount'] ?? 0).toString() - : null, - enabled: !isVerifying, - decoration: InputDecoration( - hintText: - isPenalty - ? 'Masukkan nominal pembayaran denda' - : 'Masukkan nominal pembayaran', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: AppColorsPetugas.babyBlue), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: AppColorsPetugas.babyBlue), - ), - contentPadding: const EdgeInsets.symmetric( - vertical: 14, - horizontal: 16, - ), + // Nominal Pembayaran + TextFormField( + controller: nominalController, + keyboardType: TextInputType.number, + enabled: !isVerifying, + decoration: InputDecoration( + hintText: sewa.totalTagihan.toString(), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: AppColorsPetugas.babyBlue), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: AppColorsPetugas.babyBlue), + ), + contentPadding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 16, ), ), ), @@ -1689,83 +2040,35 @@ class PetugasDetailSewaView extends StatelessWidget { SizedBox( width: double.infinity, child: ElevatedButton.icon( - onPressed: () { - // Handle payment confirmation or verification - if (isVerifying && isPenalty) { - // Verify penalty payment - controller.completeSewa(sewa['id']); - Get.back(); + onPressed: () async { + final nominal = int.tryParse(nominalController.text) ?? 0; + if (nominal > sewa.totalTagihan) { Get.snackbar( - 'Pembayaran Denda Diverifikasi', - 'Status sewa diubah menjadi Selesai', - backgroundColor: Colors.purple, + 'Nominal Tidak Valid', + 'Nominal tidak boleh melebihi total tagihan', + backgroundColor: Colors.red, colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, - ); - } else if (isVerifying) { - // Verify regular payment - controller.approveSewa(sewa['id']); - Get.back(); - Get.snackbar( - 'Pembayaran Diverifikasi', - 'Pengajuan sewa aset telah disetujui', - backgroundColor: Colors.green.shade600, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, - ); - } else if (isPenalty) { - // Handle penalty payment - controller.markPenaltyForInspection(sewa['id']); - Get.back(); - Get.snackbar( - 'Pembayaran Denda Dikonfirmasi', - 'Status diubah menjadi Periksa Denda', - backgroundColor: Colors.deepOrange, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, - ); - } else { - // Confirm regular payment - controller.approveSewa(sewa['id']); - Get.back(); - Get.snackbar( - 'Pembayaran Dikonfirmasi', - 'Status pengajuan diubah menjadi Periksa Pembayaran', - backgroundColor: Colors.green.shade600, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, ); + return; } + final metode = controller.getPaymentMethod(sewa.id).value; + await controller.confirmPembayaranTagihan( + sewaAsetId: sewa.id, + nominal: nominal, + metodePembayaran: metode, + ); + await refreshSewaData(); }, - icon: Icon( - isVerifying - ? isPenalty - ? Icons.task_alt_outlined - : Icons.check_circle_outline - : isPenalty - ? Icons.warning_amber_rounded - : Icons.payments_outlined, - ), - label: Text( - isVerifying - ? isPenalty - ? 'Verifikasi Pembayaran Denda' - : 'Verifikasi Pembayaran' - : isPenalty - ? 'Konfirmasi Pembayaran Denda' - : 'Konfirmasi Pembayaran', - ), + icon: const Icon(Icons.check_circle_outline), + label: Text(isVerifying ? 'Verifikasi' : 'Konfirmasi Pembayaran'), style: ElevatedButton.styleFrom( - backgroundColor: - isVerifying && isPenalty - ? Colors.purple - : isVerifying - ? Colors.green.shade600 - : isPenalty - ? Colors.deepOrange - : AppColorsPetugas.blueGrotto, + backgroundColor: AppColorsPetugas.blueGrotto, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), @@ -1909,27 +2212,72 @@ class PetugasDetailSewaView extends StatelessWidget { color: Colors.transparent, borderRadius: BorderRadius.circular(8), child: InkWell( - onTap: () { - // This would open the camera in a real implementation - // Using a snackbar here as a placeholder - Get.snackbar( - 'Membuka Kamera', - 'Implementasi kamera akan dibuka di sini', - backgroundColor: AppColorsPetugas.blueGrotto, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, + onTap: () async { + // Show bottom sheet to choose image source + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(16), + ), + ), + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.camera_alt), + title: const Text('Ambil Foto'), + onTap: () async { + Navigator.pop(context); + final ImagePicker picker = + ImagePicker(); + final XFile? image = await picker + .pickImage( + source: ImageSource.camera, + imageQuality: 80, + ); + if (image != null) { + selectedDamageImage.value = File( + image.path, + ); + hasImage.value = true; + } + }, + ), + ListTile( + leading: const Icon(Icons.photo_library), + title: const Text('Pilih dari Galeri'), + onTap: () async { + Navigator.pop(context); + final ImagePicker picker = + ImagePicker(); + final XFile? image = await picker + .pickImage( + source: ImageSource.gallery, + imageQuality: 80, + ); + if (image != null) { + selectedDamageImage.value = File( + image.path, + ); + hasImage.value = true; + } + }, + ), + ], + ), + ); + }, ); - - // Simulate taking a photo for the UI - hasImage.value = true; }, borderRadius: BorderRadius.circular(8), child: - hasImage.value + hasImage.value && selectedDamageImage.value != null ? Stack( alignment: Alignment.center, children: [ - // This would be the actual image preview Container( width: double.infinity, decoration: BoxDecoration( @@ -1937,15 +2285,14 @@ class PetugasDetailSewaView extends StatelessWidget { ), child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: Image.asset( - 'assets/images/damage_preview.jpg', + child: Image.file( + selectedDamageImage.value!, fit: BoxFit.cover, errorBuilder: ( context, error, stackTrace, ) { - // Fallback if image not found return Center( child: Icon( Icons.image, @@ -1958,7 +2305,6 @@ class PetugasDetailSewaView extends StatelessWidget { ), ), ), - // Edit overlay Container( width: double.infinity, height: double.infinity, @@ -2004,7 +2350,7 @@ class PetugasDetailSewaView extends StatelessWidget { SizedBox( width: double.infinity, child: ElevatedButton.icon( - onPressed: () { + onPressed: () async { // Validate inputs if (penaltyAmountController.text.isEmpty) { Get.snackbar( @@ -2017,12 +2363,40 @@ class PetugasDetailSewaView extends StatelessWidget { return; } - // Process the penalty - Get.back(); // Close the dialog + // 1. Upload image to Supabase Storage (foto.denda) + String? fotoUrl; + if (selectedDamageImage.value != null) { + final file = File(selectedDamageImage.value!.path); + fotoUrl = await AsetProvider().uploadFileToStorage( + file, + ); + } - // Update status to Pembayaran Denda - controller.requestPenaltyPayment(sewa['id']); - Get.back(); // Return from the detail page + // 2. Update tagihan_sewa (denda, keterangan, foto_kerusakan) + final tagihan = await AsetProvider().getTagihanSewa( + sewa.id, + ); + if (tagihan != null && tagihan['id'] != null) { + await AsetProvider().client + .from('tagihan_sewa') + .update({ + 'denda': + int.tryParse(penaltyAmountController.text) ?? + 0, + 'keterangan': descriptionController.text, + 'foto_kerusakan': fotoUrl ?? '', + }) + .eq('id', tagihan['id']); + } + + // 3. Update status sewa_aset ke PEMBAYARAN DENDA + await AsetProvider().updateSewaAsetStatus( + sewaAsetId: sewa.id, + status: 'PEMBAYARAN DENDA', + ); + + Get.back(); // Close the dialog + await refreshSewaData(); Get.snackbar( 'Denda Diterapkan', @@ -2051,4 +2425,124 @@ class PetugasDetailSewaView extends StatelessWidget { ), ); } + + Widget _buildDendaDetailCard(Map tagihanSewa) { + final alasan = tagihanSewa['keterangan']?.toString() ?? '-'; + final nominal = + tagihanSewa['denda'] != null && tagihanSewa['denda'] != 0 + ? 'Rp ${NumberFormat('#,###').format(tagihanSewa['denda'])}' + : '-'; + final fotoKerusakan = tagihanSewa['foto_kerusakan']?.toString() ?? ''; + + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailRow( + icon: Icons.text_snippet_outlined, + label: 'Alasan Denda', + value: alasan, + ), + _buildDetailRow( + icon: Icons.attach_money, + label: 'Nominal Denda', + value: nominal, + valueColor: Colors.red[700], + valueBold: true, + ), + const SizedBox(height: 16), + Text( + 'Foto Bukti Kerusakan:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 8), + if (fotoKerusakan.isNotEmpty) + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.network( + fotoKerusakan, + height: 160, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: + (c, e, s) => Container( + height: 160, + color: Colors.grey[200], + child: const Icon(Icons.broken_image, size: 40), + ), + ), + ) + else + const Text('Tidak ada foto kerusakan'), + ], + ), + ), + ); + } + + // Tambahkan fungsi helper untuk fetch bukti pembayaran sewa + Future> _fetchBuktiPembayaranSewa(String sewaId) async { + try { + final supabase = Supabase.instance.client; + // Ambil tagihan_sewa_id dari tagihan_sewa (berdasarkan sewa.id) + final tagihan = + await supabase + .from('tagihan_sewa') + .select('id') + .eq('sewa_aset_id', sewaId) + .maybeSingle(); + if (tagihan == null || tagihan['id'] == null) return []; + final tagihanSewaId = tagihan['id']; + // Ambil foto pembayaran dengan jenis_pembayaran = 'tagihan awal' + final fotoList = await supabase + .from('foto_pembayaran') + .select('foto_pembayaran') + .eq('tagihan_sewa_id', tagihanSewaId) + .eq('jenis_pembayaran', 'tagihan awal'); + if (fotoList == null || fotoList.isEmpty) return []; + return List.from( + fotoList + .map((e) => e['foto_pembayaran']) + .where((url) => url != null && url.toString().isNotEmpty), + ); + } catch (e) { + debugPrint('Error fetching bukti pembayaran sewa: $e'); + return []; + } + } + + // Tambahkan fungsi helper untuk fetch bukti pembayaran denda + Future> _fetchBuktiPembayaranDenda(String sewaId) async { + try { + final supabase = Supabase.instance.client; + // Ambil tagihan_sewa_id dari tagihan_sewa (berdasarkan sewa.id) + final tagihan = + await supabase + .from('tagihan_sewa') + .select('id') + .eq('sewa_aset_id', sewaId) + .maybeSingle(); + if (tagihan == null || tagihan['id'] == null) return []; + final tagihanSewaId = tagihan['id']; + // Ambil foto pembayaran dengan jenis_pembayaran = 'denda' + final fotoList = await supabase + .from('foto_pembayaran') + .select('foto_pembayaran') + .eq('tagihan_sewa_id', tagihanSewaId) + .eq('jenis_pembayaran', 'denda'); + if (fotoList == null || fotoList.isEmpty) return []; + return List.from( + fotoList + .map((e) => e['foto_pembayaran']) + .where((url) => url != null && url.toString().isNotEmpty), + ); + } catch (e) { + debugPrint('Error fetching bukti pembayaran denda: $e'); + return []; + } + } } diff --git a/lib/app/modules/petugas_bumdes/views/petugas_paket_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_paket_view.dart index cbd2353..65eb5ea 100644 --- a/lib/app/modules/petugas_bumdes/views/petugas_paket_view.dart +++ b/lib/app/modules/petugas_bumdes/views/petugas_paket_view.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import '../controllers/petugas_paket_controller.dart'; -import '../../../theme/app_colors_petugas.dart'; +import 'package:bumrent_app/app/modules/petugas_bumdes/controllers/petugas_paket_controller.dart'; +import 'package:bumrent_app/app/routes/app_pages.dart'; +import 'package:bumrent_app/app/data/models/paket_model.dart'; import '../widgets/petugas_bumdes_bottom_navbar.dart'; import '../widgets/petugas_side_navbar.dart'; import '../controllers/petugas_bumdes_dashboard_controller.dart'; import '../../../routes/app_routes.dart'; +import '../../../theme/app_colors_petugas.dart'; class PetugasPaketView extends GetView { const PetugasPaketView({Key? key}) : super(key: key); @@ -53,7 +55,11 @@ class PetugasPaketView extends GetView { ), ), floatingActionButton: FloatingActionButton.extended( - onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_PAKET), + onPressed: + () => Get.toNamed( + Routes.PETUGAS_TAMBAH_PAKET, + arguments: {'isEditing': false}, + ), label: Text( 'Tambah Paket', style: TextStyle( @@ -115,7 +121,7 @@ class PetugasPaketView extends GetView { ); } - if (controller.filteredPaketList.isEmpty) { + if (controller.filteredPackages.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -136,7 +142,11 @@ class PetugasPaketView extends GetView { ), const SizedBox(height: 16), ElevatedButton.icon( - onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_PAKET), + onPressed: + () => Get.toNamed( + Routes.PETUGAS_TAMBAH_PAKET, + arguments: {'isEditing': false}, + ), icon: const Icon(Icons.add), label: const Text('Tambah Paket'), style: ElevatedButton.styleFrom( @@ -161,18 +171,192 @@ class PetugasPaketView extends GetView { color: AppColorsPetugas.blueGrotto, child: ListView.builder( padding: const EdgeInsets.all(16), - itemCount: controller.filteredPaketList.length, + itemCount: controller.filteredPackages.length + 1, itemBuilder: (context, index) { - final paket = controller.filteredPaketList[index]; - return _buildPaketCard(context, paket); + if (index < controller.filteredPackages.length) { + final paket = controller.filteredPackages[index]; + return _buildPaketCard(context, paket); + } else { + // Blank space at the end + return const SizedBox(height: 80); + } }, ), ); }); } - Widget _buildPaketCard(BuildContext context, Map paket) { - final isAvailable = paket['tersedia'] == true; + // Format price helper method + String _formatPrice(dynamic price) { + if (price == null) return '0'; + // If price is a string that can be parsed to a number + if (price is String) { + final number = double.tryParse(price) ?? 0; + return number + .toStringAsFixed(0) + .replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]}.', + ); + } + // If price is already a number + if (price is num) { + return price + .toStringAsFixed(0) + .replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]}.', + ); + } + return '0'; + } + + // Helper method to get time unit name based on ID + String _getTimeUnitName(dynamic unitId) { + if (unitId == null) return 'unit'; + + // Convert to string in case it's not already + final unitIdStr = unitId.toString().toLowerCase(); + + // Map of known time unit IDs to their display names + final timeUnitMap = { + '6eaa32d9-855d-4214-b5b5-5c73d3edd9c5': 'jam', + '582b7e66-6869-4495-9856-cef4a46683b0': 'hari', + // Add more mappings as needed + }; + + // If the unitId is a known ID, return the corresponding name + if (timeUnitMap.containsKey(unitIdStr)) { + return timeUnitMap[unitIdStr]!; + } + + // Check if the unit is already a name (like 'jam' or 'hari') + final knownUnits = ['jam', 'hari', 'minggu', 'bulan']; + if (knownUnits.contains(unitIdStr)) { + return unitIdStr; + } + + // If the unit is a Map, try to extract the name from common fields + if (unitId is Map) { + return unitId['nama']?.toString().toLowerCase() ?? + unitId['name']?.toString().toLowerCase() ?? + unitId['satuan_waktu']?.toString().toLowerCase() ?? + 'unit'; + } + + // Default fallback + return 'unit'; + } + + // Helper method to log time unit details + void _logTimeUnitDetails( + String packageName, + List> timeUnits, + ) { + debugPrint('\nšŸ“¦ [DEBUG] Package: $packageName'); + debugPrint('šŸ”„ Found ${timeUnits.length} time units:'); + + for (var i = 0; i < timeUnits.length; i++) { + final unit = timeUnits[i]; + debugPrint('\n ā±ļø Time Unit #${i + 1}:'); + + // Log all available keys and values + debugPrint(' ā”œā”€ All fields: $unit'); + + // Log specific fields we're interested in + unit.forEach((key, value) { + debugPrint(' ā”œā”€ $key: $value (${value.runtimeType})'); + }); + + // Special handling for satuan_waktu if it's a map + if (unit['satuan_waktu'] is Map) { + final satuanWaktu = unit['satuan_waktu'] as Map; + debugPrint(' └─ satuan_waktu details:'); + satuanWaktu.forEach((k, v) { + debugPrint(' ā”œā”€ $k: $v (${v.runtimeType})'); + }); + } + } + debugPrint('\n'); + } + + Widget _buildPaketCard(BuildContext context, dynamic paket) { + // Handle both Map and PaketModel for backward compatibility + final isPaketModel = paket is PaketModel; + + debugPrint('\nšŸ” [_buildPaketCard] Paket type: ${paket.runtimeType}'); + debugPrint('šŸ“‹ Paket data: $paket'); + + // Extract status based on type + final String status = + isPaketModel + ? (paket.status?.toString().capitalizeFirst ?? 'Tidak Diketahui') + : (paket['status']?.toString().capitalizeFirst ?? + 'Tidak Diketahui'); + + debugPrint('šŸ·ļø Extracted status: $status (isPaketModel: $isPaketModel)'); + + // Extract availability based on type + final bool isAvailable = + isPaketModel + ? (paket.kuantitas > 0) + : ((paket['kuantitas'] as int?) ?? 0) > 0; + + final String nama = + isPaketModel + ? paket.nama + : (paket['nama']?.toString() ?? 'Paket Tanpa Nama'); + + // Debug package info + debugPrint('\nšŸ“¦ [PACKAGE] ${paket.runtimeType} - $nama'); + debugPrint('ā”œā”€ isPaketModel: $isPaketModel'); + debugPrint('ā”œā”€ Available: $isAvailable'); + + // Get the first rental time unit price if available, otherwise use the base price + final dynamic harga; + if (isPaketModel) { + if (paket.satuanWaktuSewa.isNotEmpty) { + _logTimeUnitDetails(nama, paket.satuanWaktuSewa); + + // Get the first time unit with its price + final firstUnit = paket.satuanWaktuSewa.first; + final firstUnitPrice = firstUnit['harga']; + + debugPrint('šŸ’° First time unit price: $firstUnitPrice'); + debugPrint('ā±ļø First time unit ID: ${firstUnit['satuan_waktu_id']}'); + debugPrint('šŸ“ First time unit details: $firstUnit'); + + // Always use the first time unit's price if available + harga = firstUnitPrice ?? 0; + } else { + debugPrint('āš ļø No time units found for package: $nama'); + debugPrint('ā„¹ļø Using base price: ${paket.harga}'); + harga = paket.harga; + } + } else { + // For non-PaketModel (Map) data + if (isPaketModel && paket.satuanWaktuSewa.isNotEmpty) { + final firstUnit = paket.satuanWaktuSewa.first; + final firstUnitPrice = firstUnit['harga']; + debugPrint('šŸ’° [MAP] First time unit price: $firstUnitPrice'); + harga = firstUnitPrice ?? 0; + } else { + debugPrint('āš ļø [MAP] No time units found for package: $nama'); + debugPrint('ā„¹ļø [MAP] Using base price: ${paket['harga']}'); + harga = paket['harga'] ?? 0; + } + } + + debugPrint('šŸ’µ Final price being used: $harga\n'); + + // Get the main photo URL + final String? foto = + isPaketModel + ? (paket.images?.isNotEmpty == true + ? paket.images!.first + : paket.foto_paket) + : (paket['foto_paket']?.toString() ?? + (paket['foto'] is String ? paket['foto'] : null)); return Container( margin: const EdgeInsets.only(bottom: 12), @@ -196,22 +380,83 @@ class PetugasPaketView extends GetView { child: Row( children: [ // Paket image or icon - Container( + SizedBox( width: 80, height: 80, - decoration: BoxDecoration( - color: AppColorsPetugas.babyBlueLight, + child: ClipRRect( borderRadius: const BorderRadius.only( topLeft: Radius.circular(12), bottomLeft: Radius.circular(12), ), - ), - child: Center( - child: Icon( - _getPaketIcon(paket['kategori']), - color: AppColorsPetugas.navyBlue, - size: 32, - ), + child: + foto != null && foto.isNotEmpty + ? Image.network( + foto, + width: 80, + height: 80, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => Container( + color: AppColorsPetugas.babyBlueLight, + child: Center( + child: Icon( + _getPaketIcon( + _getTimeUnitName( + isPaketModel + ? (paket + .satuanWaktuSewa + .isNotEmpty + ? paket + .satuanWaktuSewa + .first['satuan_waktu_id'] ?? + 'hari' + : 'hari') + : (paket['satuanWaktuSewa'] != + null && + paket['satuanWaktuSewa'] + .isNotEmpty + ? paket['satuanWaktuSewa'][0]['satuan_waktu_id'] + ?.toString() ?? + 'hari' + : 'hari'), + ), + ), + color: AppColorsPetugas.navyBlue + .withOpacity(0.5), + size: 32, + ), + ), + ), + ) + : Container( + color: AppColorsPetugas.babyBlueLight, + child: Center( + child: Icon( + _getPaketIcon( + _getTimeUnitName( + isPaketModel + ? (paket.satuanWaktuSewa.isNotEmpty + ? paket + .satuanWaktuSewa + .first['satuan_waktu_id'] ?? + 'hari' + : 'hari') + : (paket['satuanWaktuSewa'] != null && + paket['satuanWaktuSewa'] + .isNotEmpty + ? paket['satuanWaktuSewa'][0]['satuan_waktu_id'] + ?.toString() ?? + 'hari' + : 'hari'), + ), + ), + color: AppColorsPetugas.navyBlue.withOpacity( + 0.5, + ), + size: 32, + ), + ), + ), ), ), @@ -228,9 +473,10 @@ class PetugasPaketView extends GetView { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ + // Package name Text( - paket['nama'], - style: TextStyle( + nama, + style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 14, color: AppColorsPetugas.navyBlue, @@ -239,13 +485,119 @@ class PetugasPaketView extends GetView { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), - Text( - 'Rp ${_formatPrice(paket['harga'])}', - style: TextStyle( - fontSize: 12, - color: AppColorsPetugas.textSecondary, - ), + // Prices with time units + Builder( + builder: (context) { + final List> timeUnits = + []; + + // Get all time units + if (isPaketModel && + paket.satuanWaktuSewa.isNotEmpty) { + timeUnits.addAll(paket.satuanWaktuSewa); + } else if (!isPaketModel && + paket['satuanWaktuSewa'] != null && + paket['satuanWaktuSewa'].isNotEmpty) { + timeUnits.addAll( + List>.from( + paket['satuanWaktuSewa'], + ), + ); + } + + // If no time units, show nothing + if (timeUnits.isEmpty) + return const SizedBox.shrink(); + + // Filter out time units with price 0 or null + final validTimeUnits = + timeUnits.where((unit) { + final price = + unit['harga'] is int + ? unit['harga'] + : int.tryParse( + unit['harga'] + ?.toString() ?? + '0', + ) ?? + 0; + return price > 0; + }).toList(); + + if (validTimeUnits.isEmpty) + return const SizedBox.shrink(); + + return Column( + children: + validTimeUnits + .asMap() + .entries + .map((entry) { + final index = entry.key; + final unit = entry.value; + final unitPrice = + unit['harga'] is int + ? unit['harga'] + : int.tryParse( + unit['harga'] + ?.toString() ?? + '0', + ) ?? + 0; + final unitName = _getTimeUnitName( + unit['satuan_waktu_id'], + ); + final isFirst = index == 0; + + if (unitPrice <= 0) + return const SizedBox.shrink(); + + return Row( + children: [ + Flexible( + child: Text( + 'Rp ${_formatPrice(unitPrice)}/$unitName', + style: TextStyle( + fontSize: 12, + color: + AppColorsPetugas + .textSecondary, + ), + maxLines: 2, + overflow: + TextOverflow.ellipsis, + softWrap: true, + ), + ), + ], + ); + }) + .where( + (widget) => widget is! SizedBox, + ) + .toList(), + ); + }, ), + if (!isPaketModel && + paket['harga'] != null && + (paket['harga'] is int + ? paket['harga'] + : int.tryParse( + paket['harga']?.toString() ?? + '0', + ) ?? + 0) > + 0) ...[ + const SizedBox(height: 4), + Text( + 'Rp ${_formatPrice(paket['harga'])}', + style: TextStyle( + fontSize: 12, + color: AppColorsPetugas.textSecondary, + ), + ), + ], ], ), ), @@ -258,25 +610,31 @@ class PetugasPaketView extends GetView { ), decoration: BoxDecoration( color: - isAvailable + status.toLowerCase() == 'tersedia' ? AppColorsPetugas.successLight + : status.toLowerCase() == 'pemeliharaan' + ? AppColorsPetugas.warningLight : AppColorsPetugas.errorLight, borderRadius: BorderRadius.circular(8), border: Border.all( color: - isAvailable + status.toLowerCase() == 'tersedia' ? AppColorsPetugas.success + : status.toLowerCase() == 'pemeliharaan' + ? AppColorsPetugas.warning : AppColorsPetugas.error, width: 1, ), ), child: Text( - isAvailable ? 'Aktif' : 'Nonaktif', + status, style: TextStyle( fontSize: 10, color: - isAvailable + status.toLowerCase() == 'tersedia' ? AppColorsPetugas.success + : status.toLowerCase() == 'pemeliharaan' + ? AppColorsPetugas.warning : AppColorsPetugas.error, fontWeight: FontWeight.w500, ), @@ -290,9 +648,12 @@ class PetugasPaketView extends GetView { // Edit icon GestureDetector( onTap: - () => _showAddEditPaketDialog( - context, - paket: paket, + () => Get.toNamed( + Routes.PETUGAS_TAMBAH_PAKET, + arguments: { + 'isEditing': true, + 'paket': paket, + }, ), child: Container( padding: const EdgeInsets.all(5), @@ -350,33 +711,42 @@ class PetugasPaketView extends GetView { ); } - String _formatPrice(dynamic price) { - if (price == null) return '0'; - - // Convert the price to string and handle formatting - String priceStr = price.toString(); - - // Add thousand separators - final RegExp reg = RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'); - String formatted = priceStr.replaceAllMapped(reg, (Match m) => '${m[1]}.'); - - return formatted; + // Add this helper method to get color based on status + Color _getStatusColor(String status) { + switch (status.toLowerCase()) { + case 'aktif': + return AppColorsPetugas.success; + case 'tidak aktif': + case 'nonaktif': + return AppColorsPetugas.error; + case 'dalam perbaikan': + case 'maintenance': + return AppColorsPetugas.warning; + case 'tersedia': + return AppColorsPetugas.success; + case 'pemeliharaan': + return AppColorsPetugas.warning; + default: + return Colors.grey; + } } - IconData _getPaketIcon(String? category) { - if (category == null) return Icons.category; + IconData _getPaketIcon(String? timeUnit) { + if (timeUnit == null) return Icons.access_time; - switch (category.toLowerCase()) { - case 'bulanan': - return Icons.calendar_month; - case 'tahunan': + switch (timeUnit.toLowerCase()) { + case 'jam': + return Icons.access_time; + case 'hari': return Icons.calendar_today; - case 'premium': - return Icons.star; - case 'bisnis': - return Icons.business; + case 'minggu': + return Icons.date_range; + case 'bulan': + return Icons.calendar_month; + case 'tahun': + return Icons.calendar_view_month; default: - return Icons.category; + return Icons.access_time; } } @@ -426,7 +796,27 @@ class PetugasPaketView extends GetView { ); } - void _showPaketDetails(BuildContext context, Map paket) { + void _showPaketDetails(BuildContext context, dynamic paket) { + // Handle both Map and PaketModel for backward compatibility + final isPaketModel = paket is PaketModel; + final String nama = + isPaketModel + ? paket.nama + : (paket['nama']?.toString() ?? 'Paket Tanpa Nama'); + final String? deskripsi = + isPaketModel ? paket.deskripsi : paket['deskripsi']?.toString(); + final bool isAvailable = + isPaketModel + ? (paket.kuantitas > 0) + : ((paket['kuantitas'] as int?) ?? 0) > 0; + final dynamic harga = + isPaketModel + ? (paket.satuanWaktuSewa.isNotEmpty + ? paket.satuanWaktuSewa.first['harga'] + : paket.harga) + : (paket['harga'] ?? 0); + // Items are not part of the PaketModel, so we'll use an empty list + final List> items = []; showModalBottomSheet( context: context, isScrollControlled: true, @@ -448,7 +838,7 @@ class PetugasPaketView extends GetView { children: [ Expanded( child: Text( - paket['nama'], + nama, style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, @@ -473,16 +863,15 @@ class PetugasPaketView extends GetView { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildDetailItem('Kategori', paket['kategori']), _buildDetailItem( 'Harga', - controller.formatPrice(paket['harga']), + 'Rp ${_formatPrice(harga)}', ), _buildDetailItem( 'Status', - paket['tersedia'] ? 'Tersedia' : 'Tidak Tersedia', + isAvailable ? 'Tersedia' : 'Tidak Tersedia', ), - _buildDetailItem('Deskripsi', paket['deskripsi']), + _buildDetailItem('Deskripsi', deskripsi ?? '-'), ], ), ), @@ -502,11 +891,11 @@ class PetugasPaketView extends GetView { child: ListView.separated( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, - itemCount: paket['items'].length, + itemCount: items.length, separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { - final item = paket['items'][index]; + final item = items[index]; return ListTile( leading: CircleAvatar( backgroundColor: AppColorsPetugas.babyBlue, @@ -601,10 +990,11 @@ class PetugasPaketView extends GetView { ); } - void _showAddEditPaketDialog( - BuildContext context, { - Map? paket, - }) { + void _showAddEditPaketDialog(BuildContext context, {dynamic paket}) { + // Handle both Map and PaketModel for backward compatibility + final isPaketModel = paket is PaketModel; + final String? id = isPaketModel ? paket.id : paket?['id']; + final String title = id == null ? 'Tambah Paket' : 'Edit Paket'; final isEditing = paket != null; // This would be implemented with proper form validation in a real app @@ -613,7 +1003,7 @@ class PetugasPaketView extends GetView { builder: (context) { return AlertDialog( title: Text( - isEditing ? 'Edit Paket' : 'Tambah Paket Baru', + title, style: TextStyle(color: AppColorsPetugas.navyBlue), ), content: const Text( @@ -652,10 +1042,13 @@ class PetugasPaketView extends GetView { ); } - void _showDeleteConfirmation( - BuildContext context, - Map paket, - ) { + void _showDeleteConfirmation(BuildContext context, dynamic paket) { + // Handle both Map and PaketModel for backward compatibility + final isPaketModel = paket is PaketModel; + final String id = isPaketModel ? paket.id : (paket['id']?.toString() ?? ''); + final String nama = + isPaketModel ? paket.nama : (paket['nama']?.toString() ?? 'Paket'); + showDialog( context: context, builder: (context) { @@ -664,9 +1057,7 @@ class PetugasPaketView extends GetView { 'Konfirmasi Hapus', style: TextStyle(color: AppColorsPetugas.navyBlue), ), - content: Text( - 'Apakah Anda yakin ingin menghapus paket "${paket['nama']}"?', - ), + content: Text('Apakah Anda yakin ingin menghapus paket "$nama"?'), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -678,7 +1069,7 @@ class PetugasPaketView extends GetView { ElevatedButton( onPressed: () { Navigator.pop(context); - controller.deletePaket(paket['id']); + controller.deletePaket(id); Get.snackbar( 'Paket Dihapus', 'Paket berhasil dihapus dari sistem', diff --git a/lib/app/modules/petugas_bumdes/views/petugas_sewa_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_sewa_view.dart index 72ab0bb..1696bb4 100644 --- a/lib/app/modules/petugas_bumdes/views/petugas_sewa_view.dart +++ b/lib/app/modules/petugas_bumdes/views/petugas_sewa_view.dart @@ -6,6 +6,7 @@ import '../widgets/petugas_bumdes_bottom_navbar.dart'; import '../widgets/petugas_side_navbar.dart'; import '../controllers/petugas_bumdes_dashboard_controller.dart'; import 'petugas_detail_sewa_view.dart'; +import '../../../data/models/rental_booking_model.dart'; class PetugasSewaView extends StatefulWidget { const PetugasSewaView({Key? key}) : super(key: key); @@ -160,6 +161,10 @@ class _PetugasSewaViewState extends State } Widget _buildSearchSection() { + // Tambahkan controller untuk TextField agar bisa dikosongkan + final TextEditingController searchController = TextEditingController( + text: controller.searchQuery.value, + ); return Container( padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), decoration: BoxDecoration( @@ -173,9 +178,9 @@ class _PetugasSewaViewState extends State ], ), child: TextField( + controller: searchController, onChanged: (value) { controller.setSearchQuery(value); - controller.setOrderIdQuery(value); }, decoration: InputDecoration( hintText: 'Cari nama warga atau ID pesanan...', @@ -204,10 +209,21 @@ class _PetugasSewaViewState extends State ), contentPadding: EdgeInsets.zero, isDense: true, - suffixIcon: Icon( - Icons.tune_rounded, - color: AppColorsPetugas.textSecondary, - size: 20, + suffixIcon: Obx( + () => + controller.searchQuery.value.isNotEmpty + ? IconButton( + icon: Icon( + Icons.close, + color: AppColorsPetugas.textSecondary, + size: 20, + ), + onPressed: () { + searchController.clear(); + controller.setSearchQuery(''); + }, + ) + : SizedBox.shrink(), ), ), ), @@ -241,17 +257,44 @@ class _PetugasSewaViewState extends State final filteredList = status == 'Semua' ? controller.filteredSewaList + : status == 'Menunggu Pembayaran' + ? controller.sewaList + .where( + (sewa) => + sewa.status.toUpperCase() == 'MENUNGGU PEMBAYARAN' || + sewa.status.toUpperCase() == 'PEMBAYARAN DENDA', + ) + .toList() : status == 'Periksa Pembayaran' ? controller.sewaList .where( (sewa) => - sewa['status'] == 'Periksa Pembayaran' || - sewa['status'] == 'Pembayaran Denda' || - sewa['status'] == 'Periksa Denda', + sewa.status.toUpperCase() == 'PERIKSA PEMBAYARAN' || + sewa.status.toUpperCase() == 'PERIKSA PEMBAYARAN DENDA', ) .toList() + : status == 'Diterima' + ? controller.sewaList + .where((sewa) => sewa.status.toUpperCase() == 'DITERIMA') + .toList() + : status == 'Aktif' + ? controller.sewaList + .where((sewa) => sewa.status.toUpperCase() == 'AKTIF') + .toList() + : status == 'Dikembalikan' + ? controller.sewaList + .where((sewa) => sewa.status.toUpperCase() == 'DIKEMBALIKAN') + .toList() + : status == 'Selesai' + ? controller.sewaList + .where((sewa) => sewa.status.toUpperCase() == 'SELESAI') + .toList() + : status == 'Dibatalkan' + ? controller.sewaList + .where((sewa) => sewa.status.toUpperCase() == 'DIBATALKAN') + .toList() : controller.sewaList - .where((sewa) => sewa['status'] == status) + .where((sewa) => sewa.status == status) .toList(); if (filteredList.isEmpty) { @@ -313,40 +356,25 @@ class _PetugasSewaViewState extends State }); } - Widget _buildSewaCard(BuildContext context, Map sewa) { - final statusColor = controller.getStatusColor(sewa['status']); - final status = sewa['status']; + Widget _buildSewaCard(BuildContext context, SewaModel sewa) { + final statusColor = controller.getStatusColor(sewa.status); + final status = sewa.status; // Get appropriate icon for status - IconData statusIcon; - switch (status) { - case 'Menunggu Pembayaran': - statusIcon = Icons.payments_outlined; - break; - case 'Periksa Pembayaran': - statusIcon = Icons.fact_check_outlined; - break; - case 'Diterima': - statusIcon = Icons.check_circle_outlined; - break; - case 'Pembayaran Denda': - statusIcon = Icons.money_off_csred_outlined; - break; - case 'Periksa Denda': - statusIcon = Icons.assignment_late_outlined; - break; - case 'Dikembalikan': - statusIcon = Icons.assignment_return_outlined; - break; - case 'Selesai': - statusIcon = Icons.task_alt_outlined; - break; - case 'Dibatalkan': - statusIcon = Icons.cancel_outlined; - break; - default: - statusIcon = Icons.help_outline_rounded; - } + IconData statusIcon = controller.getStatusIcon(status); + + // Flag untuk membedakan tipe pesanan + final bool isAset = sewa.tipePesanan == 'tunggal'; + final bool isPaket = sewa.tipePesanan == 'paket'; + + // Pilih nama aset/paket + final String namaAsetAtauPaket = + isAset + ? (sewa.asetNama ?? '-') + : (isPaket ? (sewa.paketNama ?? '-') : '-'); + // Pilih foto aset/paket jika ingin digunakan + final String? fotoAsetAtauPaket = + isAset ? sewa.asetFoto : (isPaket ? sewa.paketFoto : null); return Container( margin: const EdgeInsets.only(bottom: 16), @@ -370,6 +398,35 @@ class _PetugasSewaViewState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Status header inside the card + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.12), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(statusIcon, size: 16, color: statusColor), + const SizedBox(width: 8), + Text( + status, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + ], + ), + ), Padding( padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), child: Row( @@ -378,14 +435,22 @@ class _PetugasSewaViewState extends State CircleAvatar( radius: 24, backgroundColor: AppColorsPetugas.babyBlueLight, - child: Text( - sewa['nama_warga'].substring(0, 1).toUpperCase(), - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: AppColorsPetugas.blueGrotto, - ), - ), + backgroundImage: + (sewa.wargaAvatar != null && + sewa.wargaAvatar.isNotEmpty) + ? NetworkImage(sewa.wargaAvatar) + : null, + child: + (sewa.wargaAvatar == null || sewa.wargaAvatar.isEmpty) + ? Text( + sewa.wargaNama.substring(0, 1).toUpperCase(), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.blueGrotto, + ), + ) + : null, ), const SizedBox(width: 16), @@ -395,55 +460,22 @@ class _PetugasSewaViewState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - sewa['nama_warga'], + sewa.wargaNama, style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: AppColorsPetugas.textPrimary, ), ), - const SizedBox(height: 2), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: statusColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(30), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - statusIcon, - size: 12, - color: statusColor, - ), - const SizedBox(width: 4), - Text( - status, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: statusColor, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - Text( - '#${sewa['order_id']}', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: AppColorsPetugas.textSecondary, - ), - ), - ], + Text( + 'Tanggal Pesan: ' + + (sewa.tanggalPemesanan != null + ? '${sewa.tanggalPemesanan.day.toString().padLeft(2, '0')}-${sewa.tanggalPemesanan.month.toString().padLeft(2, '0')}-${sewa.tanggalPemesanan.year}' + : '-'), + style: TextStyle( + fontSize: 12, + color: AppColorsPetugas.textSecondary, + ), ), ], ), @@ -460,7 +492,7 @@ class _PetugasSewaViewState extends State borderRadius: BorderRadius.circular(8), ), child: Text( - controller.formatPrice(sewa['total_biaya']), + controller.formatPrice(sewa.totalTagihan), style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, @@ -481,33 +513,51 @@ class _PetugasSewaViewState extends State child: Divider(height: 1, color: Colors.grey.shade200), ), - // Asset details + // Asset/Paket details Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), child: Row( children: [ - // Asset icon - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppColorsPetugas.babyBlueLight, + // Asset/Paket image or icon + if (fotoAsetAtauPaket != null && + fotoAsetAtauPaket.isNotEmpty) + ClipRRect( borderRadius: BorderRadius.circular(10), + child: Image.network( + fotoAsetAtauPaket, + width: 40, + height: 40, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => Icon( + Icons.inventory_2_outlined, + size: 28, + color: AppColorsPetugas.blueGrotto, + ), + ), + ) + else + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueLight, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.inventory_2_outlined, + size: 20, + color: AppColorsPetugas.blueGrotto, + ), ), - child: Icon( - Icons.inventory_2_outlined, - size: 20, - color: AppColorsPetugas.blueGrotto, - ), - ), const SizedBox(width: 12), - // Asset name and duration + // Asset/Paket name and duration Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - sewa['nama_aset'], + namaAsetAtauPaket, style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, @@ -524,7 +574,7 @@ class _PetugasSewaViewState extends State ), const SizedBox(width: 4), Text( - '${sewa['tanggal_mulai']} - ${sewa['tanggal_selesai']}', + '${sewa.waktuMulai.toIso8601String().substring(0, 10)} - ${sewa.waktuSelesai.toIso8601String().substring(0, 10)}', style: TextStyle( fontSize: 12, color: AppColorsPetugas.textSecondary, diff --git a/lib/app/modules/petugas_bumdes/views/petugas_tambah_aset_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_tambah_aset_view.dart index 4873099..9d60696 100644 --- a/lib/app/modules/petugas_bumdes/views/petugas_tambah_aset_view.dart +++ b/lib/app/modules/petugas_bumdes/views/petugas_tambah_aset_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'dart:io'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import '../../../theme/app_colors_petugas.dart'; @@ -9,32 +10,51 @@ class PetugasTambahAsetView extends GetView { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.white, - appBar: AppBar( - title: const Text( - 'Tambah Aset', - style: TextStyle(fontWeight: FontWeight.w600), - ), - backgroundColor: AppColorsPetugas.navyBlue, - elevation: 0, - centerTitle: true, - ), - body: SafeArea( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [_buildHeaderSection(), _buildFormSection(context)], + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Obx(() => Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: Text( + controller.isEditing.value ? 'Edit Aset' : 'Tambah Aset', + style: const TextStyle(fontWeight: FontWeight.w600), ), + backgroundColor: AppColorsPetugas.navyBlue, + elevation: 0, + centerTitle: true, ), - ), - bottomNavigationBar: _buildBottomBar(), + body: Stack( + children: [ + SafeArea( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeaderSection(), + _buildFormSection(context), + ], + ), + ), + ), + if (controller.isLoading.value) + Container( + color: Colors.black54, + child: const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppColorsPetugas.blueGrotto), + ), + ), + ), + ], + ), + bottomNavigationBar: _buildBottomBar(), + )), ); } Widget _buildHeaderSection() { return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.only(top: 10, left: 20, right: 20, bottom: 5), // Reduced padding decoration: BoxDecoration( gradient: LinearGradient( colors: [AppColorsPetugas.navyBlue, AppColorsPetugas.blueGrotto], @@ -42,50 +62,8 @@ class PetugasTambahAsetView extends GetView { end: Alignment.bottomCenter, ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.inventory_2_outlined, - color: Colors.white, - size: 28, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Informasi Aset Baru', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - const SizedBox(height: 4), - Text( - 'Isi data dengan lengkap untuk menambahkan aset', - style: TextStyle( - fontSize: 14, - color: Colors.white.withOpacity(0.8), - ), - ), - ], - ), - ), - ], - ), - ], + child: Container( + height: 12, // Further reduced height ), ); } @@ -131,69 +109,36 @@ class PetugasTambahAsetView extends GetView { _buildImageUploader(), const SizedBox(height: 24), - // Category Section - _buildSectionHeader(icon: Icons.category, title: 'Kategori & Status'), + // Status Section + _buildSectionHeader(icon: Icons.check_circle, title: 'Status'), const SizedBox(height: 16), - // Category and Status as cards - Row( - children: [ - Expanded( - child: _buildCategorySelect( - title: 'Kategori', - options: controller.categoryOptions, - selectedOption: controller.selectedCategory, - onChanged: controller.setCategory, - icon: Icons.inventory_2, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildCategorySelect( - title: 'Status', - options: controller.statusOptions, - selectedOption: controller.selectedStatus, - onChanged: controller.setStatus, - icon: Icons.check_circle, - ), - ), - ], + // Status card + _buildCategorySelect( + title: 'Status', + options: controller.statusOptions, + selectedOption: controller.selectedStatus, + onChanged: controller.setStatus, + icon: Icons.check_circle, ), const SizedBox(height: 24), // Quantity Section _buildSectionHeader( icon: Icons.format_list_numbered, - title: 'Kuantitas & Pengukuran', + title: 'Kuantitas', ), const SizedBox(height: 16), - // Quantity fields - Row( - children: [ - Expanded( - flex: 2, - child: _buildTextField( - label: 'Kuantitas', - hint: 'Jumlah aset', - controller: controller.quantityController, - isRequired: true, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - prefixIcon: Icons.numbers, - ), - ), - const SizedBox(width: 12), - Expanded( - flex: 3, - child: _buildTextField( - label: 'Satuan Ukur', - hint: 'contoh: Unit, Buah', - controller: controller.unitOfMeasureController, - prefixIcon: Icons.straighten, - ), - ), - ], + // Quantity field + _buildTextField( + label: 'Kuantitas', + hint: 'Jumlah aset', + controller: controller.quantityController, + isRequired: true, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + prefixIcon: Icons.numbers, ), const SizedBox(height: 24), @@ -654,6 +599,114 @@ class PetugasTambahAsetView extends GetView { ); } + // Show image source options + void _showImageSourceOptions() { + Get.bottomSheet( + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + Text( + 'Pilih Sumber Gambar', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildImageSourceOption( + icon: Icons.camera_alt, + label: 'Kamera', + onTap: () { + Get.back(); + controller.pickImageFromCamera(); + }, + ), + _buildImageSourceOption( + icon: Icons.photo_library, + label: 'Galeri', + onTap: () { + Get.back(); + controller.pickImageFromGallery(); + }, + ), + ], + ), + const SizedBox(height: 10), + TextButton( + onPressed: () => Get.back(), + child: const Text('Batal'), + ), + ], + ), + ), + isScrollControlled: true, + ); + } + + Widget _buildImageSourceOption({ + required IconData icon, + required String label, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Column( + children: [ + Container( + width: 70, + height: 70, + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueBright, + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 30, + color: AppColorsPetugas.blueGrotto, + ), + ), + const SizedBox(height: 8), + Text( + label, + style: TextStyle( + fontSize: 14, + color: AppColorsPetugas.textPrimary, + ), + ), + ], + ), + ); + } + Widget _buildImageUploader() { return Container( padding: const EdgeInsets.all(16), @@ -696,7 +749,7 @@ class PetugasTambahAsetView extends GetView { children: [ // Add button GestureDetector( - onTap: () => controller.addSampleImage(), + onTap: _showImageSourceOptions, child: Container( width: 100, height: 100, @@ -732,69 +785,107 @@ class PetugasTambahAsetView extends GetView { ), // Image previews - ...controller.selectedImages.asMap().entries.map((entry) { - final index = entry.key; - return Container( - width: 100, - height: 100, - decoration: BoxDecoration( - color: AppColorsPetugas.babyBlueLight, - borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 5, - offset: const Offset(0, 2), - ), - ], - ), - child: Stack( - children: [ - ClipRRect( + ...List.generate( + controller.selectedImages.length, + (index) => Stack( + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey[300]!), + ), + child: Obx( + () { + // Check if we have a network URL for this index + if (index < controller.networkImageUrls.length && + controller.networkImageUrls[index].isNotEmpty) { + // Display network image + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + controller.networkImageUrls[index], + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Icon(Icons.error_outline, color: Colors.red), + ); + }, + ), + ); + } else { + // Display local file + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: FutureBuilder( + future: File(controller.selectedImages[index].path).exists().then((exists) { + if (exists) { + return File(controller.selectedImages[index].path); + } else { + return File(controller.selectedImages[index].path); + } + }), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data != null) { + return Image.file( + snapshot.data!, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey[200], + child: const Icon(Icons.broken_image, color: Colors.grey), + ); + }, + ); + } else { + return Container( + color: Colors.grey[200], + child: const Center( + child: CircularProgressIndicator(), + ), + ); + } + }, + ), + ); + } + }, + ), + ), + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: () => controller.removeImage(index), child: Container( - width: 100, - height: 100, - color: AppColorsPetugas.babyBlueLight, - child: Center( - child: Icon( - Icons.image, - color: AppColorsPetugas.blueGrotto, - size: 40, - ), + padding: const EdgeInsets.all(2), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 1), + ), + ], + ), + child: const Icon( + Icons.close, + size: 16, + color: Colors.red, ), ), ), - Positioned( - top: 4, - right: 4, - child: GestureDetector( - onTap: () => controller.removeImage(index), - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 3, - offset: const Offset(0, 1), - ), - ], - ), - child: Icon( - Icons.close, - color: AppColorsPetugas.error, - size: 16, - ), - ), - ), - ), - ], - ), - ); - }), + ), + ], + ), + ).toList(), ], ), ), @@ -850,7 +941,9 @@ class PetugasTambahAsetView extends GetView { ), ) : const Icon(Icons.save), - label: Text(isSubmitting ? 'Menyimpan...' : 'Simpan Aset'), + label: Obx(() => Text( + isSubmitting ? 'Menyimpan...' : (controller.isEditing.value ? 'Simpan Perubahan' : 'Simpan Aset'), + )), style: ElevatedButton.styleFrom( backgroundColor: AppColorsPetugas.blueGrotto, foregroundColor: Colors.white, diff --git a/lib/app/modules/petugas_bumdes/views/petugas_tambah_paket_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_tambah_paket_view.dart index acb279c..0075481 100644 --- a/lib/app/modules/petugas_bumdes/views/petugas_tambah_paket_view.dart +++ b/lib/app/modules/petugas_bumdes/views/petugas_tambah_paket_view.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:get/get.dart'; import '../../../theme/app_colors_petugas.dart'; import '../controllers/petugas_tambah_paket_controller.dart'; +import 'dart:io'; class PetugasTambahPaketView extends GetView { const PetugasTambahPaketView({Key? key}) : super(key: key); @@ -12,9 +13,11 @@ class PetugasTambahPaketView extends GetView { return Scaffold( backgroundColor: Colors.white, appBar: AppBar( - title: const Text( - 'Tambah Paket', - style: TextStyle(fontWeight: FontWeight.w600), + title: Obx( + () => Text( + controller.isEditing.value ? 'Edit Paket' : 'Tambah Paket', + style: const TextStyle(fontWeight: FontWeight.w600), + ), ), backgroundColor: AppColorsPetugas.navyBlue, elevation: 0, @@ -24,7 +27,7 @@ class PetugasTambahPaketView extends GetView { child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [_buildHeaderSection(), _buildFormSection(context)], + children: [_buildFormSection(context)], ), ), ), @@ -32,64 +35,6 @@ class PetugasTambahPaketView extends GetView { ); } - Widget _buildHeaderSection() { - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [AppColorsPetugas.navyBlue, AppColorsPetugas.blueGrotto], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.category, - color: Colors.white, - size: 28, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Informasi Paket Baru', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - const SizedBox(height: 4), - Text( - 'Isi data dengan lengkap untuk menambahkan paket', - style: TextStyle( - fontSize: 14, - color: Colors.white.withOpacity(0.8), - ), - ), - ], - ), - ), - ], - ), - ], - ), - ); - } - Widget _buildFormSection(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20), @@ -132,22 +77,22 @@ class PetugasTambahPaketView extends GetView { const SizedBox(height: 24), // Category Section - _buildSectionHeader(icon: Icons.category, title: 'Kategori & Status'), + _buildSectionHeader(icon: Icons.category, title: 'Status'), const SizedBox(height: 16), // Category and Status as cards Row( children: [ - Expanded( - child: _buildCategorySelect( - title: 'Kategori', - options: controller.categoryOptions, - selectedOption: controller.selectedCategory, - onChanged: controller.setCategory, - icon: Icons.category, - ), - ), - const SizedBox(width: 12), + // Expanded( + // child: _buildCategorySelect( + // title: 'Kategori', + // options: controller.categoryOptions, + // selectedOption: controller.selectedCategory, + // onChanged: controller.setCategory, + // icon: Icons.category, + // ), + // ), + // const SizedBox(width: 12), Expanded( child: _buildCategorySelect( title: 'Status', @@ -161,24 +106,6 @@ class PetugasTambahPaketView extends GetView { ), const SizedBox(height: 24), - // Price Section - _buildSectionHeader( - icon: Icons.monetization_on, - title: 'Harga Paket', - ), - const SizedBox(height: 16), - _buildTextField( - label: 'Harga Paket', - hint: 'Masukkan harga paket', - controller: controller.priceController, - isRequired: true, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - prefixText: 'Rp ', - prefixIcon: Icons.payments, - ), - const SizedBox(height: 24), - // Package Items Section _buildSectionHeader( icon: Icons.inventory_2, @@ -186,6 +113,40 @@ class PetugasTambahPaketView extends GetView { ), const SizedBox(height: 16), _buildPackageItems(), + const SizedBox(height: 24), + _buildSectionHeader( + icon: Icons.schedule, + title: 'Opsi Waktu & Harga Sewa', + ), + const SizedBox(height: 16), + _buildTimeOptionsCards(), + const SizedBox(height: 16), + Obx( + () => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (controller.timeOptions['Per Jam']!.value) + _buildPriceCard( + title: 'Harga Per Jam', + icon: Icons.timer, + priceController: controller.pricePerHourController, + maxController: controller.maxHourController, + maxLabel: 'Maksimal Jam', + ), + if (controller.timeOptions['Per Jam']!.value && + controller.timeOptions['Per Hari']!.value) + const SizedBox(height: 16), + if (controller.timeOptions['Per Hari']!.value) + _buildPriceCard( + title: 'Harga Per Hari', + icon: Icons.calendar_today, + priceController: controller.pricePerDayController, + maxController: controller.maxDayController, + maxLabel: 'Maksimal Hari', + ), + ], + ), + ), const SizedBox(height: 40), ], ), @@ -310,7 +271,7 @@ class PetugasTambahPaketView extends GetView { const SizedBox(height: 16), // Asset dropdown - DropdownButtonFormField( + DropdownButtonFormField( value: controller.selectedAsset.value, decoration: const InputDecoration( labelText: 'Pilih Aset', @@ -319,8 +280,8 @@ class PetugasTambahPaketView extends GetView { hint: const Text('Pilih Aset'), items: controller.availableAssets.map((asset) { - return DropdownMenuItem( - value: asset['id'] as int, + return DropdownMenuItem( + value: asset['id'].toString(), child: Text( '${asset['nama']} (Stok: ${asset['stok']})', ), @@ -422,7 +383,7 @@ class PetugasTambahPaketView extends GetView { const SizedBox(height: 16), // Asset dropdown - DropdownButtonFormField( + DropdownButtonFormField( value: controller.selectedAsset.value, decoration: const InputDecoration( labelText: 'Pilih Aset', @@ -431,8 +392,8 @@ class PetugasTambahPaketView extends GetView { hint: const Text('Pilih Aset'), items: controller.availableAssets.map((asset) { - return DropdownMenuItem( - value: asset['id'] as int, + return DropdownMenuItem( + value: asset['id'].toString(), child: Text( '${asset['nama']} (Stok: ${asset['stok']})', ), @@ -757,7 +718,7 @@ class PetugasTambahPaketView extends GetView { children: [ // Add button GestureDetector( - onTap: () => controller.addSampleImage(), + onTap: _showImageSourceOptions, child: Container( width: 100, height: 100, @@ -791,69 +752,82 @@ class PetugasTambahPaketView extends GetView { ), ), ), - // Image previews - ...controller.selectedImages.asMap().entries.map((entry) { - final index = entry.key; - return Container( - width: 100, - height: 100, - decoration: BoxDecoration( - color: AppColorsPetugas.babyBlueLight, - borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 5, - offset: const Offset(0, 2), - ), - ], - ), - child: Stack( - children: [ - ClipRRect( + ...List.generate(controller.selectedImages.length, ( + index, + ) { + final img = controller.selectedImages[index]; + return Stack( + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), - child: Container( - width: 100, - height: 100, - color: AppColorsPetugas.babyBlueLight, - child: Center( - child: Icon( - Icons.image, - color: AppColorsPetugas.blueGrotto, - size: 40, - ), - ), - ), + border: Border.all(color: Colors.grey[300]!), ), - Positioned( - top: 4, - right: 4, - child: GestureDetector( - onTap: () => controller.removeImage(index), - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 3, - offset: const Offset(0, 1), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: + (img is String && img.startsWith('http')) + ? Image.network( + img, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: + (context, error, stackTrace) => + const Center( + child: Icon( + Icons.broken_image, + color: Colors.grey, + ), + ), + ) + : (img is String) + ? Container( + color: Colors.grey[200], + child: const Icon( + Icons.broken_image, + color: Colors.grey, + ), + ) + : Image.file( + File(img.path), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: + (context, error, stackTrace) => + const Center( + child: Icon( + Icons.broken_image, + color: Colors.grey, + ), + ), ), - ], - ), - child: Icon( - Icons.close, - color: AppColorsPetugas.error, - size: 16, - ), + ), + ), + Positioned( + top: 4, + right: 4, + child: InkWell( + onTap: () => controller.removeImage(index), + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + size: 18, + color: Colors.red, ), ), ), - ], - ), + ), + ], ); }), ], @@ -864,6 +838,104 @@ class PetugasTambahPaketView extends GetView { ); } + void _showImageSourceOptions() { + Get.bottomSheet( + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + Text( + 'Pilih Sumber Gambar', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildImageSourceOption( + icon: Icons.camera_alt, + label: 'Kamera', + onTap: () { + Get.back(); + controller.pickImageFromCamera(); + }, + ), + _buildImageSourceOption( + icon: Icons.photo_library, + label: 'Galeri', + onTap: () { + Get.back(); + controller.pickImageFromGallery(); + }, + ), + ], + ), + const SizedBox(height: 10), + ], + ), + ), + ); + } + + Widget _buildImageSourceOption({ + required IconData icon, + required String label, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueBright, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: AppColorsPetugas.blueGrotto, size: 28), + ), + const SizedBox(height: 8), + Text( + label, + style: TextStyle( + fontSize: 14, + color: AppColorsPetugas.navyBlue, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + Widget _buildBottomBar() { return Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), @@ -899,26 +971,37 @@ class PetugasTambahPaketView extends GetView { final isSubmitting = controller.isSubmitting.value; return ElevatedButton.icon( onPressed: - isValid && !isSubmitting ? controller.savePaket : null, + controller.isFormChanged.value && !isSubmitting + ? controller.savePaket + : null, icon: isSubmitting - ? SizedBox( - height: 20, - width: 20, + ? const SizedBox( + width: 24, + height: 24, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Icon(Icons.save), - label: Text(isSubmitting ? 'Menyimpan...' : 'Simpan Paket'), + label: Text( + isSubmitting + ? 'Menyimpan...' + : (controller.isEditing.value + ? 'Simpan Paket' + : 'Tambah Paket'), + ), style: ElevatedButton.styleFrom( backgroundColor: AppColorsPetugas.blueGrotto, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 16), + textStyle: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(12), ), disabledBackgroundColor: AppColorsPetugas.textLight, ), @@ -929,4 +1012,226 @@ class PetugasTambahPaketView extends GetView { ), ); } + + Widget _buildTimeOptionsCards() { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: + controller.timeOptions.entries.map((entry) { + final option = entry.key; + final isSelected = entry.value; + return Obx( + () => Material( + color: Colors.transparent, + child: InkWell( + onTap: () => controller.toggleTimeOption(option), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: + isSelected.value + ? AppColorsPetugas.blueGrotto.withOpacity( + 0.1, + ) + : Colors.grey.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + option == 'Per Jam' + ? Icons.hourglass_bottom + : Icons.calendar_today, + color: + isSelected.value + ? AppColorsPetugas.blueGrotto + : Colors.grey, + size: 20, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + option, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: + isSelected.value + ? AppColorsPetugas.navyBlue + : Colors.grey.shade700, + ), + ), + const SizedBox(height: 2), + Text( + option == 'Per Jam' + ? 'Sewa paket dengan basis perhitungan per jam' + : 'Sewa paket dengan basis perhitungan per hari', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + Checkbox( + value: isSelected.value, + onChanged: + (_) => controller.toggleTimeOption(option), + activeColor: AppColorsPetugas.blueGrotto, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } + + Widget _buildPriceCard({ + required String title, + required IconData icon, + required TextEditingController priceController, + required TextEditingController maxController, + required String maxLabel, + }) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColorsPetugas.babyBlue.withOpacity(0.5)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 20, color: AppColorsPetugas.blueGrotto), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColorsPetugas.navyBlue, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Harga Sewa', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColorsPetugas.textSecondary, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: priceController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + hintText: 'Masukkan harga', + hintStyle: TextStyle(color: AppColorsPetugas.textLight), + prefixText: 'Rp ', + filled: true, + fillColor: AppColorsPetugas.babyBlueBright, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + maxLabel, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColorsPetugas.textSecondary, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: maxController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + hintText: 'Opsional', + hintStyle: TextStyle(color: AppColorsPetugas.textLight), + filled: true, + fillColor: AppColorsPetugas.babyBlueBright, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } } diff --git a/lib/app/modules/petugas_bumdes/widgets/petugas_side_navbar.dart b/lib/app/modules/petugas_bumdes/widgets/petugas_side_navbar.dart index 055dd6f..8454f9c 100644 --- a/lib/app/modules/petugas_bumdes/widgets/petugas_side_navbar.dart +++ b/lib/app/modules/petugas_bumdes/widgets/petugas_side_navbar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../../../theme/app_colors.dart'; +import '../../../theme/app_colors_petugas.dart'; import '../controllers/petugas_bumdes_dashboard_controller.dart'; class PetugasSideNavbar extends StatelessWidget { @@ -11,7 +12,7 @@ class PetugasSideNavbar extends StatelessWidget { @override Widget build(BuildContext context) { return Drawer( - backgroundColor: Colors.white, + backgroundColor: AppColorsPetugas.babyBlueLight, elevation: 0, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( @@ -32,24 +33,46 @@ class PetugasSideNavbar extends StatelessWidget { Widget _buildHeader() { return Container( padding: const EdgeInsets.fromLTRB(20, 60, 20, 20), - color: AppColors.primary, + color: AppColorsPetugas.navyBlue, width: double.infinity, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - ), - child: CircleAvatar( - radius: 30, - backgroundColor: Colors.white, - child: Icon(Icons.person, color: AppColors.primary, size: 36), - ), - ), + Obx(() { + final avatar = controller.avatarUrl.value; + if (avatar.isNotEmpty) { + return Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: CircleAvatar( + radius: 30, + backgroundColor: Colors.white, + backgroundImage: NetworkImage(avatar), + onBackgroundImageError: (error, stackTrace) {}, + ), + ); + } else { + return Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: CircleAvatar( + radius: 30, + backgroundColor: Colors.white, + child: Icon( + Icons.person, + color: AppColors.primary, + size: 36, + ), + ), + ); + } + }), const SizedBox(width: 16), Expanded( child: Column( diff --git a/lib/app/modules/splash/views/splash_view.dart b/lib/app/modules/splash/views/splash_view.dart index 551e048..478a018 100644 --- a/lib/app/modules/splash/views/splash_view.dart +++ b/lib/app/modules/splash/views/splash_view.dart @@ -34,7 +34,7 @@ class SplashView extends GetView { child: Container( decoration: const BoxDecoration( image: DecorationImage( - image: AssetImage('assets/images/pattern.png'), + image: AssetImage('assets/images/logo.png'), // Using logo.png which exists repeat: ImageRepeat.repeat, scale: 4.0, ), diff --git a/lib/app/modules/warga/bindings/warga_sewa_binding.dart b/lib/app/modules/warga/bindings/warga_sewa_binding.dart index e7bf650..2c6cec6 100644 --- a/lib/app/modules/warga/bindings/warga_sewa_binding.dart +++ b/lib/app/modules/warga/bindings/warga_sewa_binding.dart @@ -8,17 +8,11 @@ import '../../../data/providers/aset_provider.dart'; class WargaSewaBinding extends Bindings { @override void dependencies() { - // Ensure NavigationService is registered and set to Sewa tab - if (Get.isRegistered()) { - final navService = Get.find(); - navService.setNavIndex(1); // Set to Sewa tab - } - // Ensure AuthProvider is registered if (!Get.isRegistered()) { Get.put(AuthProvider(), permanent: true); } - + // Ensure AsetProvider is registered if (!Get.isRegistered()) { Get.put(AsetProvider(), permanent: true); diff --git a/lib/app/modules/warga/controllers/pembayaran_sewa_controller.dart b/lib/app/modules/warga/controllers/pembayaran_sewa_controller.dart index a8f2f79..70a63c3 100644 --- a/lib/app/modules/warga/controllers/pembayaran_sewa_controller.dart +++ b/lib/app/modules/warga/controllers/pembayaran_sewa_controller.dart @@ -15,7 +15,7 @@ import '../../../services/navigation_service.dart'; class WebImageFile { final String imageUrl; String id = ''; // Database ID for the foto_pembayaran record (UUID string) - + WebImageFile(this.imageUrl); } @@ -24,7 +24,7 @@ class PembayaranSewaController extends GetxController // Dependencies final NavigationService navigationService = Get.find(); final AsetProvider asetProvider = Get.find(); - + // Direct access to Supabase client for storage operations final SupabaseClient client = Supabase.instance.client; @@ -47,18 +47,22 @@ class PembayaranSewaController extends GetxController final isLoading = false.obs; final currentStep = 0.obs; - // Payment proof images - now a list to support multiple images (both File and WebImageFile) - final RxList paymentProofImages = [].obs; - + // Payment proof images for tagihan awal + final RxList paymentProofImagesTagihanAwal = [].obs; + // Payment proof images for denda + final RxList paymentProofImagesDenda = [].obs; + // Track original images loaded from database final RxList originalImages = [].obs; - + // Track images marked for deletion - final RxList imagesToDelete = [].obs; - + final RxList imagesToDeleteTagihanAwal = [].obs; + final RxList imagesToDeleteDenda = [].obs; + // Flag to track if there are changes that need to be saved - final RxBool hasUnsavedChanges = false.obs; - + final RxBool hasUnsavedChangesTagihanAwal = false.obs; + final RxBool hasUnsavedChangesDenda = false.obs; + // Get image widget for a specific image Widget getImageWidget(dynamic imageFile) { // Check if it's a WebImageFile (for existing images loaded from URLs) @@ -95,15 +99,10 @@ class PembayaranSewaController extends GetxController ); }, ); - } + } // For mobile with a File object else if (imageFile is File) { - return Image.file( - imageFile, - height: 120, - width: 120, - fit: BoxFit.cover, - ); + return Image.file(imageFile, height: 120, width: 120, fit: BoxFit.cover); } // Fallback for any other type else { @@ -115,28 +114,36 @@ class PembayaranSewaController extends GetxController ); } } - + // Remove an image from the list void removeImage(dynamic image) { - // If this is an existing image (WebImageFile), add it to imagesToDelete - if (image is WebImageFile && image.id.isNotEmpty) { - imagesToDelete.add(image); - debugPrint('šŸ—‘ļø Marked image for deletion: ${image.imageUrl} (ID: ${image.id})'); + if (selectedPaymentType.value == 'denda') { + // Untuk denda + if (image is WebImageFile && image.id.isNotEmpty) { + imagesToDeleteDenda.add(image); + debugPrint( + 'šŸ—‘ļø Marked image for deletion (denda): \\${image.imageUrl} (ID: \\${image.id})', + ); + } + paymentProofImagesDenda.remove(image); + } else { + // Default/tagihan awal + if (image is WebImageFile && image.id.isNotEmpty) { + imagesToDeleteTagihanAwal.add(image); + debugPrint( + 'šŸ—‘ļø Marked image for deletion: \\${image.imageUrl} (ID: \\${image.id})', + ); + } + paymentProofImagesTagihanAwal.remove(image); } - - // Remove from the current list - paymentProofImages.remove(image); - - // Check if we have any changes (additions or deletions) _checkForChanges(); - update(); } - + // Show image in full screen when tapped void showFullScreenImage(dynamic image) { String imageUrl; - + if (image is WebImageFile) { imageUrl = image.imageUrl; } else if (image is File) { @@ -145,9 +152,9 @@ class PembayaranSewaController extends GetxController debugPrint('āŒ Cannot display image: Unknown image type'); return; } - + debugPrint('šŸ“· Showing full screen image: $imageUrl'); - + // Show full screen image dialog Get.dialog( Dialog( @@ -161,22 +168,25 @@ class PembayaranSewaController extends GetxController panEnabled: true, minScale: 0.5, maxScale: 4, - child: kIsWeb - ? Image.network( - imageUrl, - fit: BoxFit.contain, - height: Get.height, - width: Get.width, - errorBuilder: (context, error, stackTrace) { - return const Center(child: Text('Error loading image')); - }, - ) - : Image.file( - File(imageUrl), - fit: BoxFit.contain, - height: Get.height, - width: Get.width, - ), + child: + kIsWeb + ? Image.network( + imageUrl, + fit: BoxFit.contain, + height: Get.height, + width: Get.width, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Text('Error loading image'), + ); + }, + ) + : Image.file( + File(imageUrl), + fit: BoxFit.contain, + height: Get.height, + width: Get.width, + ), ), // Close button Positioned( @@ -193,38 +203,36 @@ class PembayaranSewaController extends GetxController barrierDismissible: true, ); } - + // Check if there are any changes to save (new images added or existing images removed) void _checkForChanges() { - // We have changes if: - // 1. We have images marked for deletion - // 2. We have new images (files) added - // 3. The current list differs from the original list - - bool hasChanges = false; - - // Check if any images are marked for deletion - if (imagesToDelete.isNotEmpty) { - hasChanges = true; + bool hasChangesTagihanAwal = false; + bool hasChangesDenda = false; + if (imagesToDeleteTagihanAwal.isNotEmpty) { + hasChangesTagihanAwal = true; } - - // Check if any new images have been added - for (dynamic image in paymentProofImages) { + if (imagesToDeleteDenda.isNotEmpty) { + hasChangesDenda = true; + } + for (dynamic image in paymentProofImagesTagihanAwal) { if (image is File) { - // This is a new image - hasChanges = true; + hasChangesTagihanAwal = true; break; } } - - // Check if the number of images has changed - if (paymentProofImages.length != originalImages.length) { - hasChanges = true; + for (dynamic image in paymentProofImagesDenda) { + if (image is File) { + hasChangesDenda = true; + break; + } } - - hasUnsavedChanges.value = hasChanges; - debugPrint('šŸ’¾ Has unsaved changes: $hasChanges'); + hasUnsavedChangesTagihanAwal.value = hasChangesTagihanAwal; + hasUnsavedChangesDenda.value = hasChangesDenda; + debugPrint( + 'šŸ’¾ Has unsaved changes (tagihan awal): $hasChangesTagihanAwal, (denda): $hasChangesDenda', + ); } + final isUploading = false.obs; final uploadProgress = 0.0.obs; @@ -233,7 +241,7 @@ class PembayaranSewaController extends GetxController Timer? _countdownTimer; final int paymentTimeLimit = 3600; // 1 hour in seconds final timeRemaining = 0.obs; - + // Bank accounts for transfer final bankAccounts = RxList>([]); @@ -246,12 +254,12 @@ class PembayaranSewaController extends GetxController if (Get.arguments != null) { if (Get.arguments['orderId'] != null) { orderId.value = Get.arguments['orderId']; - + // If rental data is passed, use it directly if (Get.arguments['rentalData'] != null) { Map rentalData = Get.arguments['rentalData']; debugPrint('Received rental data: $rentalData'); - + // Pre-populate order details with rental data orderDetails.value = { 'id': rentalData['id'] ?? '', @@ -260,8 +268,16 @@ class PembayaranSewaController extends GetxController 'rental_period': rentalData['waktuSewa'] ?? '', 'duration': rentalData['duration'] ?? '', 'price_per_unit': 0, // This might not be available in rental data - 'total_price': rentalData['totalPrice'] != null ? - int.tryParse(rentalData['totalPrice'].toString().replaceAll(RegExp(r'[^0-9]'), '')) ?? 0 : 0, + 'total_price': + rentalData['totalPrice'] != null + ? int.tryParse( + rentalData['totalPrice'].toString().replaceAll( + RegExp(r'[^0-9]'), + '', + ), + ) ?? + 0 + : 0, 'status': rentalData['status'] ?? 'MENUNGGU PEMBAYARAN', 'created_at': DateTime.now().toString(), 'denda': 0, // Default value @@ -271,12 +287,12 @@ class PembayaranSewaController extends GetxController 'waktu_selesai': rentalData['waktuSelesai'], 'rentang_waktu': rentalData['rentangWaktu'], }; - + // Still load additional details from the database checkSewaAsetTableStructure(); loadTagihanSewaDetails().then((_) { // Load existing payment proof images after tagihan_sewa details are loaded - loadExistingPaymentProofImages(); + loadExistingPaymentProofImages(jenisPembayaran: 'tagihan awal'); }); loadSewaAsetDetails(); loadBankAccounts(); // Load bank accounts data @@ -286,7 +302,7 @@ class PembayaranSewaController extends GetxController loadOrderDetails(); loadTagihanSewaDetails().then((_) { // Load existing payment proof images after tagihan_sewa details are loaded - loadExistingPaymentProofImages(); + loadExistingPaymentProofImages(jenisPembayaran: 'tagihan awal'); }); loadSewaAsetDetails(); loadBankAccounts(); // Load bank accounts data @@ -382,18 +398,19 @@ class PembayaranSewaController extends GetxController } val?['quantity'] = data['kuantitas'] ?? 1; val?['denda'] = - data['denda'] ?? - 0; // Use data from API or default to 0 - val?['keterangan'] = - data['keterangan'] ?? - ''; // Use data from API or default to empty string - - // Update status if it exists in the data - if (data['status'] != null && data['status'].toString().isNotEmpty) { + data['denda'] ?? 0; // Use data from API or default to 0 + val?['keterangan'] = data['keterangan'] ?? ''; + if (data['status'] != null && + data['status'].toString().isNotEmpty) { val?['status'] = data['status']; - debugPrint('šŸ“Š Order status from sewa_aset: ${data['status']}'); + debugPrint( + 'šŸ“Š Order status from sewa_aset: \\${data['status']}', + ); + } + // Tambahkan mapping updated_at + if (data['updated_at'] != null) { + val?['updated_at'] = data['updated_at']; } - // Format rental period if (data['waktu_mulai'] != null && data['waktu_selesai'] != null) { @@ -401,12 +418,12 @@ class PembayaranSewaController extends GetxController final startTime = DateTime.parse(data['waktu_mulai']); final endTime = DateTime.parse(data['waktu_selesai']); val?['rental_period'] = - '${startTime.day}/${startTime.month}/${startTime.year}, ${startTime.hour}:${startTime.minute.toString().padLeft(2, '0')} - ${endTime.hour}:${endTime.minute.toString().padLeft(2, '0')}'; + '\\${startTime.day}/\\${startTime.month}/\\${startTime.year}, \\${startTime.hour}:\\${startTime.minute.toString().padLeft(2, '0')} - \\${endTime.hour}:\\${endTime.minute.toString().padLeft(2, '0')}'; debugPrint( - 'āœ… Successfully formatted rental period: ${val?['rental_period']}', + 'āœ… Successfully formatted rental period: \\${val?['rental_period']}', ); } catch (e) { - debugPrint('āŒ Error parsing date: $e'); + debugPrint('āŒ Error parsing date: \\${e}'); } } else { debugPrint( @@ -414,7 +431,7 @@ class PembayaranSewaController extends GetxController ); } }); - + // Update the current step based on the status updateCurrentStepBasedOnStatus(); } else { @@ -456,13 +473,13 @@ class PembayaranSewaController extends GetxController if (data != null) { tagihanSewa.value = data; debugPrint('āœ… Tagihan sewa loaded: ${tagihanSewa.value['id']}'); - + // Debug the tagihan_sewa data debugPrint('šŸ“‹ TAGIHAN SEWA DETAILS:'); data.forEach((key, value) { debugPrint(' $key: $value'); }); - + // Specifically debug denda, keterangan, and foto_kerusakan debugPrint('šŸ’° DENDA DETAILS:'); debugPrint(' denda: ${data['denda']}'); @@ -547,6 +564,11 @@ class PembayaranSewaController extends GetxController // Select payment type (tagihan_awal or denda) void selectPaymentType(String type) { selectedPaymentType.value = type; + if (type == 'tagihan_awal') { + loadExistingPaymentProofImages(jenisPembayaran: 'tagihan awal'); + } else if (type == 'denda') { + loadExistingPaymentProofImages(jenisPembayaran: 'denda'); + } update(); } @@ -558,21 +580,20 @@ class PembayaranSewaController extends GetxController source: ImageSource.camera, imageQuality: 80, ); - if (image != null) { - // Add to the list of images instead of replacing - paymentProofImages.add(File(image.path)); - - // Check for changes + if (selectedPaymentType.value == 'denda') { + paymentProofImagesDenda.add(File(image.path)); + } else { + paymentProofImagesTagihanAwal.add(File(image.path)); + } _checkForChanges(); - update(); } } catch (e) { debugPrint('āŒ Error taking photo: $e'); Get.snackbar( 'Error', - 'Gagal mengambil foto: ${e.toString()}', + 'Gagal mengambil foto: \\${e.toString()}', snackPosition: SnackPosition.BOTTOM, backgroundColor: Colors.red, colorText: Colors.white, @@ -588,17 +609,20 @@ class PembayaranSewaController extends GetxController source: ImageSource.gallery, imageQuality: 80, ); - if (image != null) { - // Add to the list of images instead of replacing - paymentProofImages.add(File(image.path)); + if (selectedPaymentType.value == 'denda') { + paymentProofImagesDenda.add(File(image.path)); + } else { + paymentProofImagesTagihanAwal.add(File(image.path)); + } + _checkForChanges(); update(); } } catch (e) { debugPrint('āŒ Error selecting photo from gallery: $e'); Get.snackbar( 'Error', - 'Gagal memilih foto dari galeri: ${e.toString()}', + 'Gagal memilih foto dari galeri: \\${e.toString()}', snackPosition: SnackPosition.BOTTOM, backgroundColor: Colors.red, colorText: Colors.white, @@ -607,7 +631,19 @@ class PembayaranSewaController extends GetxController } // Upload payment proof to Supabase storage and save to foto_pembayaran table - Future uploadPaymentProof() async { + Future uploadPaymentProof({required String jenisPembayaran}) async { + final paymentProofImages = + jenisPembayaran == 'tagihan awal' + ? paymentProofImagesTagihanAwal + : paymentProofImagesDenda; + final imagesToDelete = + jenisPembayaran == 'tagihan awal' + ? imagesToDeleteTagihanAwal + : imagesToDeleteDenda; + final hasUnsavedChanges = + jenisPembayaran == 'tagihan awal' + ? hasUnsavedChangesTagihanAwal + : hasUnsavedChangesDenda; // If there are no images and none marked for deletion, show error if (paymentProofImages.isEmpty && imagesToDelete.isEmpty) { Get.snackbar( @@ -619,7 +655,7 @@ class PembayaranSewaController extends GetxController ); return; } - + // If there are no changes, no need to do anything if (!hasUnsavedChanges.value) { Get.snackbar( @@ -635,17 +671,19 @@ class PembayaranSewaController extends GetxController try { isUploading.value = true; uploadProgress.value = 0.0; - + // Set up upload progress listener final progressNotifier = StreamController(); progressNotifier.stream.listen((progress) { uploadProgress.value = progress; }); - + // First, delete any images marked for deletion if (imagesToDelete.isNotEmpty) { - debugPrint('šŸ—‘ļø Deleting ${imagesToDelete.length} images from database and storage'); - + debugPrint( + 'šŸ—‘ļø Deleting ${imagesToDelete.length} images from database and storage', + ); + for (WebImageFile image in imagesToDelete) { // Delete the record from the foto_pembayaran table if (image.id.isNotEmpty) { @@ -656,15 +694,15 @@ class PembayaranSewaController extends GetxController .from('foto_pembayaran') .delete() .eq('id', image.id); // ID is already a string UUID - + debugPrint('šŸ—‘ļø Delete result: $result'); } catch (e) { debugPrint('āŒ Error deleting record: $e'); throw e; // Re-throw so the main catch block handles it } - + debugPrint('šŸ—‘ļø Deleted database record with ID: ${image.id}'); - + // Extract the file name from the URL to delete from storage try { // Parse the URL to get the filename more reliably @@ -672,33 +710,37 @@ class PembayaranSewaController extends GetxController String path = uri.path; // The filename is the last part of the path after the last '/' final String fileName = path.substring(path.lastIndexOf('/') + 1); - - debugPrint('šŸ—‘ļø Attempting to delete file from storage: $fileName'); - + + debugPrint( + 'šŸ—‘ļø Attempting to delete file from storage: $fileName', + ); + // Delete the file from storage - await client.storage - .from('bukti.pembayaran') - .remove([fileName]); - - debugPrint('šŸ—‘ļø Successfully deleted file from storage: $fileName'); + await client.storage.from('bukti.pembayaran').remove([fileName]); + + debugPrint( + 'šŸ—‘ļø Successfully deleted file from storage: $fileName', + ); } catch (e) { debugPrint('āš ļø Error deleting file from storage: $e'); // Continue even if file deletion fails - we've at least deleted from the database } } } - + // Clear the deleted images list imagesToDelete.clear(); } - + // Upload each new image to Supabase Storage and save to database - debugPrint('šŸ”„ Uploading new payment proof images to Supabase storage...'); - + debugPrint( + 'šŸ”„ Uploading new payment proof images to Supabase storage...', + ); + List uploadedUrls = []; List newImagesToUpload = []; List existingImageUrls = []; - + // Separate existing WebImageFile objects from new File objects that need uploading for (final image in paymentProofImages) { if (image is WebImageFile) { @@ -709,41 +751,46 @@ class PembayaranSewaController extends GetxController newImagesToUpload.add(image); } } - - debugPrint('šŸ”„ Found ${existingImageUrls.length} existing images and ${newImagesToUpload.length} new images to upload'); - + + debugPrint( + 'šŸ”„ Found ${existingImageUrls.length} existing images and ${newImagesToUpload.length} new images to upload', + ); + // If there are new images to upload if (newImagesToUpload.isNotEmpty) { // Calculate progress increment per image final double progressIncrement = 1.0 / newImagesToUpload.length; double currentProgress = 0.0; - + // Upload each new image for (int i = 0; i < newImagesToUpload.length; i++) { final dynamic imageFile = newImagesToUpload[i]; - final String fileName = '${DateTime.now().millisecondsSinceEpoch}_${orderId.value}_$i.jpg'; - + final String fileName = + '${DateTime.now().millisecondsSinceEpoch}_${orderId.value}_$i.jpg'; + // Create a sub-progress tracker for this image final subProgressNotifier = StreamController(); subProgressNotifier.stream.listen((subProgress) { // Calculate overall progress - progressNotifier.add(currentProgress + (subProgress * progressIncrement)); + progressNotifier.add( + currentProgress + (subProgress * progressIncrement), + ); }); - + // Upload to Supabase Storage final String? imageUrl = await _uploadToSupabaseStorage( - imageFile, - fileName, + imageFile, + fileName, subProgressNotifier, ); - + if (imageUrl == null) { throw Exception('Failed to upload image $i to storage'); } - + debugPrint('āœ… Image $i uploaded successfully: $imageUrl'); uploadedUrls.add(imageUrl); - + // Update progress for next image currentProgress += progressIncrement; } @@ -751,43 +798,59 @@ class PembayaranSewaController extends GetxController // If there are only existing images, set progress to 100% progressNotifier.add(1.0); } - + // Save all new URLs to foto_pembayaran table for (String imageUrl in uploadedUrls) { - await _saveToFotoPembayaranTable(imageUrl); + await _saveToFotoPembayaranTable(imageUrl, jenisPembayaran); } - + // Reload the existing images to get fresh data with new IDs - await loadExistingPaymentProofImages(); - + await loadExistingPaymentProofImages(jenisPembayaran: jenisPembayaran); + // Update order status in orderDetails orderDetails.update((val) { - val?['status'] = 'MEMERIKSA PEMBAYARAN'; + if (jenisPembayaran == 'denda' && + val?['status'] == 'PEMBAYARAN DENDA') { + val?['status'] = 'PERIKSA PEMBAYARAN DENDA'; + } else { + val?['status'] = 'MEMERIKSA PEMBAYARAN'; + } }); - + // Also update the status in the sewa_aset table try { // Get the sewa_aset_id from the tagihanSewa data final dynamic sewaAsetId = tagihanSewa.value['sewa_aset_id']; - + if (sewaAsetId != null && sewaAsetId.toString().isNotEmpty) { - debugPrint('šŸ”„ Updating status in sewa_aset table for ID: $sewaAsetId'); - + debugPrint( + 'šŸ”„ Updating status in sewa_aset table for ID: $sewaAsetId', + ); + // Update the status in the sewa_aset table final updateResult = await client .from('sewa_aset') - .update({'status': 'PERIKSA PEMBAYARAN'}) + .update({ + 'status': + (jenisPembayaran == 'denda' && + orderDetails.value['status'] == + 'PERIKSA PEMBAYARAN DENDA') + ? 'PERIKSA PEMBAYARAN DENDA' + : 'PERIKSA PEMBAYARAN', + }) .eq('id', sewaAsetId.toString()); - + debugPrint('āœ… Status updated in sewa_aset table: $updateResult'); } else { - debugPrint('āš ļø Could not update sewa_aset status: No valid sewa_aset_id found'); + debugPrint( + 'āš ļø Could not update sewa_aset status: No valid sewa_aset_id found', + ); } } catch (e) { // Don't fail the entire operation if this update fails debugPrint('āŒ Error updating status in sewa_aset table: $e'); } - + // Update current step based on status updateCurrentStepBasedOnStatus(); @@ -862,12 +925,12 @@ class PembayaranSewaController extends GetxController val?['status'] = newStatus; }); } - + // Update currentStep based on order status void updateCurrentStepBasedOnStatus() { final status = orderDetails.value['status']?.toString().toUpperCase() ?? ''; debugPrint('šŸ“Š Updating current step based on status: $status'); - + switch (status) { case 'MENUNGGU PEMBAYARAN': currentStep.value = 0; @@ -878,27 +941,29 @@ class PembayaranSewaController extends GetxController case 'DITERIMA': currentStep.value = 2; break; - case 'PENGEMBALIAN': + case 'AKTIF': currentStep.value = 3; break; - case 'PEMBAYARAN DENDA': + case 'PENGEMBALIAN': currentStep.value = 4; break; - case 'MEMERIKSA PEMBAYARAN DENDA': + case 'PEMBAYARAN DENDA': currentStep.value = 5; break; - case 'SELESAI': + case 'PERIKSA PEMBAYARAN DENDA': currentStep.value = 6; break; + case 'SELESAI': + currentStep.value = 7; + break; case 'DIBATALKAN': - // Special case for canceled orders - currentStep.value = 0; + currentStep.value = 8; break; default: currentStep.value = 0; break; } - + debugPrint('šŸ“Š Current step updated to: ${currentStep.value}'); } @@ -931,7 +996,7 @@ class PembayaranSewaController extends GetxController void cancelPayment() { Get.back(); } - + // Debug function to check sewa_aset table structure void checkSewaAsetTableStructure() { try { @@ -950,7 +1015,9 @@ class PembayaranSewaController extends GetxController debugPrint('Available fields in sewa_aset table:'); record.forEach((key, value) { - debugPrint(' $key: (${value != null ? value.runtimeType : 'null'})'); + debugPrint( + ' $key: (${value != null ? value.runtimeType : 'null'})', + ); }); // Specifically check for time fields @@ -979,7 +1046,7 @@ class PembayaranSewaController extends GetxController debugPrint('āŒ Error in checkSewaAsetTableStructure: $e'); } } - + // Load bank accounts from akun_bank table Future loadBankAccounts() async { debugPrint('Loading bank accounts from akun_bank table...'); @@ -987,12 +1054,16 @@ class PembayaranSewaController extends GetxController final data = await asetProvider.getBankAccounts(); if (data.isNotEmpty) { bankAccounts.assignAll(data); - debugPrint('āœ… Bank accounts loaded: ${bankAccounts.length} accounts found'); - + debugPrint( + 'āœ… Bank accounts loaded: ${bankAccounts.length} accounts found', + ); + // Debug the bank accounts data debugPrint('šŸ“‹ BANK ACCOUNTS DETAILS:'); for (var account in bankAccounts) { - debugPrint(' Bank: ${account['nama_bank']}, Account: ${account['nama_akun']}, Number: ${account['no_rekening']}'); + debugPrint( + ' Bank: ${account['nama_bank']}, Account: ${account['nama_akun']}, Number: ${account['no_rekening']}', + ); } } else { debugPrint('āš ļø No bank accounts found in akun_bank table'); @@ -1001,23 +1072,27 @@ class PembayaranSewaController extends GetxController debugPrint('āŒ Error loading bank accounts: $e'); } } - + // Helper method to upload image to Supabase storage - Future _uploadToSupabaseStorage(dynamic imageFile, String fileName, StreamController progressNotifier) async { + Future _uploadToSupabaseStorage( + dynamic imageFile, + String fileName, + StreamController progressNotifier, + ) async { try { debugPrint('šŸ”„ Uploading image to Supabase storage: $fileName'); - + // If it's already a WebImageFile, just return the URL if (imageFile is WebImageFile) { progressNotifier.add(1.0); // No upload needed return imageFile.imageUrl; } - + // Handle File objects if (imageFile is File) { // Get file bytes List fileBytes = await imageFile.readAsBytes(); - + // Upload to Supabase Storage await client.storage .from('bukti.pembayaran') @@ -1029,16 +1104,18 @@ class PembayaranSewaController extends GetxController upsert: false, ), ); - + // Get public URL - final String publicUrl = client.storage.from('bukti.pembayaran').getPublicUrl(fileName); - + final String publicUrl = client.storage + .from('bukti.pembayaran') + .getPublicUrl(fileName); + debugPrint('āœ… Upload successful: $publicUrl'); progressNotifier.add(1.0); // Upload complete - + return publicUrl; } - + // If we get here, we don't know how to handle this type throw Exception('Unsupported image type: ${imageFile.runtimeType}'); } catch (e) { @@ -1050,96 +1127,77 @@ class PembayaranSewaController extends GetxController } // Helper method to save image URL to foto_pembayaran table - Future _saveToFotoPembayaranTable(String imageUrl) async { + Future _saveToFotoPembayaranTable( + String imageUrl, + String jenisPembayaran, + ) async { try { debugPrint('šŸ”„ Saving image URL to foto_pembayaran table...'); - + // Get the tagihan_sewa_id from the tagihanSewa object final dynamic tagihanSewaId = tagihanSewa.value['id']; - + if (tagihanSewaId == null || tagihanSewaId.toString().isEmpty) { throw Exception('tagihan_sewa_id not found in tagihanSewa data'); } - + debugPrint('šŸ”„ Using tagihan_sewa_id: $tagihanSewaId'); - + // Prepare the data to insert final Map data = { 'tagihan_sewa_id': tagihanSewaId, 'foto_pembayaran': imageUrl, + 'jenis_pembayaran': jenisPembayaran, 'created_at': DateTime.now().toIso8601String(), }; - + // Insert data into the foto_pembayaran table - final response = await client - .from('foto_pembayaran') - .insert(data) - .select() - .single(); - - debugPrint('āœ… Image URL saved to foto_pembayaran table: ${response['id']}'); + final response = + await client.from('foto_pembayaran').insert(data).select().single(); + + debugPrint( + 'āœ… Image URL saved to foto_pembayaran table: ${response['id']}', + ); } catch (e) { debugPrint('āŒ Error in _saveToFotoPembayaranTable: $e'); throw Exception('Failed to save image URL to database: $e'); } } - - // Load existing payment proof images - Future loadExistingPaymentProofImages() async { + + // Load existing payment proof images for a specific jenis_pembayaran + Future loadExistingPaymentProofImages({ + required String jenisPembayaran, + }) async { try { - debugPrint('šŸ”„ Loading existing payment proof images for tagihan_sewa_id: ${tagihanSewa.value['id']}'); - - // Check if we have a valid tagihan_sewa_id + debugPrint( + 'šŸ”„ Loading existing payment proof images for tagihan_sewa_id: \\${tagihanSewa.value['id']} dan jenis_pembayaran: $jenisPembayaran', + ); final dynamic tagihanSewaId = tagihanSewa.value['id']; if (tagihanSewaId == null || tagihanSewaId.toString().isEmpty) { debugPrint('āš ļø No valid tagihan_sewa_id found, skipping image load'); return; } - - // First, make a test query to see the structure of the response - final testResponse = await client - .from('foto_pembayaran') - .select() - .limit(1); - - // Log the test response structure - if (testResponse.isNotEmpty) { - debugPrint('šŸ’¾ DEBUG: Test database response: ${testResponse[0]}'); - testResponse[0].forEach((key, value) { - debugPrint('šŸ’¾ DEBUG: Field $key = $value (${value?.runtimeType})'); - }); - } - - // Now make the actual query for this tagihan_sewa_id final List response = await client .from('foto_pembayaran') .select() .eq('tagihan_sewa_id', tagihanSewaId) + .eq('jenis_pembayaran', jenisPembayaran) .order('created_at', ascending: false); - - debugPrint('šŸ”„ Found ${response.length} existing payment proof images'); - - // Clear existing tracking lists - paymentProofImages.clear(); - originalImages.clear(); - imagesToDelete.clear(); - hasUnsavedChanges.value = false; - - // Process each image in the response + debugPrint( + 'šŸ”„ Found \\${response.length} existing payment proof images for $jenisPembayaran', + ); + final targetList = + jenisPembayaran == 'tagihan awal' + ? paymentProofImagesTagihanAwal + : paymentProofImagesDenda; + targetList.clear(); for (final item in response) { - // Extract the image URL final String imageUrl = item['foto_pembayaran']; - - // Extract the ID - debug the item structure - debugPrint('šŸ’¾ Image data: $item'); - - // Get the ID field - in Supabase, this is a UUID string String imageId = ''; try { if (item.containsKey('id')) { final dynamic rawId = item['id']; if (rawId != null) { - // Store ID as string since it's a UUID imageId = rawId.toString(); } debugPrint('šŸ”„ Image ID: $imageId'); @@ -1147,56 +1205,49 @@ class PembayaranSewaController extends GetxController } catch (e) { debugPrint('āŒ Error getting image ID: $e'); } - - // Create the WebImageFile object final webImageFile = WebImageFile(imageUrl); webImageFile.id = imageId; - - // Add to tracking lists - paymentProofImages.add(webImageFile); - originalImages.add(webImageFile); - + targetList.add(webImageFile); debugPrint('āœ… Added image: $imageUrl with ID: $imageId'); } - - // Update the UI update(); - } catch (e) { debugPrint('āŒ Error loading payment proof images: $e'); } } - + // Refresh all data Future refreshData() async { debugPrint('Refreshing payment page data...'); isLoading.value = true; - + try { // Reload all data - await Future.delayed(const Duration(milliseconds: 500)); // Small delay for better UX + await Future.delayed( + const Duration(milliseconds: 500), + ); // Small delay for better UX loadOrderDetails(); loadTagihanSewaDetails(); loadSewaAsetDetails(); loadBankAccounts(); // Load bank accounts data - + // Explicitly update the current step based on the status // This ensures the progress timeline is always in sync with the actual status updateCurrentStepBasedOnStatus(); - + // Restart countdown timer if needed if (orderDetails.value['status'] == 'MENUNGGU PEMBAYARAN') { _countdownTimer?.cancel(); startCountdownTimer(); } - + debugPrint('Data refresh completed'); } catch (e) { debugPrint('Error refreshing data: $e'); } finally { isLoading.value = false; } - + return Future.value(); } } diff --git a/lib/app/modules/warga/controllers/sewa_aset_controller.dart b/lib/app/modules/warga/controllers/sewa_aset_controller.dart index a64d93f..fd63c9e 100644 --- a/lib/app/modules/warga/controllers/sewa_aset_controller.dart +++ b/lib/app/modules/warga/controllers/sewa_aset_controller.dart @@ -88,6 +88,19 @@ class SewaAsetController extends GetxController void onReady() { super.onReady(); debugPrint('šŸš€ SewaAsetController: onReady called'); + // Set tab index from arguments (if any) after build + Future.delayed(Duration.zero, () { + final args = Get.arguments; + if (args != null && args is Map && args['tab'] != null) { + int initialTab = + args['tab'] is int + ? args['tab'] + : int.tryParse(args['tab'].toString()) ?? 0; + if (tabController.length > initialTab) { + tabController.index = initialTab; + } + } + }); } @override diff --git a/lib/app/modules/warga/controllers/warga_dashboard_controller.dart b/lib/app/modules/warga/controllers/warga_dashboard_controller.dart index 739efa1..af00025 100644 --- a/lib/app/modules/warga/controllers/warga_dashboard_controller.dart +++ b/lib/app/modules/warga/controllers/warga_dashboard_controller.dart @@ -2,11 +2,14 @@ import 'package:get/get.dart'; import '../../../data/providers/auth_provider.dart'; import '../../../routes/app_routes.dart'; import '../../../services/navigation_service.dart'; +import '../../../data/providers/aset_provider.dart'; +import 'package:intl/intl.dart'; class WargaDashboardController extends GetxController { // Dependency injection final AuthProvider _authProvider = Get.find(); final NavigationService navigationService = Get.find(); + final AsetProvider _asetProvider = Get.find(); // User data final userName = 'Pengguna Warga'.obs; @@ -28,6 +31,11 @@ class WargaDashboardController extends GetxController { // Active penalties final activePenalties = >[].obs; + // Summary counts + final diterimaCount = 0.obs; + final tagihanAktifCount = 0.obs; + final dendaAktifCount = 0.obs; + @override void onInit() { super.onInit(); @@ -36,6 +44,7 @@ class WargaDashboardController extends GetxController { navigationService.setNavIndex(0); // Load user data + fetchProfileFromWargaDesa(); _loadUserData(); // Load sample data @@ -46,6 +55,12 @@ class WargaDashboardController extends GetxController { // Load unpaid rentals loadUnpaidRentals(); + + // Debug count sewa_aset by status + _debugCountSewaAset(); + + // Load sewa aktif + loadActiveRentals(); } Future _loadUserData() async { @@ -112,7 +127,7 @@ class WargaDashboardController extends GetxController { } void refreshData() { - // Refresh data from repository + fetchProfileFromWargaDesa(); _loadSampleData(); loadDummyData(); } @@ -129,12 +144,17 @@ class WargaDashboardController extends GetxController { // Already on Home tab break; case 1: - // Navigate to Sewa page - navigationService.toWargaSewa(); + // Navigate to Sewa page, tab Aktif + toWargaSewaTabAktif(); break; } } + void toWargaSewaTabAktif() { + // Navigasi ke halaman warga sewa dan tab Aktif (index 3) + Get.toNamed(Routes.WARGA_SEWA, arguments: {'tab': 3}); + } + void logout() async { await _authProvider.signOut(); navigationService.toLogin(); @@ -177,4 +197,137 @@ class WargaDashboardController extends GetxController { print('Error loading unpaid rentals: $e'); } } + + Future _debugCountSewaAset() async { + diterimaCount.value = await _asetProvider.countSewaAsetByStatus([ + 'DITERIMA', + ]); + tagihanAktifCount.value = await _asetProvider.countSewaAsetByStatus([ + 'MENUNGGU PEMBAYARAN', + 'PERIKSA PEMBAYARAN', + ]); + dendaAktifCount.value = await _asetProvider.countSewaAsetByStatus([ + 'PEMBAYARAN DENDA', + 'PERIKSA PEMBAYARAN DENDA', + ]); + print('[DEBUG] Jumlah sewa diterima: ${diterimaCount.value}'); + print('[DEBUG] Jumlah tagihan aktif: ${tagihanAktifCount.value}'); + print('[DEBUG] Jumlah denda aktif: ${dendaAktifCount.value}'); + } + + Future loadActiveRentals() async { + try { + activeRentals.clear(); + final sewaAsetList = await _authProvider.getSewaAsetByStatus(['AKTIF']); + for (var sewaAset in sewaAsetList) { + String assetName = 'Aset'; + String? imageUrl; + String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; + if (sewaAset['aset_id'] != null) { + final asetData = await _asetProvider.getAsetById(sewaAset['aset_id']); + if (asetData != null) { + assetName = asetData.nama; + imageUrl = asetData.imageUrl; + } + } + DateTime? waktuMulai; + DateTime? waktuSelesai; + String waktuSewa = ''; + String tanggalSewa = ''; + String jamMulai = ''; + String jamSelesai = ''; + String rentangWaktu = ''; + if (sewaAset['waktu_mulai'] != null && + sewaAset['waktu_selesai'] != null) { + waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); + waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); + final formatTanggal = DateFormat('dd-MM-yyyy'); + final formatWaktu = DateFormat('HH:mm'); + final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); + tanggalSewa = formatTanggalLengkap.format(waktuMulai); + jamMulai = formatWaktu.format(waktuMulai); + jamSelesai = formatWaktu.format(waktuSelesai); + if (namaSatuanWaktu.toLowerCase() == 'jam') { + rentangWaktu = '$jamMulai - $jamSelesai'; + } else if (namaSatuanWaktu.toLowerCase() == 'hari') { + final tanggalMulai = formatTanggalLengkap.format(waktuMulai); + final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai); + rentangWaktu = '$tanggalMulai - $tanggalSelesai'; + } else { + rentangWaktu = '$jamMulai - $jamSelesai'; + } + waktuSewa = + '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' + '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; + } + String totalPrice = 'Rp 0'; + if (sewaAset['total'] != null) { + final formatter = NumberFormat.currency( + locale: 'id', + symbol: 'Rp ', + decimalDigits: 0, + ); + totalPrice = formatter.format(sewaAset['total']); + } + String duration = '-'; + final tagihan = await _asetProvider.getTagihanSewa(sewaAset['id']); + if (tagihan != null) { + final durasiTagihan = tagihan['durasi'] ?? sewaAset['durasi']; + final satuanTagihan = tagihan['nama_satuan_waktu'] ?? namaSatuanWaktu; + duration = '${durasiTagihan ?? '-'} ${satuanTagihan ?? ''}'; + } else { + duration = '${sewaAset['durasi'] ?? '-'} ${namaSatuanWaktu ?? ''}'; + } + activeRentals.add({ + 'id': sewaAset['id'] ?? '', + 'name': assetName, + 'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg', + 'jumlahUnit': sewaAset['kuantitas'] ?? 0, + 'waktuSewa': waktuSewa, + 'duration': duration, + 'status': sewaAset['status'] ?? 'AKTIF', + 'totalPrice': totalPrice, + 'tanggalSewa': tanggalSewa, + 'jamMulai': jamMulai, + 'jamSelesai': jamSelesai, + 'rentangWaktu': rentangWaktu, + 'namaSatuanWaktu': namaSatuanWaktu, + 'waktuMulai': sewaAset['waktu_mulai'], + 'waktuSelesai': sewaAset['waktu_selesai'], + 'can_extend': sewaAset['can_extend'] == true, + }); + } + } catch (e) { + print('Error loading active rentals: $e'); + } + } + + void toSewaAsetTabPaket() { + // Navigasi ke halaman sewa_aset tab Paket (index 1) + Get.toNamed(Routes.SEWA_ASET, arguments: {'tab': 1}); + } + + Future fetchProfileFromWargaDesa() async { + try { + final user = _authProvider.currentUser; + if (user == null) return; + final userId = user.id; + final data = + await _authProvider.client + .from('warga_desa') + .select('nik, alamat, email, nama_lengkap, no_hp, avatar') + .eq('user_id', userId) + .maybeSingle(); + if (data != null) { + userNik.value = data['nik']?.toString() ?? ''; + userAddress.value = data['alamat']?.toString() ?? ''; + userEmail.value = data['email']?.toString() ?? ''; + userName.value = data['nama_lengkap']?.toString() ?? ''; + userPhone.value = data['no_hp']?.toString() ?? ''; + userAvatar.value = data['avatar']?.toString() ?? ''; + } + } catch (e) { + print('Error fetching profile from warga_desa: $e'); + } + } } diff --git a/lib/app/modules/warga/controllers/warga_sewa_controller.dart b/lib/app/modules/warga/controllers/warga_sewa_controller.dart index e4d90c5..b5e912f 100644 --- a/lib/app/modules/warga/controllers/warga_sewa_controller.dart +++ b/lib/app/modules/warga/controllers/warga_sewa_controller.dart @@ -12,10 +12,10 @@ class WargaSewaController extends GetxController // Get navigation service final NavigationService navigationService = Get.find(); - + // Get auth provider for user data and sewa_aset queries final AuthProvider authProvider = Get.find(); - + // Get aset provider for asset data final AsetProvider asetProvider = Get.find(); @@ -25,33 +25,35 @@ class WargaSewaController extends GetxController final acceptedRentals = >[].obs; final completedRentals = >[].obs; final cancelledRentals = >[].obs; - + final returnedRentals = >[].obs; + final activeRentals = >[].obs; + // Loading states final isLoading = false.obs; final isLoadingPending = false.obs; final isLoadingAccepted = false.obs; final isLoadingCompleted = false.obs; final isLoadingCancelled = false.obs; + final isLoadingReturned = false.obs; + final isLoadingActive = false.obs; + + bool _tabSetFromArgument = false; @override void onInit() { super.onInit(); - // Ensure tab index is set to Sewa (1) - navigationService.setNavIndex(1); - - // Initialize tab controller with 6 tabs - tabController = TabController(length: 6, vsync: this); - - // Set initial tab and ensure tab view is updated - tabController.index = 0; + // Initialize tab controller with 7 tabs + tabController = TabController(length: 7, vsync: this); // Load real rental data for all tabs loadRentalsData(); loadPendingRentals(); loadAcceptedRentals(); + loadActiveRentals(); loadCompletedRentals(); loadCancelledRentals(); + loadReturnedRentals(); // Listen to tab changes to update state if needed tabController.addListener(() { @@ -77,7 +79,9 @@ class WargaSewaController extends GetxController } break; case 3: // Aktif - // Add Aktif tab logic when needed + if (activeRentals.isEmpty && !isLoadingActive.value) { + loadActiveRentals(); + } break; case 4: // Selesai if (completedRentals.isEmpty && !isLoadingCompleted.value) { @@ -89,6 +93,11 @@ class WargaSewaController extends GetxController loadCancelledRentals(); } break; + case 6: // Dikembalikan + if (returnedRentals.isEmpty && !isLoadingReturned.value) { + loadReturnedRentals(); + } + break; } }); } @@ -96,9 +105,26 @@ class WargaSewaController extends GetxController @override void onReady() { super.onReady(); - // Ensure nav index is set to Sewa (1) when the controller is ready - // This helps maintain correct state during hot reload - navigationService.setNavIndex(1); + // Jalankan update nav index dan tab index setelah build selesai + Future.delayed(Duration.zero, () { + navigationService.setNavIndex(1); + + final args = Get.arguments; + int initialTab = 0; + if (!_tabSetFromArgument && + args != null && + args is Map && + args['tab'] != null) { + initialTab = + args['tab'] is int + ? args['tab'] + : int.tryParse(args['tab'].toString()) ?? 0; + if (tabController.length > initialTab) { + tabController.index = initialTab; + _tabSetFromArgument = true; + } + } + }); } @override @@ -111,25 +137,25 @@ class WargaSewaController extends GetxController Future loadRentalsData() async { try { isLoading.value = true; - + // Clear existing data rentals.clear(); - + // Get sewa_aset data with status "MENUNGGU PEMBAYARAN" or "PEMBAYARAN DENDA" final sewaAsetList = await authProvider.getSewaAsetByStatus([ 'MENUNGGU PEMBAYARAN', - 'PEMBAYARAN DENDA' + 'PEMBAYARAN DENDA', ]); - + debugPrint('Fetched ${sewaAsetList.length} sewa_aset records'); - + // Process each sewa_aset record for (var sewaAset in sewaAsetList) { // Get asset details if aset_id is available String assetName = 'Aset'; String? imageUrl; String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; - + if (sewaAset['aset_id'] != null) { final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); if (asetData != null) { @@ -137,7 +163,7 @@ class WargaSewaController extends GetxController imageUrl = asetData.imageUrl; } } - + // Parse waktu mulai and waktu selesai DateTime? waktuMulai; DateTime? waktuSelesai; @@ -146,20 +172,21 @@ class WargaSewaController extends GetxController String jamMulai = ''; String jamSelesai = ''; String rentangWaktu = ''; - - if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) { + + if (sewaAset['waktu_mulai'] != null && + sewaAset['waktu_selesai'] != null) { waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); - + // Format for display final formatTanggal = DateFormat('dd-MM-yyyy'); final formatWaktu = DateFormat('HH:mm'); final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); - + tanggalSewa = formatTanggalLengkap.format(waktuMulai); jamMulai = formatWaktu.format(waktuMulai); jamSelesai = formatWaktu.format(waktuSelesai); - + // Format based on satuan waktu if (namaSatuanWaktu.toLowerCase() == 'jam') { // For hours, show time range on same day @@ -173,12 +200,13 @@ class WargaSewaController extends GetxController // Default format rentangWaktu = '$jamMulai - $jamSelesai'; } - + // Full time format for waktuSewa - waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' - '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; + waktuSewa = + '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' + '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; } - + // Format price String totalPrice = 'Rp 0'; if (sewaAset['total'] != null) { @@ -189,7 +217,7 @@ class WargaSewaController extends GetxController ); totalPrice = formatter.format(sewaAset['total']); } - + // Add to rentals list rentals.add({ 'id': sewaAset['id'] ?? '', @@ -208,9 +236,10 @@ class WargaSewaController extends GetxController 'namaSatuanWaktu': namaSatuanWaktu, 'waktuMulai': sewaAset['waktu_mulai'], 'waktuSelesai': sewaAset['waktu_selesai'], + 'updated_at': sewaAset['updated_at'], }); } - + debugPrint('Processed ${rentals.length} rental records'); } catch (e) { debugPrint('Error loading rentals data: $e'); @@ -245,28 +274,67 @@ class WargaSewaController extends GetxController } // Actions - void cancelRental(String id) { - Get.snackbar( - 'Info', - 'Pembatalan berhasil', - snackPosition: SnackPosition.BOTTOM, + void cancelRental(String id) async { + final confirmed = await Get.dialog( + AlertDialog( + title: const Text('Konfirmasi Pembatalan'), + content: const Text('Apakah Anda yakin ingin membatalkan pesanan ini?'), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text('Tidak'), + ), + ElevatedButton( + onPressed: () => Get.back(result: true), + child: const Text('Ya, Batalkan'), + ), + ], + ), ); + if (confirmed == true) { + try { + await asetProvider.client + .from('sewa_aset') + .update({'status': 'DIBATALKAN'}) + .eq('id', id); + Get.snackbar( + 'Berhasil', + 'Pesanan berhasil dibatalkan', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + // Refresh data + loadRentalsData(); + loadPendingRentals(); + loadAcceptedRentals(); + loadActiveRentals(); + loadCompletedRentals(); + loadCancelledRentals(); + loadReturnedRentals(); + } catch (e) { + Get.snackbar( + 'Gagal', + 'Gagal membatalkan pesanan: $e', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } } // Navigate to payment page with the selected rental data void viewRentalDetail(Map rental) { debugPrint('Navigating to payment page with rental ID: ${rental['id']}'); - + // Navigate to payment page with rental data Get.toNamed( Routes.PEMBAYARAN_SEWA, - arguments: { - 'orderId': rental['id'], - 'rentalData': rental, - }, + arguments: {'orderId': rental['id'], 'rentalData': rental}, ); } - + void payRental(String id) { Get.snackbar( 'Info', @@ -274,27 +342,27 @@ class WargaSewaController extends GetxController snackPosition: SnackPosition.BOTTOM, ); } - + // Load data for the Selesai tab (status: SELESAI) Future loadCompletedRentals() async { try { isLoadingCompleted.value = true; - + // Clear existing data completedRentals.clear(); - + // Get sewa_aset data with status "SELESAI" final sewaAsetList = await authProvider.getSewaAsetByStatus(['SELESAI']); - + debugPrint('Fetched ${sewaAsetList.length} completed sewa_aset records'); - + // Process each sewa_aset record for (var sewaAset in sewaAsetList) { // Get asset details if aset_id is available String assetName = 'Aset'; String? imageUrl; String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; - + if (sewaAset['aset_id'] != null) { final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); if (asetData != null) { @@ -302,7 +370,7 @@ class WargaSewaController extends GetxController imageUrl = asetData.imageUrl; } } - + // Parse waktu mulai and waktu selesai DateTime? waktuMulai; DateTime? waktuSelesai; @@ -311,20 +379,21 @@ class WargaSewaController extends GetxController String jamMulai = ''; String jamSelesai = ''; String rentangWaktu = ''; - - if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) { + + if (sewaAset['waktu_mulai'] != null && + sewaAset['waktu_selesai'] != null) { waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); - + // Format for display final formatTanggal = DateFormat('dd-MM-yyyy'); final formatWaktu = DateFormat('HH:mm'); final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); - + tanggalSewa = formatTanggalLengkap.format(waktuMulai); jamMulai = formatWaktu.format(waktuMulai); jamSelesai = formatWaktu.format(waktuSelesai); - + // Format based on satuan waktu if (namaSatuanWaktu.toLowerCase() == 'jam') { // For hours, show time range on same day @@ -338,12 +407,13 @@ class WargaSewaController extends GetxController // Default format rentangWaktu = '$jamMulai - $jamSelesai'; } - + // Full time format for waktuSewa - waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' - '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; + waktuSewa = + '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' + '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; } - + // Format price String totalPrice = 'Rp 0'; if (sewaAset['total'] != null) { @@ -354,7 +424,7 @@ class WargaSewaController extends GetxController ); totalPrice = formatter.format(sewaAset['total']); } - + // Add to completed rentals list completedRentals.add({ 'id': sewaAset['id'] ?? '', @@ -374,35 +444,39 @@ class WargaSewaController extends GetxController 'waktuSelesai': sewaAset['waktu_selesai'], }); } - - debugPrint('Processed ${completedRentals.length} completed rental records'); + + debugPrint( + 'Processed ${completedRentals.length} completed rental records', + ); } catch (e) { debugPrint('Error loading completed rentals data: $e'); } finally { isLoadingCompleted.value = false; } } - + // Load data for the Dibatalkan tab (status: DIBATALKAN) Future loadCancelledRentals() async { try { isLoadingCancelled.value = true; - + // Clear existing data cancelledRentals.clear(); - + // Get sewa_aset data with status "DIBATALKAN" - final sewaAsetList = await authProvider.getSewaAsetByStatus(['DIBATALKAN']); - + final sewaAsetList = await authProvider.getSewaAsetByStatus([ + 'DIBATALKAN', + ]); + debugPrint('Fetched ${sewaAsetList.length} cancelled sewa_aset records'); - + // Process each sewa_aset record for (var sewaAset in sewaAsetList) { // Get asset details if aset_id is available String assetName = 'Aset'; String? imageUrl; String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; - + if (sewaAset['aset_id'] != null) { final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); if (asetData != null) { @@ -410,7 +484,7 @@ class WargaSewaController extends GetxController imageUrl = asetData.imageUrl; } } - + // Parse waktu mulai and waktu selesai DateTime? waktuMulai; DateTime? waktuSelesai; @@ -419,20 +493,21 @@ class WargaSewaController extends GetxController String jamMulai = ''; String jamSelesai = ''; String rentangWaktu = ''; - - if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) { + + if (sewaAset['waktu_mulai'] != null && + sewaAset['waktu_selesai'] != null) { waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); - + // Format for display final formatTanggal = DateFormat('dd-MM-yyyy'); final formatWaktu = DateFormat('HH:mm'); final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); - + tanggalSewa = formatTanggalLengkap.format(waktuMulai); jamMulai = formatWaktu.format(waktuMulai); jamSelesai = formatWaktu.format(waktuSelesai); - + // Format based on satuan waktu if (namaSatuanWaktu.toLowerCase() == 'jam') { // For hours, show time range on same day @@ -446,12 +521,13 @@ class WargaSewaController extends GetxController // Default format rentangWaktu = '$jamMulai - $jamSelesai'; } - + // Full time format for waktuSewa - waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' - '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; + waktuSewa = + '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' + '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; } - + // Format price String totalPrice = 'Rp 0'; if (sewaAset['total'] != null) { @@ -462,7 +538,7 @@ class WargaSewaController extends GetxController ); totalPrice = formatter.format(sewaAset['total']); } - + // Add to cancelled rentals list cancelledRentals.add({ 'id': sewaAset['id'] ?? '', @@ -483,35 +559,40 @@ class WargaSewaController extends GetxController 'alasanPembatalan': sewaAset['alasan_pembatalan'] ?? '-', }); } - - debugPrint('Processed ${cancelledRentals.length} cancelled rental records'); + + debugPrint( + 'Processed ${cancelledRentals.length} cancelled rental records', + ); } catch (e) { debugPrint('Error loading cancelled rentals data: $e'); } finally { isLoadingCancelled.value = false; } } - + // Load data for the Pending tab (status: PERIKSA PEMBAYARAN) Future loadPendingRentals() async { try { isLoadingPending.value = true; - + // Clear existing data pendingRentals.clear(); - - // Get sewa_aset data with status "PERIKSA PEMBAYARAN" - final sewaAsetList = await authProvider.getSewaAsetByStatus(['PERIKSA PEMBAYARAN']); - + + // Get sewa_aset data with status 'PERIKSA PEMBAYARAN' dan 'PERIKSA PEMBAYARAN DENDA' + final sewaAsetList = await authProvider.getSewaAsetByStatus([ + 'PERIKSA PEMBAYARAN', + 'PERIKSA PEMBAYARAN DENDA', + ]); + debugPrint('Fetched ${sewaAsetList.length} pending sewa_aset records'); - + // Process each sewa_aset record for (var sewaAset in sewaAsetList) { // Get asset details if aset_id is available String assetName = 'Aset'; String? imageUrl; String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; - + if (sewaAset['aset_id'] != null) { final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); if (asetData != null) { @@ -519,7 +600,7 @@ class WargaSewaController extends GetxController imageUrl = asetData.imageUrl; } } - + // Parse waktu mulai and waktu selesai DateTime? waktuMulai; DateTime? waktuSelesai; @@ -528,20 +609,21 @@ class WargaSewaController extends GetxController String jamMulai = ''; String jamSelesai = ''; String rentangWaktu = ''; - - if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) { + + if (sewaAset['waktu_mulai'] != null && + sewaAset['waktu_selesai'] != null) { waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); - + // Format for display final formatTanggal = DateFormat('dd-MM-yyyy'); final formatWaktu = DateFormat('HH:mm'); final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); - + tanggalSewa = formatTanggalLengkap.format(waktuMulai); jamMulai = formatWaktu.format(waktuMulai); jamSelesai = formatWaktu.format(waktuSelesai); - + // Format based on satuan waktu if (namaSatuanWaktu.toLowerCase() == 'jam') { // For hours, show time range on same day @@ -555,12 +637,13 @@ class WargaSewaController extends GetxController // Default format rentangWaktu = '$jamMulai - $jamSelesai'; } - + // Full time format for waktuSewa - waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' - '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; + waktuSewa = + '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' + '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; } - + // Format price String totalPrice = 'Rp 0'; if (sewaAset['total'] != null) { @@ -571,7 +654,7 @@ class WargaSewaController extends GetxController ); totalPrice = formatter.format(sewaAset['total']); } - + // Add to pending rentals list pendingRentals.add({ 'id': sewaAset['id'] ?? '', @@ -591,7 +674,7 @@ class WargaSewaController extends GetxController 'waktuSelesai': sewaAset['waktu_selesai'], }); } - + debugPrint('Processed ${pendingRentals.length} pending rental records'); } catch (e) { debugPrint('Error loading pending rentals data: $e'); @@ -599,27 +682,27 @@ class WargaSewaController extends GetxController isLoadingPending.value = false; } } - + // Load data for the Diterima tab (status: DITERIMA) Future loadAcceptedRentals() async { try { isLoadingAccepted.value = true; - + // Clear existing data acceptedRentals.clear(); - + // Get sewa_aset data with status "DITERIMA" final sewaAsetList = await authProvider.getSewaAsetByStatus(['DITERIMA']); - + debugPrint('Fetched ${sewaAsetList.length} accepted sewa_aset records'); - + // Process each sewa_aset record for (var sewaAset in sewaAsetList) { // Get asset details if aset_id is available String assetName = 'Aset'; String? imageUrl; String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; - + if (sewaAset['aset_id'] != null) { final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); if (asetData != null) { @@ -627,7 +710,7 @@ class WargaSewaController extends GetxController imageUrl = asetData.imageUrl; } } - + // Parse waktu mulai and waktu selesai DateTime? waktuMulai; DateTime? waktuSelesai; @@ -636,20 +719,21 @@ class WargaSewaController extends GetxController String jamMulai = ''; String jamSelesai = ''; String rentangWaktu = ''; - - if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) { + + if (sewaAset['waktu_mulai'] != null && + sewaAset['waktu_selesai'] != null) { waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); - + // Format for display final formatTanggal = DateFormat('dd-MM-yyyy'); final formatWaktu = DateFormat('HH:mm'); final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); - + tanggalSewa = formatTanggalLengkap.format(waktuMulai); jamMulai = formatWaktu.format(waktuMulai); jamSelesai = formatWaktu.format(waktuSelesai); - + // Format based on satuan waktu if (namaSatuanWaktu.toLowerCase() == 'jam') { // For hours, show time range on same day @@ -663,12 +747,13 @@ class WargaSewaController extends GetxController // Default format rentangWaktu = '$jamMulai - $jamSelesai'; } - + // Full time format for waktuSewa - waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' - '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; + waktuSewa = + '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' + '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; } - + // Format price String totalPrice = 'Rp 0'; if (sewaAset['total'] != null) { @@ -679,7 +764,7 @@ class WargaSewaController extends GetxController ); totalPrice = formatter.format(sewaAset['total']); } - + // Add to accepted rentals list acceptedRentals.add({ 'id': sewaAset['id'] ?? '', @@ -699,7 +784,7 @@ class WargaSewaController extends GetxController 'waktuSelesai': sewaAset['waktu_selesai'], }); } - + debugPrint('Processed ${acceptedRentals.length} accepted rental records'); } catch (e) { debugPrint('Error loading accepted rentals data: $e'); @@ -707,4 +792,166 @@ class WargaSewaController extends GetxController isLoadingAccepted.value = false; } } + + Future loadReturnedRentals() async { + try { + isLoadingReturned.value = true; + returnedRentals.clear(); + final sewaAsetList = await authProvider.getSewaAsetByStatus([ + 'DIKEMBALIKAN', + ]); + for (var sewaAset in sewaAsetList) { + String assetName = 'Aset'; + String? imageUrl; + String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; + if (sewaAset['aset_id'] != null) { + final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); + if (asetData != null) { + assetName = asetData.nama; + imageUrl = asetData.imageUrl; + } + } + DateTime? waktuMulai; + DateTime? waktuSelesai; + String waktuSewa = ''; + String tanggalSewa = ''; + String jamMulai = ''; + String jamSelesai = ''; + String rentangWaktu = ''; + if (sewaAset['waktu_mulai'] != null && + sewaAset['waktu_selesai'] != null) { + waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); + waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); + final formatTanggal = DateFormat('dd-MM-yyyy'); + final formatWaktu = DateFormat('HH:mm'); + final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); + tanggalSewa = formatTanggalLengkap.format(waktuMulai); + jamMulai = formatWaktu.format(waktuMulai); + jamSelesai = formatWaktu.format(waktuSelesai); + if (namaSatuanWaktu.toLowerCase() == 'jam') { + rentangWaktu = '$jamMulai - $jamSelesai'; + } else if (namaSatuanWaktu.toLowerCase() == 'hari') { + final tanggalMulai = formatTanggalLengkap.format(waktuMulai); + final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai); + rentangWaktu = '$tanggalMulai - $tanggalSelesai'; + } else { + rentangWaktu = '$jamMulai - $jamSelesai'; + } + waktuSewa = + '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' + '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; + } + String totalPrice = 'Rp 0'; + if (sewaAset['total'] != null) { + final formatter = NumberFormat.currency( + locale: 'id', + symbol: 'Rp ', + decimalDigits: 0, + ); + totalPrice = formatter.format(sewaAset['total']); + } + returnedRentals.add({ + 'id': sewaAset['id'] ?? '', + 'name': assetName, + 'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg', + 'jumlahUnit': sewaAset['kuantitas'] ?? 0, + 'waktuSewa': waktuSewa, + 'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}', + 'status': sewaAset['status'] ?? 'DIKEMBALIKAN', + 'totalPrice': totalPrice, + 'tanggalSewa': tanggalSewa, + 'jamMulai': jamMulai, + 'jamSelesai': jamSelesai, + 'rentangWaktu': rentangWaktu, + 'namaSatuanWaktu': namaSatuanWaktu, + 'waktuMulai': sewaAset['waktu_mulai'], + 'waktuSelesai': sewaAset['waktu_selesai'], + }); + } + } catch (e) { + debugPrint('Error loading returned rentals data: $e'); + } finally { + isLoadingReturned.value = false; + } + } + + Future loadActiveRentals() async { + try { + isLoadingActive.value = true; + activeRentals.clear(); + final sewaAsetList = await authProvider.getSewaAsetByStatus(['AKTIF']); + for (var sewaAset in sewaAsetList) { + String assetName = 'Aset'; + String? imageUrl; + String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; + if (sewaAset['aset_id'] != null) { + final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); + if (asetData != null) { + assetName = asetData.nama; + imageUrl = asetData.imageUrl; + } + } + DateTime? waktuMulai; + DateTime? waktuSelesai; + String waktuSewa = ''; + String tanggalSewa = ''; + String jamMulai = ''; + String jamSelesai = ''; + String rentangWaktu = ''; + if (sewaAset['waktu_mulai'] != null && + sewaAset['waktu_selesai'] != null) { + waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); + waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); + final formatTanggal = DateFormat('dd-MM-yyyy'); + final formatWaktu = DateFormat('HH:mm'); + final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); + tanggalSewa = formatTanggalLengkap.format(waktuMulai); + jamMulai = formatWaktu.format(waktuMulai); + jamSelesai = formatWaktu.format(waktuSelesai); + if (namaSatuanWaktu.toLowerCase() == 'jam') { + rentangWaktu = '$jamMulai - $jamSelesai'; + } else if (namaSatuanWaktu.toLowerCase() == 'hari') { + final tanggalMulai = formatTanggalLengkap.format(waktuMulai); + final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai); + rentangWaktu = '$tanggalMulai - $tanggalSelesai'; + } else { + rentangWaktu = '$jamMulai - $jamSelesai'; + } + waktuSewa = + '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' + '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; + } + String totalPrice = 'Rp 0'; + if (sewaAset['total'] != null) { + final formatter = NumberFormat.currency( + locale: 'id', + symbol: 'Rp ', + decimalDigits: 0, + ); + totalPrice = formatter.format(sewaAset['total']); + } + activeRentals.add({ + 'id': sewaAset['id'] ?? '', + 'name': assetName, + 'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg', + 'jumlahUnit': sewaAset['kuantitas'] ?? 0, + 'waktuSewa': waktuSewa, + 'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}', + 'status': sewaAset['status'] ?? 'AKTIF', + 'totalPrice': totalPrice, + 'tanggalSewa': tanggalSewa, + 'jamMulai': jamMulai, + 'jamSelesai': jamSelesai, + 'rentangWaktu': rentangWaktu, + 'namaSatuanWaktu': namaSatuanWaktu, + 'waktuMulai': sewaAset['waktu_mulai'], + 'waktuSelesai': sewaAset['waktu_selesai'], + }); + } + } catch (e) { + debugPrint('Error loading active rentals data: $e'); + } finally { + isLoadingActive.value = false; + } + } } diff --git a/lib/app/modules/warga/views/pembayaran_sewa_view.dart b/lib/app/modules/warga/views/pembayaran_sewa_view.dart index cc24e79..3267b9e 100644 --- a/lib/app/modules/warga/views/pembayaran_sewa_view.dart +++ b/lib/app/modules/warga/views/pembayaran_sewa_view.dart @@ -4,6 +4,7 @@ import 'package:get/get.dart'; import '../controllers/pembayaran_sewa_controller.dart'; import 'package:intl/intl.dart'; import '../../../theme/app_colors.dart'; +import 'dart:async'; class PembayaranSewaView extends GetView { const PembayaranSewaView({super.key}); @@ -81,6 +82,44 @@ class PembayaranSewaView extends GetView { ], ), ), + if ((controller.orderDetails.value['status'] ?? '') + .toString() + .toUpperCase() == + 'MENUNGGU PEMBAYARAN' && + controller.orderDetails.value['updated_at'] != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Obx(() { + final status = + (controller.orderDetails.value['status'] ?? '') + .toString() + .toUpperCase(); + final updatedAtStr = + controller.orderDetails.value['updated_at']; + print('DEBUG status: ' + status); + print( + 'DEBUG updated_at (raw): ' + + (updatedAtStr?.toString() ?? 'NULL'), + ); + if (status == 'MENUNGGU PEMBAYARAN' && updatedAtStr != null) { + try { + final updatedAt = DateTime.parse(updatedAtStr); + print( + 'DEBUG updated_at (parsed): ' + + updatedAt.toIso8601String(), + ); + return CountdownTimerWidget(updatedAt: updatedAt); + } catch (e) { + print('ERROR parsing updated_at: ' + e.toString()); + return Text( + 'Format tanggal salah', + style: TextStyle(color: Colors.red), + ); + } + } + return SizedBox.shrink(); + }), + ), ], ), ); @@ -123,36 +162,36 @@ class PembayaranSewaView extends GetView { () => controller.isLoading.value ? Center( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - children: [ - const CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation( - Colors.deepPurple, - ), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + children: [ + const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Colors.deepPurple, ), - const SizedBox(height: 16), - Text( - 'Memuat data tagihan...', - style: TextStyle( - color: Colors.grey[700], - fontSize: 14, - ), + ), + const SizedBox(height: 16), + Text( + 'Memuat data tagihan...', + style: TextStyle( + color: Colors.grey[700], + fontSize: 14, ), - ], - ), + ), + ], ), - ) - : Column( - children: [ - _buildInvoiceIdCard(), - const SizedBox(height: 16), - _buildTagihanAwalCard(), - const SizedBox(height: 16), - _buildDendaCard(), - ], ), + ) + : Column( + children: [ + _buildInvoiceIdCard(), + const SizedBox(height: 16), + _buildTagihanAwalCard(), + const SizedBox(height: 16), + _buildDendaCard(), + ], + ), ), ], ), @@ -174,35 +213,32 @@ class PembayaranSewaView extends GetView { _buildPaymentTypeSelection(), const SizedBox(height: 24), Obx(() { - // Show payment method selection only after selecting a payment type if (controller.selectedPaymentType.value.isNotEmpty) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildPaymentMethodSelection(), const SizedBox(height: 24), - if (controller.paymentMethod.value == 'transfer') - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildTransferInstructions(), - const SizedBox(height: 24), - _buildPaymentProofUpload(), - ], - ) - else if (controller.paymentMethod.value == 'cash') + if (controller.paymentMethod.value == 'transfer') ...[ + _buildTransferInstructions(), + const SizedBox(height: 24), + if (controller.selectedPaymentType.value == + 'tagihan_awal') + _buildPaymentProofUploadTagihanAwal(), + if (controller.selectedPaymentType.value == 'denda') + _buildPaymentProofUploadDenda(), + ] else if (controller.paymentMethod.value == 'cash') _buildCashInstructions() else _buildSelectPaymentMethodPrompt(), ], ); } else { - // Prompt to select payment type first return _buildSelectPaymentTypePrompt(); } }), - ], - ), + ], + ), ), ); } @@ -272,18 +308,36 @@ class PembayaranSewaView extends GetView { 'Batas waktu pembayaran: ', style: TextStyle(fontSize: 14, color: Colors.grey), ), - Obx( - () => Text( - controller.orderDetails.value['status'] == 'DIBATALKAN' - ? 'Dibatalkan' - : controller.remainingTime.value, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.red[700], - ), - ), - ), + Obx(() { + final status = + (controller.orderDetails.value['status'] ?? '') + .toString() + .toUpperCase(); + final updatedAtStr = + controller.orderDetails.value['updated_at']; + print('DEBUG status: ' + status); + print( + 'DEBUG updated_at (raw): ' + + (updatedAtStr?.toString() ?? 'NULL'), + ); + if (status == 'MENUNGGU PEMBAYARAN' && updatedAtStr != null) { + try { + final updatedAt = DateTime.parse(updatedAtStr); + print( + 'DEBUG updated_at (parsed): ' + + updatedAt.toIso8601String(), + ); + return CountdownTimerWidget(updatedAt: updatedAt); + } catch (e) { + print('ERROR parsing updated_at: ' + e.toString()); + return Text( + 'Format tanggal salah', + style: TextStyle(color: Colors.red), + ); + } + } + return SizedBox.shrink(); + }), ], ), ], @@ -313,29 +367,41 @@ class PembayaranSewaView extends GetView { 'icon': Icons.check_circle, 'step': 2, }, + { + 'title': 'Aktif', + 'description': 'Aset sewa sedang digunakan', + 'icon': Icons.play_circle_fill, + 'step': 3, + }, { 'title': 'Pengembalian', 'description': 'Proses pengembalian aset sewa', 'icon': Icons.assignment_return, - 'step': 3, + 'step': 4, }, { 'title': 'Pembayaran Denda', 'description': 'Pembayaran denda jika ada kerusakan atau keterlambatan', 'icon': Icons.money, - 'step': 4, + 'step': 5, }, { - 'title': 'Memeriksa Pembayaran Denda', + 'title': 'Periksa Pembayaran Denda', 'description': 'Verifikasi pembayaran denda oleh petugas', 'icon': Icons.fact_check, - 'step': 5, + 'step': 6, }, { 'title': 'Selesai', 'description': 'Pesanan sewa telah selesai', 'icon': Icons.task_alt, - 'step': 6, + 'step': 7, + }, + { + 'title': 'Dibatalkan', + 'description': 'Pesanan ini telah dibatalkan', + 'icon': Icons.cancel, + 'step': 8, }, ]; @@ -360,37 +426,69 @@ class PembayaranSewaView extends GetView { const SizedBox(height: 20), Obx(() { final currentStep = controller.currentStep.value; + final isCancelled = currentStep == 8; + // Filter steps: tampilkan step Dibatalkan hanya jika status DIBATALKAN + final visibleSteps = + isCancelled + ? steps + : steps.where((s) => s['step'] != 8).toList(); return ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemCount: steps.length, + itemCount: visibleSteps.length, itemBuilder: (context, index) { - final step = steps[index]; + final step = visibleSteps[index]; final stepNumber = step['step'] as int; - final isActive = currentStep >= stepNumber; - final isCompleted = currentStep > stepNumber; - final isLast = index == steps.length - 1; + final isActive = + currentStep >= stepNumber && + (!isCancelled || stepNumber == 8); + final isCompleted = + currentStep > stepNumber && + (!isCancelled || stepNumber == 8); + final isLast = index == visibleSteps.length - 1; - // Determine the appropriate colors + // Custom color for dibatalkan + final bool isCancelledStep = stepNumber == 8; final Color iconColor = - isActive + isCancelledStep + ? Colors.red + : isCancelled + ? Colors.grey[400]! + : isActive ? (isCompleted ? AppColors.success : AppColors.primary) : Colors.grey[300]!; final Color lineColor = - isCompleted ? AppColors.success : Colors.grey[300]!; + isCancelledStep + ? Colors.red + : isCancelled + ? Colors.grey[400]! + : isCompleted + ? AppColors.success + : Colors.grey[300]!; final Color bgColor = - isActive + isCancelledStep + ? Colors.red.withOpacity(0.1) + : isCancelled + ? Colors.grey[100]! + : isActive ? (isCompleted ? AppColors.successLight : AppColors.primarySoft) : Colors.grey[100]!; + // Icon logic: silang untuk step lain jika dibatalkan + final IconData displayIcon = + isCancelled + ? (isCancelledStep ? Icons.cancel : Icons.cancel) + : (isCompleted + ? Icons.check + : step['icon'] as IconData); + return Row( - crossAxisAlignment: CrossAxisAlignment.start, children: [ Column( children: [ @@ -403,9 +501,7 @@ class PembayaranSewaView extends GetView { border: Border.all(color: iconColor, width: 2), ), child: Icon( - isCompleted - ? Icons.check - : step['icon'] as IconData, + displayIcon, color: iconColor, size: 18, ), @@ -425,7 +521,9 @@ class PembayaranSewaView extends GetView { fontWeight: FontWeight.w600, fontSize: 14, color: - isActive + isCancelledStep + ? Colors.red + : isActive ? AppColors.textPrimary : AppColors.textSecondary, ), @@ -434,7 +532,10 @@ class PembayaranSewaView extends GetView { Text( step['description'] as String, style: TextStyle( - color: AppColors.textSecondary, + color: + isCancelledStep + ? Colors.red + : AppColors.textSecondary, fontSize: 12, ), ), @@ -442,13 +543,18 @@ class PembayaranSewaView extends GetView { ], ), ), - if (isCompleted) + if (isCancelled && isCancelledStep) + Icon(Icons.cancel, color: Colors.red, size: 18) + else if (!isCancelled && isCompleted) Icon( Icons.check_circle, color: AppColors.success, size: 18, ) - else if (currentStep == stepNumber) + else if (isActive && + !isCancelledStep && + !isCompleted && + !isCancelled) Container( padding: const EdgeInsets.symmetric( horizontal: 8, @@ -592,19 +698,20 @@ class PembayaranSewaView extends GetView { '${controller.sewaAsetDetails.value['kuantitas'] ?? controller.orderDetails.value['quantity'] ?? 0} unit', ), // Waktu Sewa with sub-points for Waktu Mulai and Waktu Selesai - _buildDetailItemWithSubpoints( - 'Waktu Sewa', - [ - { - 'label': 'Waktu Mulai', - 'value': _formatDateTime(controller.sewaAsetDetails.value['waktu_mulai']), - }, - { - 'label': 'Waktu Selesai', - 'value': _formatDateTime(controller.sewaAsetDetails.value['waktu_selesai']), - }, - ], - ), + _buildDetailItemWithSubpoints('Waktu Sewa', [ + { + 'label': 'Waktu Mulai', + 'value': _formatDateTime( + controller.sewaAsetDetails.value['waktu_mulai'], + ), + }, + { + 'label': 'Waktu Selesai', + 'value': _formatDateTime( + controller.sewaAsetDetails.value['waktu_selesai'], + ), + }, + ]), _buildDetailItem( 'Durasi', controller.tagihanSewa.value['durasi'] != null @@ -665,8 +772,9 @@ class PembayaranSewaView extends GetView { // Get values from tagihan_sewa table final denda = controller.tagihanSewa.value['denda']; final keterangan = controller.tagihanSewa.value['keterangan']; - final fotoKerusakan = controller.tagihanSewa.value['foto_kerusakan']; - + final fotoKerusakan = + controller.tagihanSewa.value['foto_kerusakan']; + debugPrint('Tagihan Denda: $denda'); debugPrint('Tagihan Keterangan: $keterangan'); debugPrint('Tagihan Foto Kerusakan: $fotoKerusakan'); @@ -748,32 +856,49 @@ class PembayaranSewaView extends GetView { onTap: () { // Show fullscreen image when tapped // Use the BuildContext from the current widget tree - _showFullScreenImage(Get.context!, fotoKerusakan); + _showFullScreenImage( + Get.context!, + fotoKerusakan, + ); }, child: Hero( - tag: 'damage-photo-${fotoKerusakan ?? 'default'}', - child: fotoKerusakan != null && fotoKerusakan.toString().isNotEmpty && fotoKerusakan.toString().startsWith('http') - ? Image.network( - fotoKerusakan.toString(), - width: double.infinity, - height: 200, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - debugPrint('Error loading image: $error'); - return Image.asset( + tag: + 'damage-photo-${fotoKerusakan ?? 'default'}', + child: + fotoKerusakan != null && + fotoKerusakan + .toString() + .isNotEmpty && + fotoKerusakan.toString().startsWith( + 'http', + ) + ? Image.network( + fotoKerusakan.toString(), + width: double.infinity, + height: 200, + fit: BoxFit.cover, + errorBuilder: ( + context, + error, + stackTrace, + ) { + debugPrint( + 'Error loading image: $error', + ); + return Image.asset( + 'assets/images/gambar_pendukung.jpg', + width: double.infinity, + height: 200, + fit: BoxFit.cover, + ); + }, + ) + : Image.asset( 'assets/images/gambar_pendukung.jpg', width: double.infinity, height: 200, fit: BoxFit.cover, - ); - }, - ) - : Image.asset( - 'assets/images/gambar_pendukung.jpg', - width: double.infinity, - height: 200, - fit: BoxFit.cover, - ), + ), ), ), ), @@ -880,12 +1005,13 @@ class PembayaranSewaView extends GetView { // Show fullscreen image dialog void _showFullScreenImage(BuildContext context, dynamic imageUrl) { - final String imageSource = (imageUrl != null && - imageUrl.toString().isNotEmpty && - imageUrl.toString().startsWith('http')) - ? imageUrl.toString() - : ''; - + final String imageSource = + (imageUrl != null && + imageUrl.toString().isNotEmpty && + imageUrl.toString().startsWith('http')) + ? imageUrl.toString() + : ''; + showDialog( context: context, builder: (BuildContext context) { @@ -906,27 +1032,30 @@ class PembayaranSewaView extends GetView { height: MediaQuery.of(context).size.height, color: Colors.black.withOpacity(0.8), child: Center( - child: imageSource.isNotEmpty - ? Image.network( - imageSource, - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) { - debugPrint('Error loading fullscreen image: $error'); - return Image.asset( - 'assets/images/gambar_pendukung.jpg', - fit: BoxFit.contain, - ); - }, - ) - : Image.asset( - 'assets/images/gambar_pendukung.jpg', - fit: BoxFit.contain, - ), + child: + imageSource.isNotEmpty + ? Image.network( + imageSource, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + debugPrint( + 'Error loading fullscreen image: $error', + ); + return Image.asset( + 'assets/images/gambar_pendukung.jpg', + fit: BoxFit.contain, + ); + }, + ) + : Image.asset( + 'assets/images/gambar_pendukung.jpg', + fit: BoxFit.contain, + ), ), ), ), ), - + // Close button at the top right Positioned( top: 40, @@ -1022,7 +1151,7 @@ class PembayaranSewaView extends GetView { 0; // Get denda value - final denda = controller.sewaAsetDetails.value['denda'] ?? 0; + final denda = controller.tagihanSewa.value['denda'] ?? 0; return Column( children: [ @@ -1042,7 +1171,7 @@ class PembayaranSewaView extends GetView { amount: 'Rp ${NumberFormat('#,###').format(denda)}', type: 'denda', description: 'Pembayaran untuk denda yang diberikan', - isDisabled: denda == 0, + isDisabled: denda == null || denda == 0, isSelected: controller.selectedPaymentType.value == 'denda', ), ], @@ -1316,7 +1445,7 @@ class PembayaranSewaView extends GetView { ), const SizedBox(height: 16), Obx(() { - if (controller.bankAccounts.isEmpty) { + if (controller.isLoading.value) { return const Center( child: Padding( padding: EdgeInsets.all(16.0), @@ -1324,21 +1453,33 @@ class PembayaranSewaView extends GetView { ), ); } - + if (controller.bankAccounts.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'Tidak ada rekening bank yang tersedia.\nSilakan hubungi admin.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ), + ); + } return Column( - children: controller.bankAccounts.map((account) { - return Column( - children: [ - _buildBankAccount( - bankName: account['nama_bank'] ?? 'Bank', - accountNumber: account['no_rekening'] ?? '', - accountName: account['nama_akun'] ?? '', - bankLogo: 'assets/images/bank_logo.png', - ), - const SizedBox(height: 16), - ], - ); - }).toList(), + children: + controller.bankAccounts.map((account) { + return Column( + children: [ + _buildBankAccount( + bankName: account['nama_bank'] ?? 'Bank', + accountNumber: account['no_rekening'] ?? '', + accountName: account['nama_akun'] ?? '', + bankLogo: 'assets/images/bank_logo.png', + ), + const SizedBox(height: 16), + ], + ); + }).toList(), ); }), const SizedBox(height: 16), @@ -1365,13 +1506,6 @@ class PembayaranSewaView extends GetView { title: 'Tunggu konfirmasi', description: 'Pembayaran Anda akan dikonfirmasi oleh petugas', ), - _buildTransferStep( - icon: Icons.receipt_long, - title: 'Dapatkan struk pembayaran', - description: - 'Setelah dikonfirmasi, akan dibuatkan struk pembayaran', - isLast: true, - ), ], ), ), @@ -1438,7 +1572,7 @@ class PembayaranSewaView extends GetView { }, ); } - + // Bank Account Widget Widget _buildBankAccount({ required String bankName, @@ -1503,12 +1637,14 @@ class PembayaranSewaView extends GetView { onPressed: () { // Copy to clipboard functionality Clipboard.setData(ClipboardData(text: accountNumber)); - + // Show feedback to user final scaffoldMessenger = ScaffoldMessenger.of(Get.context!); scaffoldMessenger.showSnackBar( SnackBar( - content: Text('Nomor rekening $accountNumber disalin ke clipboard'), + content: Text( + 'Nomor rekening $accountNumber disalin ke clipboard', + ), duration: const Duration(seconds: 2), backgroundColor: Colors.green[700], behavior: SnackBarBehavior.floating, @@ -1541,18 +1677,21 @@ class PembayaranSewaView extends GetView { GestureDetector( onTap: () { // Get the total price - final totalPrice = controller.orderDetails.value['total_price'] ?? 0; + final totalPrice = + controller.orderDetails.value['total_price'] ?? 0; // Format the total price as a number without 'Rp' prefix final formattedPrice = totalPrice.toString(); - + // Copy to clipboard Clipboard.setData(ClipboardData(text: formattedPrice)); - + // Show feedback to user final scaffoldMessenger = ScaffoldMessenger.of(Get.context!); scaffoldMessenger.showSnackBar( SnackBar( - content: Text('Nominal Rp $formattedPrice disalin ke clipboard'), + content: Text( + 'Nominal Rp $formattedPrice disalin ke clipboard', + ), duration: const Duration(seconds: 2), backgroundColor: Colors.green[700], behavior: SnackBarBehavior.floating, @@ -1570,11 +1709,7 @@ class PembayaranSewaView extends GetView { ), ), const SizedBox(width: 4), - Icon( - Icons.copy, - size: 14, - color: Colors.deepPurple[300], - ), + Icon(Icons.copy, size: 14, color: Colors.deepPurple[300]), ], ), ), @@ -1635,8 +1770,8 @@ class PembayaranSewaView extends GetView { ); } - // Payment Proof Upload - Widget _buildPaymentProofUpload() { + // Payment Proof Upload for Tagihan Awal + Widget _buildPaymentProofUploadTagihanAwal() { return Card( elevation: 2, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), @@ -1661,48 +1796,55 @@ class PembayaranSewaView extends GetView { spacing: 12, runSpacing: 12, children: [ - // Display all existing images ...List.generate( - controller.paymentProofImages.length, - (index) => _buildImageItem(index), + controller.paymentProofImagesTagihanAwal.length, + (index) => _buildImageItemTagihanAwal(index), ), - // Add photo button - _buildAddPhotoButton(), + _buildAddPhotoButtonTagihanAwal(), ], ); }), const SizedBox(height: 16), - // Upload button Obx(() { - // Disable button if there are no changes or if upload is in progress - final bool isDisabled = controller.isUploading.value || !controller.hasUnsavedChanges.value; - + final bool isDisabled = + controller.isUploading.value || + !controller.hasUnsavedChangesTagihanAwal.value; return ElevatedButton.icon( - onPressed: isDisabled ? null : controller.uploadPaymentProof, - icon: controller.isUploading.value - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), + onPressed: + isDisabled + ? null + : () => controller.uploadPaymentProof( + jenisPembayaran: 'tagihan awal', ), - ) - : const Icon(Icons.save), - label: Text(controller.isUploading.value - ? 'Menyimpan...' - : (controller.hasUnsavedChanges.value ? 'Simpan' : 'Tidak Ada Perubahan')), + icon: + controller.isUploading.value + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : const Icon(Icons.save), + label: Text( + controller.isUploading.value + ? 'Menyimpan...' + : (controller.hasUnsavedChangesTagihanAwal.value + ? 'Simpan' + : 'Tidak Ada Perubahan'), + ), style: ElevatedButton.styleFrom( backgroundColor: Colors.blue, foregroundColor: Colors.white, minimumSize: const Size(double.infinity, 48), - // Gray out button when disabled disabledBackgroundColor: Colors.grey[300], disabledForegroundColor: Colors.grey[600], ), ); }), - // Upload progress indicator Obx(() { if (controller.isUploading.value) { return Column( @@ -1711,7 +1853,9 @@ class PembayaranSewaView extends GetView { LinearProgressIndicator( value: controller.uploadProgress.value, backgroundColor: Colors.grey[200], - valueColor: AlwaysStoppedAnimation(Colors.blue[700]!), + valueColor: AlwaysStoppedAnimation( + Colors.blue[700]!, + ), ), const SizedBox(height: 8), Text( @@ -1729,13 +1873,15 @@ class PembayaranSewaView extends GetView { ), ); } - - // Build individual image item with remove button - Widget _buildImageItem(int index) { - final image = controller.paymentProofImages[index]; + + Widget _buildImageItemTagihanAwal(int index) { + final image = controller.paymentProofImagesTagihanAwal[index]; + final status = + controller.orderDetails.value['status']?.toString().toUpperCase() ?? ''; + final canDelete = + status == 'MENUNGGU PEMBAYARAN' || status == 'PERIKSA PEMBAYARAN'; return Stack( children: [ - // Make the container tappable to show full-screen image GestureDetector( onTap: () => controller.showFullScreenImage(image), child: Container( @@ -1751,28 +1897,27 @@ class PembayaranSewaView extends GetView { ), ), ), - // Close/remove button remains the same - Positioned( - top: 4, - right: 4, - child: InkWell( - onTap: () => controller.removeImage(image), - child: Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, + if (canDelete) + Positioned( + top: 4, + right: 4, + child: InkWell( + onTap: () => controller.removeImage(image), + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, size: 18, color: Colors.red), ), - child: const Icon(Icons.close, size: 18, color: Colors.red), ), ), - ), ], ); } - - // Build add photo button - Widget _buildAddPhotoButton() { + + Widget _buildAddPhotoButtonTagihanAwal() { return InkWell( onTap: () => _showImageSourceOptions(Get.context!), child: Container( @@ -1786,11 +1931,183 @@ class PembayaranSewaView extends GetView { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.add_a_photo, - size: 40, - color: Colors.blue[700], + Icon(Icons.add_a_photo, size: 40, color: Colors.blue[700]), + const SizedBox(height: 8), + Text( + 'Tambah Foto', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.blue[700], + ), ), + ], + ), + ), + ); + } + + // Payment Proof Upload for Denda + Widget _buildPaymentProofUploadDenda() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.photo_camera, size: 24), + SizedBox(width: 8), + Text( + 'Unggah Bukti Pembayaran', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 16), + Obx(() { + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ...List.generate( + controller.paymentProofImagesDenda.length, + (index) => _buildImageItemDenda(index), + ), + _buildAddPhotoButtonDenda(), + ], + ); + }), + const SizedBox(height: 16), + Obx(() { + final bool isDisabled = + controller.isUploading.value || + !controller.hasUnsavedChangesDenda.value; + return ElevatedButton.icon( + onPressed: + isDisabled + ? null + : () => controller.uploadPaymentProof( + jenisPembayaran: 'denda', + ), + icon: + controller.isUploading.value + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : const Icon(Icons.save), + label: Text( + controller.isUploading.value + ? 'Menyimpan...' + : (controller.hasUnsavedChangesDenda.value + ? 'Simpan' + : 'Tidak Ada Perubahan'), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 48), + disabledBackgroundColor: Colors.grey[300], + disabledForegroundColor: Colors.grey[600], + ), + ); + }), + Obx(() { + if (controller.isUploading.value) { + return Column( + children: [ + const SizedBox(height: 16), + LinearProgressIndicator( + value: controller.uploadProgress.value, + backgroundColor: Colors.grey[200], + valueColor: AlwaysStoppedAnimation( + Colors.blue[700]!, + ), + ), + const SizedBox(height: 8), + Text( + 'Mengunggah bukti pembayaran... ${(controller.uploadProgress.value * 100).toInt()}%', + style: const TextStyle(fontSize: 12), + ), + ], + ); + } else { + return const SizedBox.shrink(); + } + }), + ], + ), + ), + ); + } + + Widget _buildImageItemDenda(int index) { + final image = controller.paymentProofImagesDenda[index]; + final status = + controller.orderDetails.value['status']?.toString().toUpperCase() ?? ''; + final canDelete = status != 'SELESAI'; + return Stack( + children: [ + GestureDetector( + onTap: () => controller.showFullScreenImage(image), + child: Container( + width: 140, + height: 140, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: controller.getImageWidget(image), + ), + ), + ), + if (canDelete) + Positioned( + top: 4, + right: 4, + child: InkWell( + onTap: () => controller.removeImage(image), + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, size: 18, color: Colors.red), + ), + ), + ), + ], + ); + } + + Widget _buildAddPhotoButtonDenda() { + return InkWell( + onTap: () => _showImageSourceOptions(Get.context!), + child: Container( + width: 140, + height: 140, + decoration: BoxDecoration( + color: Colors.blue[50], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue[200]!), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add_a_photo, size: 40, color: Colors.blue[700]), const SizedBox(height: 8), Text( 'Tambah Foto', @@ -1871,13 +2188,6 @@ class PembayaranSewaView extends GetView { description: 'Total: Rp ${controller.orderDetails.value['total_price'] ?? 0}', ), - _buildCashStep( - number: 4, - title: 'Dapatkan struk pembayaran', - description: - 'Setelah dikonfirmasi, akan dibuatkan struk pembayaran', - isLast: true, - ), ], ), ), @@ -2030,10 +2340,11 @@ class PembayaranSewaView extends GetView { // Get denda from tagihan_sewa final denda = controller.tagihanSewa.value['denda'] ?? 0; - + // Get total dibayarkan from tagihan_dibayar - final dibayarkan = controller.tagihanSewa.value['tagihan_dibayar'] ?? 0; - + final dibayarkan = + controller.tagihanSewa.value['tagihan_dibayar'] ?? 0; + debugPrint('Tagihan Awal: $tagihanAwal'); debugPrint('Denda: $denda'); debugPrint('Total Dibayarkan: $dibayarkan'); @@ -2080,7 +2391,10 @@ class PembayaranSewaView extends GetView { } // Helper method to build detail item with subpoints - Widget _buildDetailItemWithSubpoints(String label, List> subpoints) { + Widget _buildDetailItemWithSubpoints( + String label, + List> subpoints, + ) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: Column( @@ -2097,35 +2411,32 @@ class PembayaranSewaView extends GetView { ), const SizedBox(height: 8), // Subpoints with indentation - ...subpoints.map((subpoint) => Padding( - padding: const EdgeInsets.only(left: 16, bottom: 6), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 2, - child: Text( - subpoint['label'] ?? '', - style: TextStyle( - fontSize: 13, - color: Colors.grey[600], - ), - ), + ...subpoints.map( + (subpoint) => Padding( + padding: const EdgeInsets.only(left: 16, bottom: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Text( + subpoint['label'] ?? '', + style: TextStyle(fontSize: 13, color: Colors.grey[600]), ), - Expanded( - flex: 3, - child: Text( - subpoint['value'] ?? '-', - style: const TextStyle( - fontSize: 13, - ), - textAlign: TextAlign.right, - ), + ), + Expanded( + flex: 3, + child: Text( + subpoint['value'] ?? '-', + style: const TextStyle(fontSize: 13), + textAlign: TextAlign.right, ), - ], - ), - )), + ), + ], + ), + ), + ), ], ), ); @@ -2152,3 +2463,85 @@ class PembayaranSewaView extends GetView { } } } + +class CountdownTimerWidget extends StatefulWidget { + final DateTime updatedAt; + final VoidCallback? onTimeout; + const CountdownTimerWidget({ + required this.updatedAt, + this.onTimeout, + Key? key, + }) : super(key: key); + @override + State createState() => _CountdownTimerWidgetState(); +} + +class _CountdownTimerWidgetState extends State { + late Duration remaining; + Timer? timer; + @override + void initState() { + super.initState(); + print( + 'DEBUG [CountdownTimerWidget] updatedAt: ' + + widget.updatedAt.toIso8601String(), + ); + updateRemaining(); + timer = Timer.periodic( + const Duration(seconds: 1), + (_) => updateRemaining(), + ); + } + + void updateRemaining() { + final now = DateTime.now(); + final end = widget.updatedAt.add(const Duration(hours: 1)); + setState(() { + remaining = end.difference(now); + if (remaining.isNegative) { + remaining = Duration.zero; + timer?.cancel(); + widget.onTimeout?.call(); + } + }); + } + + @override + void dispose() { + timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (remaining.inSeconds <= 0) { + return Text('Waktu habis', style: TextStyle(color: Colors.red)); + } + final h = remaining.inHours; + final m = remaining.inMinutes % 60; + final s = remaining.inSeconds % 60; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.withOpacity(0.3), width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.timer_outlined, size: 14, color: Colors.red), + const SizedBox(width: 4), + Text( + 'Bayar dalam ${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.red, + ), + ), + ], + ), + ); + } +} diff --git a/lib/app/modules/warga/views/sewa_aset_view.dart b/lib/app/modules/warga/views/sewa_aset_view.dart index 3933d68..f127dfc 100644 --- a/lib/app/modules/warga/views/sewa_aset_view.dart +++ b/lib/app/modules/warga/views/sewa_aset_view.dart @@ -117,6 +117,7 @@ class SewaAsetView extends GetView { ), ], ), + dividerColor: Colors.transparent, labelColor: Colors.white, unselectedLabelColor: const Color( 0xFF718093, diff --git a/lib/app/modules/warga/views/warga_dashboard_view.dart b/lib/app/modules/warga/views/warga_dashboard_view.dart index 090004d..24c7d31 100644 --- a/lib/app/modules/warga/views/warga_dashboard_view.dart +++ b/lib/app/modules/warga/views/warga_dashboard_view.dart @@ -154,10 +154,10 @@ class WargaDashboardView extends GetView { 'route': () => controller.navigateToRentals(), }, { - 'title': 'Bayar', - 'icon': Icons.payment_outlined, + 'title': 'Paket', + 'icon': Icons.widgets_outlined, 'color': const Color(0xFF2196F3), - 'route': () => Get.toNamed(Routes.PEMBAYARAN_SEWA), + 'route': () => controller.toSewaAsetTabPaket(), }, ]; @@ -218,32 +218,44 @@ class WargaDashboardView extends GetView { child: Column( children: [ // Sewa Diterima - _buildActivityCard( - title: 'Sewa Diterima', - value: controller.activeRentals.length.toString(), - icon: Icons.check_circle_outline, - color: AppColors.success, - onTap: () => controller.navigateToRentals(), + Obx( + () => _buildActivityCard( + title: 'Sewa Diterima', + value: controller.diterimaCount.value.toString(), + icon: Icons.check_circle_outline, + color: AppColors.success, + onTap: + () => + Get.toNamed(Routes.WARGA_SEWA, arguments: {'tab': 2}), + ), ), const SizedBox(height: 12), // Tagihan Aktif - _buildActivityCard( - title: 'Tagihan Aktif', - value: controller.activeBills.length.toString(), - icon: Icons.receipt_long_outlined, - color: AppColors.warning, - onTap: () => Get.toNamed(Routes.PEMBAYARAN_SEWA), + Obx( + () => _buildActivityCard( + title: 'Tagihan Aktif', + value: controller.tagihanAktifCount.value.toString(), + icon: Icons.receipt_long_outlined, + color: AppColors.warning, + onTap: + () => + Get.toNamed(Routes.WARGA_SEWA, arguments: {'tab': 0}), + ), ), const SizedBox(height: 12), // Denda Aktif - _buildActivityCard( - title: 'Denda Aktif', - value: controller.activePenalties.length.toString(), - icon: Icons.warning_amber_outlined, - color: AppColors.error, - onTap: () => Get.toNamed(Routes.PEMBAYARAN_SEWA), + Obx( + () => _buildActivityCard( + title: 'Denda Aktif', + value: controller.dendaAktifCount.value.toString(), + icon: Icons.warning_amber_outlined, + color: AppColors.error, + onTap: + () => + Get.toNamed(Routes.WARGA_SEWA, arguments: {'tab': 0}), + ), ), ], ), @@ -357,7 +369,7 @@ class WargaDashboardView extends GetView { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'Sewa Diterima', + 'Sewa Aktif', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -498,31 +510,34 @@ class WargaDashboardView extends GetView { ), child: Row( children: [ - // Asset icon + // Asset icon/gambar Container( - padding: const EdgeInsets.all(12), + width: 48, + height: 48, decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - AppColors.primary.withOpacity(0.7), - AppColors.primary, - ], - ), borderRadius: BorderRadius.circular(14), - boxShadow: [ - BoxShadow( - color: AppColors.primary.withOpacity(0.3), - blurRadius: 8, - offset: const Offset(0, 3), - ), - ], + color: AppColors.primary.withOpacity(0.08), ), - child: const Icon( - Icons.local_shipping, - color: Colors.white, - size: 24, + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: + rental['imageUrl'] != null && + rental['imageUrl'].toString().isNotEmpty + ? Image.network( + rental['imageUrl'], + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => Icon( + Icons.local_shipping, + color: AppColors.primary, + size: 28, + ), + ) + : Icon( + Icons.local_shipping, + color: AppColors.primary, + size: 28, + ), ), ), const SizedBox(width: 16), @@ -533,7 +548,7 @@ class WargaDashboardView extends GetView { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - rental['name'], + rental['name'] ?? '-', style: TextStyle( fontSize: 17, fontWeight: FontWeight.bold, @@ -542,7 +557,7 @@ class WargaDashboardView extends GetView { ), const SizedBox(height: 4), Text( - rental['time'], + rental['waktuSewa'] ?? '', style: TextStyle( fontSize: 13, color: AppColors.textSecondary, @@ -567,7 +582,7 @@ class WargaDashboardView extends GetView { ), ), child: Text( - rental['price'], + rental['totalPrice'] ?? 'Rp 0', style: TextStyle( color: AppColors.primary, fontWeight: FontWeight.bold, @@ -591,14 +606,14 @@ class WargaDashboardView extends GetView { child: _buildInfoItem( icon: Icons.timer_outlined, title: 'Durasi', - value: rental['duration'], + value: rental['duration'] ?? '-', ), ), Expanded( child: _buildInfoItem( icon: Icons.calendar_today_outlined, title: 'Status', - value: 'Diterima', + value: rental['status'] ?? '-', valueColor: AppColors.success, ), ), @@ -608,7 +623,7 @@ class WargaDashboardView extends GetView { const SizedBox(height: 16), // Action buttons - if (rental['can_extend']) + if ((rental['can_extend'] ?? false) == true) OutlinedButton.icon( onPressed: () => controller.extendRental(rental['id']), icon: const Icon(Icons.update, size: 18), diff --git a/lib/app/modules/warga/views/warga_profile_view.dart b/lib/app/modules/warga/views/warga_profile_view.dart index 5d01563..40318f8 100644 --- a/lib/app/modules/warga/views/warga_profile_view.dart +++ b/lib/app/modules/warga/views/warga_profile_view.dart @@ -3,12 +3,16 @@ import 'package:get/get.dart'; import '../controllers/warga_dashboard_controller.dart'; import '../views/warga_layout.dart'; import '../../../theme/app_colors.dart'; +import '../../../widgets/app_drawer.dart'; +import '../../../services/navigation_service.dart'; class WargaProfileView extends GetView { const WargaProfileView({super.key}); @override Widget build(BuildContext context) { + final navigationService = Get.find(); + navigationService.setNavIndex(2); return WargaLayout( appBar: AppBar( title: const Text('Profil Saya'), @@ -29,6 +33,14 @@ class WargaProfileView extends GetView { ), ], ), + drawer: AppDrawer( + onNavItemTapped: (index) { + // Handle navigation if needed + }, + onLogout: () { + controller.logout(); + }, + ), backgroundColor: Colors.grey.shade100, body: RefreshIndicator( color: AppColors.primary, diff --git a/lib/app/modules/warga/views/warga_sewa_view.dart b/lib/app/modules/warga/views/warga_sewa_view.dart index a94ba12..9b808e5 100644 --- a/lib/app/modules/warga/views/warga_sewa_view.dart +++ b/lib/app/modules/warga/views/warga_sewa_view.dart @@ -6,6 +6,7 @@ import '../views/warga_layout.dart'; import '../../../services/navigation_service.dart'; import '../../../widgets/app_drawer.dart'; import '../../../theme/app_colors.dart'; +import 'dart:async'; class WargaSewaView extends GetView { const WargaSewaView({super.key}); @@ -50,6 +51,7 @@ class WargaSewaView extends GetView { _buildPendingTab(), _buildDiterimaTab(), _buildAktifTab(), + _buildDikembalikanTab(), _buildSelesaiTab(), _buildDibatalkanTab(), ], @@ -119,6 +121,7 @@ class WargaSewaView extends GetView { _buildTab(text: 'Pending', icon: Icons.pending_outlined), _buildTab(text: 'Diterima', icon: Icons.check_circle_outline), _buildTab(text: 'Aktif', icon: Icons.play_circle_outline), + _buildTab(text: 'Dikembalikan', icon: Icons.assignment_return), _buildTab(text: 'Selesai', icon: Icons.task_alt_outlined), _buildTab(text: 'Dibatalkan', icon: Icons.cancel_outlined), ], @@ -142,28 +145,29 @@ class WargaSewaView extends GetView { ), ); } - + Widget _buildPendingTab() { return Obx(() { // Show loading indicator while fetching data if (controller.isLoadingPending.value) { - return const Center( - child: CircularProgressIndicator(), - ); + return const Center(child: CircularProgressIndicator()); } - + // Check if there is any data to display if (controller.pendingRentals.isNotEmpty) { return SingleChildScrollView( physics: const BouncingScrollPhysics(), padding: const EdgeInsets.all(20), child: Column( - children: controller.pendingRentals - .map((rental) => Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: _buildUnpaidRentalCard(rental), - )) - .toList(), + children: + controller.pendingRentals + .map( + (rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildUnpaidRentalCard(rental), + ), + ) + .toList(), ), ); } @@ -179,51 +183,194 @@ class WargaSewaView extends GetView { ); }); } - + Widget _buildAktifTab() { return Obx(() { - // Show loading indicator while fetching data - if (controller.isLoading.value) { - return const Center( - child: CircularProgressIndicator(), + if (controller.isLoadingActive.value) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.activeRentals.isEmpty) { + return _buildTabContent( + icon: Icons.play_circle_outline, + title: 'Tidak ada sewa aktif', + subtitle: 'Sewa yang sedang berlangsung akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: Colors.blue, ); } - - // Placeholder content for the Aktif tab - return const Center( + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.play_circle_filled, - size: 80, - color: Colors.grey, - ), - SizedBox(height: 16), - Text( - 'Tab Aktif', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - SizedBox(height: 8), - Text( - 'Konten tab Aktif akan ditampilkan di sini', - style: TextStyle(color: Colors.grey), - ), - ], + children: + controller.activeRentals + .map( + (rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildAktifRentalCard(rental), + ), + ) + .toList(), ), ); }); } + Widget _buildAktifRentalCard(Map rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.info.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(Icons.play_circle_fill, size: 18, color: AppColors.info), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'AKTIF', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.info, + ), + ), + ], + ), + ), + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + rental['imageUrl'] ?? '', + width: 80, + height: 80, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => Container( + width: 80, + height: 80, + color: Colors.grey[200], + child: const Icon( + Icons.broken_image, + color: Colors.grey, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + ], + ), + ), + ], + ), + ), + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + // Price section + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildActionButton( + icon: Icons.info_outline, + label: 'Lihat Detail', + onPressed: () => controller.viewRentalDetail(rental), + iconColor: AppColors.info, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + Widget _buildBelumBayarTab() { return Obx(() { // Show loading indicator while fetching data if (controller.isLoading.value) { - return const Center( - child: CircularProgressIndicator(), - ); + return const Center(child: CircularProgressIndicator()); } - + // Check if there is any data to display if (controller.rentals.isNotEmpty) { return SingleChildScrollView( @@ -232,12 +379,16 @@ class WargaSewaView extends GetView { child: Column( children: [ // Build a card for each rental item - ...controller.rentals.map((rental) => Column( - children: [ - _buildUnpaidRentalCard(rental), - const SizedBox(height: 20), - ], - )).toList(), + ...controller.rentals + .map( + (rental) => Column( + children: [ + _buildUnpaidRentalCard(rental), + const SizedBox(height: 20), + ], + ), + ) + .toList(), _buildTipsSection(), ], ), @@ -259,8 +410,9 @@ class WargaSewaView extends GetView { Widget _buildUnpaidRentalCard(Map rental) { // Determine status color based on status final bool isPembayaranDenda = rental['status'] == 'PEMBAYARAN DENDA'; - final Color statusColor = isPembayaranDenda ? AppColors.error : AppColors.warning; - + final Color statusColor = + isPembayaranDenda ? AppColors.error : AppColors.warning; + return Container( decoration: BoxDecoration( color: Colors.white, @@ -289,7 +441,9 @@ class WargaSewaView extends GetView { mainAxisAlignment: MainAxisAlignment.start, children: [ Icon( - isPembayaranDenda ? Icons.warning_amber_rounded : Icons.access_time_rounded, + isPembayaranDenda + ? Icons.warning_amber_rounded + : Icons.access_time_rounded, size: 18, color: statusColor, ), @@ -315,49 +469,52 @@ class WargaSewaView extends GetView { // Asset image with rounded corners ClipRRect( borderRadius: BorderRadius.circular(12), - child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') - ? Image.network( - rental['imageUrl'], - width: 90, - height: 90, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( + child: + rental['imageUrl'] != null && + rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], width: 90, height: 90, - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.image_not_supported_outlined, - size: 36, - color: Colors.grey.shade400, - ), - ); - }, - ) - : Image.asset( - rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', - width: 90, - height: 90, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? + 'assets/images/gambar_pendukung.jpg', width: 90, height: 90, - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.image_not_supported_outlined, - size: 36, - color: Colors.grey.shade400, - ), - ); - }, - ), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ), ), const SizedBox(width: 16), // Asset details @@ -389,40 +546,12 @@ class WargaSewaView extends GetView { text: rental['rentangWaktu'] ?? '', ), const SizedBox(height: 12), - // Countdown timer - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, + if ((rental['status'] ?? '').toString().toUpperCase() == + 'MENUNGGU PEMBAYARAN' && + rental['updated_at'] != null) + CountdownTimerWidget( + updatedAt: DateTime.parse(rental['updated_at']), ), - decoration: BoxDecoration( - color: AppColors.error.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColors.error.withOpacity(0.3), - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.timer_outlined, - size: 14, - color: AppColors.error, - ), - const SizedBox(width: 4), - Text( - 'Bayar dalam ${rental['countdown'] ?? '00:59:59'}', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: AppColors.error, - ), - ), - ], - ), - ), ], ), ), @@ -465,7 +594,10 @@ class WargaSewaView extends GetView { ElevatedButton( onPressed: () {}, style: ElevatedButton.styleFrom( - backgroundColor: rental['status'] == 'PEMBAYARAN DENDA' ? AppColors.error : AppColors.warning, + backgroundColor: + rental['status'] == 'PEMBAYARAN DENDA' + ? AppColors.error + : AppColors.warning, foregroundColor: Colors.white, elevation: 0, padding: const EdgeInsets.symmetric( @@ -477,8 +609,13 @@ class WargaSewaView extends GetView { ), ), child: Text( - rental['status'] == 'PEMBAYARAN DENDA' ? 'Bayar Denda' : 'Bayar Sekarang', - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + rental['status'] == 'PEMBAYARAN DENDA' + ? 'Bayar Denda' + : 'Bayar Sekarang', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), ), ), ], @@ -502,14 +639,18 @@ class WargaSewaView extends GetView { ), ), const SizedBox(width: 8), - Expanded( - child: _buildActionButton( - icon: Icons.cancel_outlined, - label: 'Batalkan', - onPressed: () => controller.cancelRental(rental['id']), - iconColor: AppColors.error, + if ((rental['status'] ?? '').toString().toUpperCase() != + 'PEMBAYARAN DENDA' && + (rental['status'] ?? '').toString().toUpperCase() != + 'PERIKSA PEMBAYARAN DENDA') + Expanded( + child: _buildActionButton( + icon: Icons.cancel_outlined, + label: 'Batalkan', + onPressed: () => controller.cancelRental(rental['id']), + iconColor: AppColors.error, + ), ), - ), ], ), ), @@ -560,11 +701,9 @@ class WargaSewaView extends GetView { return Obx(() { // Show loading indicator while fetching data if (controller.isLoadingAccepted.value) { - return const Center( - child: CircularProgressIndicator(), - ); + return const Center(child: CircularProgressIndicator()); } - + // Check if there is any data to display if (controller.acceptedRentals.isNotEmpty) { return SingleChildScrollView( @@ -573,12 +712,16 @@ class WargaSewaView extends GetView { child: Column( children: [ // Build a card for each accepted rental item - ...controller.acceptedRentals.map((rental) => Column( - children: [ - _buildDiterimaRentalCard(rental), - const SizedBox(height: 20), - ], - )).toList(), + ...controller.acceptedRentals + .map( + (rental) => Column( + children: [ + _buildDiterimaRentalCard(rental), + const SizedBox(height: 20), + ], + ), + ) + .toList(), _buildTipsSectionDiterima(), ], ), @@ -652,49 +795,52 @@ class WargaSewaView extends GetView { // Asset image with rounded corners ClipRRect( borderRadius: BorderRadius.circular(12), - child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') - ? Image.network( - rental['imageUrl'], - width: 90, - height: 90, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( + child: + rental['imageUrl'] != null && + rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], width: 90, height: 90, - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.image_not_supported_outlined, - size: 36, - color: Colors.grey.shade400, - ), - ); - }, - ) - : Image.asset( - rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', - width: 90, - height: 90, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? + 'assets/images/gambar_pendukung.jpg', width: 90, height: 90, - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.image_not_supported_outlined, - size: 36, - color: Colors.grey.shade400, - ), - ); - }, - ), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ), ), const SizedBox(width: 16), // Asset details @@ -826,12 +972,15 @@ class WargaSewaView extends GetView { physics: const BouncingScrollPhysics(), padding: const EdgeInsets.all(20), child: Column( - children: controller.completedRentals - .map((rental) => Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: _buildSelesaiRentalCard(rental), - )) - .toList(), + children: + controller.completedRentals + .map( + (rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildSelesaiRentalCard(rental), + ), + ) + .toList(), ), ); }); @@ -888,49 +1037,52 @@ class WargaSewaView extends GetView { // Asset image with rounded corners ClipRRect( borderRadius: BorderRadius.circular(12), - child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') - ? Image.network( - rental['imageUrl'], - width: 90, - height: 90, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( + child: + rental['imageUrl'] != null && + rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], width: 90, height: 90, - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.image_not_supported_outlined, - size: 36, - color: Colors.grey.shade400, - ), - ); - }, - ) - : Image.asset( - rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', - width: 90, - height: 90, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? + 'assets/images/gambar_pendukung.jpg', width: 90, height: 90, - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.image_not_supported_outlined, - size: 36, - color: Colors.grey.shade400, - ), - ); - }, - ), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ), ), const SizedBox(width: 16), // Asset details @@ -972,11 +1124,7 @@ class WargaSewaView extends GetView { ), // Divider - Divider( - color: Colors.grey.shade200, - thickness: 1, - height: 1, - ), + Divider(color: Colors.grey.shade200, thickness: 1, height: 1), // Price section Padding( @@ -1047,12 +1195,15 @@ class WargaSewaView extends GetView { physics: const BouncingScrollPhysics(), padding: const EdgeInsets.all(20), child: Column( - children: controller.cancelledRentals - .map((rental) => Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: _buildDibatalkanRentalCard(rental), - )) - .toList(), + children: + controller.cancelledRentals + .map( + (rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildDibatalkanRentalCard(rental), + ), + ) + .toList(), ), ); }); @@ -1109,49 +1260,52 @@ class WargaSewaView extends GetView { // Asset image with rounded corners ClipRRect( borderRadius: BorderRadius.circular(12), - child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') - ? Image.network( - rental['imageUrl'], - width: 90, - height: 90, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( + child: + rental['imageUrl'] != null && + rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], width: 90, height: 90, - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.image_not_supported_outlined, - size: 36, - color: Colors.grey.shade400, - ), - ); - }, - ) - : Image.asset( - rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', - width: 90, - height: 90, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? + 'assets/images/gambar_pendukung.jpg', width: 90, height: 90, - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.image_not_supported_outlined, - size: 36, - color: Colors.grey.shade400, - ), - ); - }, - ), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ), ), const SizedBox(width: 16), // Asset details @@ -1185,7 +1339,8 @@ class WargaSewaView extends GetView { icon: Icons.access_time, text: rental['duration'] ?? '-', ), - if (rental['alasanPembatalan'] != null && rental['alasanPembatalan'] != '-') + if (rental['alasanPembatalan'] != null && + rental['alasanPembatalan'] != '-') Padding( padding: const EdgeInsets.only(top: 8.0), child: _buildInfoRow( @@ -1201,11 +1356,7 @@ class WargaSewaView extends GetView { ), // Divider - Divider( - color: Colors.grey.shade200, - thickness: 1, - height: 1, - ), + Divider(color: Colors.grey.shade200, thickness: 1, height: 1), // Price section Padding( @@ -1243,15 +1394,6 @@ class WargaSewaView extends GetView { iconColor: AppColors.info, ), ), - const SizedBox(width: 8), - Expanded( - child: _buildActionButton( - icon: Icons.refresh, - label: 'Pesan Kembali', - onPressed: () {}, - iconColor: AppColors.success, - ), - ), ], ), ], @@ -1490,4 +1632,261 @@ class WargaSewaView extends GetView { ), ); } + + Widget _buildDikembalikanTab() { + return Obx(() { + if (controller.isLoadingReturned.value) { + return const Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ); + } + if (controller.returnedRentals.isEmpty) { + return _buildTabContent( + icon: Icons.assignment_return, + title: 'Belum Ada Sewa Dikembalikan', + subtitle: 'Sewa yang sudah dikembalikan akan muncul di sini', + buttonText: 'Lihat Aset', + onButtonPressed: () => Get.toNamed('/warga-aset'), + color: Colors.deepPurple, + ); + } + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: + controller.returnedRentals + .map( + (rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildDikembalikanRentalCard(rental), + ), + ) + .toList(), + ), + ); + }); + } + + Widget _buildDikembalikanRentalCard(Map rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.info.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(Icons.assignment_return, size: 18, color: AppColors.info), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'DIKEMBALIKAN', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.info, + ), + ), + ], + ), + ), + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + rental['imageUrl'] ?? '', + width: 64, + height: 64, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => Container( + width: 64, + height: 64, + color: Colors.grey[200], + child: const Icon(Icons.image, color: Colors.grey), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + ], + ), + ), + ], + ), + ), + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: _buildActionButton( + icon: Icons.info_outline, + label: 'Lihat Detail', + onPressed: () => controller.viewRentalDetail(rental), + iconColor: AppColors.info, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class CountdownTimerWidget extends StatefulWidget { + final DateTime updatedAt; + final VoidCallback? onTimeout; + const CountdownTimerWidget({ + required this.updatedAt, + this.onTimeout, + Key? key, + }) : super(key: key); + @override + State createState() => _CountdownTimerWidgetState(); +} + +class _CountdownTimerWidgetState extends State { + late Duration remaining; + Timer? timer; + @override + void initState() { + super.initState(); + updateRemaining(); + timer = Timer.periodic( + const Duration(seconds: 1), + (_) => updateRemaining(), + ); + print('DEBUG updated_at: ${widget.updatedAt}'); + } + + void updateRemaining() { + final now = DateTime.now(); + final deadline = widget.updatedAt.add(const Duration(hours: 1)); + setState(() { + remaining = deadline.difference(now); + if (remaining.isNegative) { + remaining = Duration.zero; + timer?.cancel(); + widget.onTimeout?.call(); + } + }); + } + + @override + void dispose() { + timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (remaining.inSeconds <= 0) { + return Text('Waktu habis', style: TextStyle(color: Colors.red)); + } + final h = remaining.inHours; + final m = remaining.inMinutes % 60; + final s = remaining.inSeconds % 60; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.withOpacity(0.3), width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.timer_outlined, size: 14, color: Colors.red), + const SizedBox(width: 4), + Text( + 'Bayar dalam ${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.red, + ), + ), + ], + ), + ); + } } diff --git a/lib/app/services/pembayaran_service.dart b/lib/app/services/pembayaran_service.dart new file mode 100644 index 0000000..c3cf222 --- /dev/null +++ b/lib/app/services/pembayaran_service.dart @@ -0,0 +1,88 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../data/models/pembayaran_model.dart'; + +class PembayaranService { + final SupabaseClient _supabase = Supabase.instance.client; + + /// Ambil data pembayaran antara [start] (inklusif) dan [end] (eksklusif). + Future> _fetchBetween( + DateTime start, + DateTime end, + ) async { + final data = await _supabase + .from('pembayaran') + .select('id, metode_pembayaran, total_pembayaran, waktu_pembayaran') + .gte('waktu_pembayaran', start.toIso8601String()) + .lt('waktu_pembayaran', end.toIso8601String()) + .order('waktu_pembayaran', ascending: true); + + return (data as List) + .map((e) => PembayaranModel.fromJson(e as Map)) + .toList(); + } + + /// Hitung statistik yang diminta. + Future> fetchStats() async { + final now = DateTime.now(); + + // Rentang bulan ini: [awal bulan ini, awal bulan depan) + final thisMonthStart = DateTime(now.year, now.month, 1); + final nextMonthStart = DateTime(now.year, now.month + 1, 1); + + // Bulan lalu: [awal bulan lalu, awal bulan ini) + final lastMonthStart = DateTime(now.year, now.month - 1, 1); + final thisMonthEnd = thisMonthStart; + + // 6 bulan terakhir: [6 bulan lalu, sekarang] + final sixMonthsAgo = DateTime(now.year, now.month - 6, 1); + + // 1) Data bulan ini & bulan lalu + final thisMonthData = await _fetchBetween(thisMonthStart, nextMonthStart); + final lastMonthData = await _fetchBetween(lastMonthStart, thisMonthEnd); + + // 2) Data 6 bulan terakhir + final sixMonthsData = await _fetchBetween(sixMonthsAgo, nextMonthStart); + + // 3) Hitung total pendapatan + double sum(List list) => + list.fold(0.0, (acc, e) => acc + e.totalPembayaran); + + final totalThis = sum(thisMonthData); + final totalLast = sum(lastMonthData); + final totalSix = sum(sixMonthsData); + + // 4) Persentase selisih (bulanan) + double percentDiff = 0.0; + if (totalLast != 0) { + percentDiff = ((totalThis - totalLast) / totalLast) * 100; + } + + // 5) Total per metode (hanya dari bulan ini, misalnya) + double totTunai = 0.0, totTransfer = 0.0; + for (var p in thisMonthData) { + if (p.metodePembayaran.toLowerCase() == 'tunai') { + totTunai += p.totalPembayaran; + } else if (p.metodePembayaran.toLowerCase() == 'transfer') { + totTransfer += p.totalPembayaran; + } + } + + // 6) Trend per month (6 months, oldest to newest) + List trendPerMonth = []; + for (int i = 5; i >= 0; i--) { + final dt = DateTime(now.year, now.month - i, 1); + final dtNext = DateTime(now.year, now.month - i + 1, 1); + final monthData = await _fetchBetween(dt, dtNext); + trendPerMonth.add(sum(monthData)); + } + + return { + 'totalThisMonth': totalThis, + 'percentComparedLast': percentDiff, + 'totalTunai': totTunai, + 'totalTransfer': totTransfer, + 'totalLastSixMonths': totalSix, + 'trendPerMonth': trendPerMonth, + }; + } +} diff --git a/lib/app/services/service_manager.dart b/lib/app/services/service_manager.dart index cb29284..9bfde27 100644 --- a/lib/app/services/service_manager.dart +++ b/lib/app/services/service_manager.dart @@ -2,6 +2,8 @@ import 'package:get/get.dart'; import 'navigation_service.dart'; import '../data/providers/auth_provider.dart'; import '../modules/warga/controllers/warga_dashboard_controller.dart'; +import '../data/providers/aset_provider.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; /// Abstract class untuk mengelola lifecycle service dan dependency abstract class ServiceManager { @@ -26,6 +28,11 @@ abstract class ServiceManager { Get.put(AuthProvider(), permanent: true); } + // Register AsetProvider if not already registered + if (!Get.isRegistered()) { + Get.put(AsetProvider(), permanent: true); + } + // Register WargaDashboardController as a permanent controller // This ensures it's always available for the drawer registerWargaDashboardController(); diff --git a/lib/app/services/sewa_service.dart b/lib/app/services/sewa_service.dart new file mode 100644 index 0000000..23eca91 --- /dev/null +++ b/lib/app/services/sewa_service.dart @@ -0,0 +1,222 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../data/models/rental_booking_model.dart'; +import 'package:flutter/foundation.dart'; + +class SewaService { + final SupabaseClient _supabase = Supabase.instance.client; + + Future> fetchAllSewa() async { + // 1) Ambil semua sewa_aset + final sewaData = + await _supabase.from('sewa_aset').select(''' + id, + user_id, + status, + waktu_mulai, + waktu_selesai, + tanggal_pemesanan, + tipe_pesanan, + kuantitas, + aset_id, + paket_id + ''') + as List; + + if (sewaData.isEmpty) return []; + + // Konversi dasar ke map + final List> rawSewa = + sewaData.map((e) => e as Map).toList(); + + // Kumpulkan semua ID + final sewaIds = rawSewa.map((e) => e['id'] as String).toList(); + final userIds = rawSewa.map((e) => e['user_id'] as String).toSet().toList(); + + // Pisahkan aset dan paket IDs + final asetIds = + rawSewa + .where((e) => e['tipe_pesanan'] == 'tunggal') + .map((e) => e['aset_id'] as String?) + .whereType() + .toSet() + .toList(); + final paketIds = + rawSewa + .where((e) => e['tipe_pesanan'] == 'paket') + .map((e) => e['paket_id'] as String?) + .whereType() + .toSet() + .toList(); + + // 2) Ambil tagihan_sewa + final tagihanData = + await _supabase + .from('tagihan_sewa') + .select('sewa_aset_id, total_tagihan, denda, tagihan_dibayar') + .filter('sewa_aset_id', 'in', '(${sewaIds.join(",")})') + as List; + final Map> mapTagihan = { + for (var t in tagihanData) + t['sewa_aset_id'] as String: t as Map, + }; + + // 3) Ambil data warga_desa + final wargaData = + await _supabase + .from('warga_desa') + .select('user_id, nama_lengkap, no_hp, avatar') + .filter('user_id', 'in', '(${userIds.join(",")})') + as List; + debugPrint('DEBUG wargaData (raw): ' + wargaData.toString()); + final Map> mapWarga = { + for (var w in wargaData) + (w['user_id'] as String): { + 'nama': w['nama_lengkap'] as String? ?? '-', + 'noHp': w['no_hp'] as String? ?? '-', + 'avatar': w['avatar'] as String? ?? '-', + }, + }; + debugPrint('DEBUG mapWarga (mapped): ' + mapWarga.toString()); + + // 4) Ambil data aset (untuk tunggal) + Map> mapAset = {}; + if (asetIds.isNotEmpty) { + final asetData = + await _supabase + .from('aset') + .select('id, nama') + .filter('id', 'in', '(${asetIds.join(",")})') + as List; + final fotoData = + await _supabase + .from('foto_aset') + .select('id_aset, foto_aset') + .filter('id_aset', 'in', '(${asetIds.join(",")})') + as List; + + // Map aset id → nama + mapAset = { + for (var a in asetData) + (a['id'] as String): {'nama': a['nama'] as String}, + }; + // Ambil foto pertama per aset + for (var f in fotoData) { + final aid = f['id_aset'] as String; + if (mapAset.containsKey(aid) && mapAset[aid]!['foto'] == null) { + mapAset[aid]!['foto'] = f['foto_aset'] as String; + } + } + } + + // 5) Ambil data paket (untuk paket) + Map> mapPaket = {}; + if (paketIds.isNotEmpty) { + final paketData = + await _supabase + .from('paket') + .select('id, nama') + .filter('id', 'in', '(${paketIds.join(",")})') + as List; + final fotoData = + await _supabase + .from('foto_aset') + .select('id_paket, foto_aset') + .filter('id_paket', 'in', '(${paketIds.join(",")})') + as List; + + mapPaket = { + for (var p in paketData) + (p['id'] as String): {'nama': p['nama'] as String}, + }; + for (var f in fotoData) { + final pid = f['id_paket'] as String; + if (mapPaket.containsKey(pid) && mapPaket[pid]!['foto'] == null) { + mapPaket[pid]!['foto'] = f['foto_aset'] as String; + } + } + } + + // Debug print hasil query utama + debugPrint('sewaData: ' + sewaData.toString()); + debugPrint('sewaData count: ' + sewaData.length.toString()); + debugPrint('tagihanData: ' + tagihanData.toString()); + debugPrint('wargaData: ' + wargaData.toString()); + debugPrint('mapAset: ' + mapAset.toString()); + debugPrint('mapPaket: ' + mapPaket.toString()); + + // 6) Gabungkan dan bangun SewaModel + final List result = []; + for (var row in rawSewa) { + final id = row['id'] as String; + final tipe = row['tipe_pesanan'] as String; + final warga = mapWarga[row['user_id']]; + final tagihan = mapTagihan[id]; + if (warga == null || tagihan == null) { + // Skip data jika relasi tidak ditemukan + continue; + } + result.add( + SewaModel( + id: id, + userId: row['user_id'], + status: row['status'] as String, + waktuMulai: DateTime.parse(row['waktu_mulai'] as String), + waktuSelesai: DateTime.parse(row['waktu_selesai'] as String), + tanggalPemesanan: DateTime.parse(row['tanggal_pemesanan'] as String), + tipePesanan: tipe, + kuantitas: row['kuantitas'] as int, + asetId: tipe == 'tunggal' ? row['aset_id'] as String : null, + asetNama: + (row['aset_id'] != null && + tipe == 'tunggal' && + mapAset[row['aset_id']] != null) + ? mapAset[row['aset_id']]!['nama'] + : null, + asetFoto: + (row['aset_id'] != null && + tipe == 'tunggal' && + mapAset[row['aset_id']] != null) + ? mapAset[row['aset_id']]!['foto'] + : null, + paketId: tipe == 'paket' ? row['paket_id'] as String : null, + paketNama: + (row['paket_id'] != null && + tipe == 'paket' && + mapPaket[row['paket_id']] != null) + ? mapPaket[row['paket_id']]!['nama'] + : null, + paketFoto: + (row['paket_id'] != null && + tipe == 'paket' && + mapPaket[row['paket_id']] != null) + ? mapPaket[row['paket_id']]!['foto'] + : null, + totalTagihan: + tagihan['total_tagihan'] != null + ? (tagihan['total_tagihan'] as num?)?.toDouble() ?? 0.0 + : 0.0, + denda: + tagihan['denda'] != null + ? (tagihan['denda'] as num?)?.toDouble() + : null, + dibayar: + tagihan['tagihan_dibayar'] != null + ? (tagihan['tagihan_dibayar'] as num?)?.toDouble() + : null, + paidAmount: + tagihan['total_tagihan'] != null + ? (tagihan['total_tagihan'] as num?)?.toDouble() + : null, + wargaNama: warga['nama'] ?? '-', + wargaNoHp: warga['noHp'] ?? '-', + wargaAvatar: warga['avatar'] ?? '-', + ), + ); + } + + // Debug print hasil query result + debugPrint('SewaModel result: ' + result.toString()); + debugPrint('SewaModel result count: ' + result.length.toString()); + return result; + } +} diff --git a/lib/app/theme/app_theme.dart b/lib/app/theme/app_theme.dart index a077146..acadf8e 100644 --- a/lib/app/theme/app_theme.dart +++ b/lib/app/theme/app_theme.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'app_colors.dart'; /// App theme configuration @@ -19,13 +20,16 @@ class AppTheme { ), scaffoldBackgroundColor: AppColors.background, + // Set Lato as the default font for the entire app + fontFamily: GoogleFonts.lato().fontFamily, + // App bar theme appBarTheme: AppBarTheme( backgroundColor: AppColors.primary, foregroundColor: Colors.white, elevation: 0, iconTheme: const IconThemeData(color: Colors.white), - titleTextStyle: const TextStyle( + titleTextStyle: GoogleFonts.lato( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600, @@ -50,7 +54,10 @@ class AppTheme { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), - textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + textStyle: GoogleFonts.lato( + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), ), @@ -62,7 +69,10 @@ class AppTheme { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), - textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + textStyle: GoogleFonts.lato( + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), ), @@ -70,7 +80,10 @@ class AppTheme { style: TextButton.styleFrom( foregroundColor: AppColors.primary, padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), - textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + textStyle: GoogleFonts.lato( + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), ), @@ -98,8 +111,8 @@ class AppTheme { borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: AppColors.error, width: 1.5), ), - hintStyle: TextStyle(color: AppColors.textLight), - labelStyle: TextStyle(color: AppColors.textSecondary), + hintStyle: GoogleFonts.lato(color: AppColors.textLight), + labelStyle: GoogleFonts.lato(color: AppColors.textSecondary), ), // Checkbox theme @@ -115,21 +128,21 @@ class AppTheme { // Text themes textTheme: TextTheme( - displayLarge: TextStyle(color: AppColors.textPrimary), - displayMedium: TextStyle(color: AppColors.textPrimary), - displaySmall: TextStyle(color: AppColors.textPrimary), - headlineLarge: TextStyle(color: AppColors.textPrimary), - headlineMedium: TextStyle(color: AppColors.textPrimary), - headlineSmall: TextStyle(color: AppColors.textPrimary), - titleLarge: TextStyle(color: AppColors.textPrimary), - titleMedium: TextStyle(color: AppColors.textPrimary), - titleSmall: TextStyle(color: AppColors.textPrimary), - bodyLarge: TextStyle(color: AppColors.textPrimary), - bodyMedium: TextStyle(color: AppColors.textPrimary), - bodySmall: TextStyle(color: AppColors.textSecondary), - labelLarge: TextStyle(color: AppColors.textPrimary), - labelMedium: TextStyle(color: AppColors.textSecondary), - labelSmall: TextStyle(color: AppColors.textLight), + displayLarge: GoogleFonts.lato(color: AppColors.textPrimary), + displayMedium: GoogleFonts.lato(color: AppColors.textPrimary), + displaySmall: GoogleFonts.lato(color: AppColors.textPrimary), + headlineLarge: GoogleFonts.lato(color: AppColors.textPrimary), + headlineMedium: GoogleFonts.lato(color: AppColors.textPrimary), + headlineSmall: GoogleFonts.lato(color: AppColors.textPrimary), + titleLarge: GoogleFonts.lato(color: AppColors.textPrimary), + titleMedium: GoogleFonts.lato(color: AppColors.textPrimary), + titleSmall: GoogleFonts.lato(color: AppColors.textPrimary), + bodyLarge: GoogleFonts.lato(color: AppColors.textPrimary), + bodyMedium: GoogleFonts.lato(color: AppColors.textPrimary), + bodySmall: GoogleFonts.lato(color: AppColors.textSecondary), + labelLarge: GoogleFonts.lato(color: AppColors.textPrimary), + labelMedium: GoogleFonts.lato(color: AppColors.textSecondary), + labelSmall: GoogleFonts.lato(color: AppColors.textLight), ), // Divider theme diff --git a/lib/app/utils/format_utils.dart b/lib/app/utils/format_utils.dart new file mode 100644 index 0000000..46ecc47 --- /dev/null +++ b/lib/app/utils/format_utils.dart @@ -0,0 +1,10 @@ +import 'package:intl/intl.dart'; + +String formatRupiah(double value) { + final formatter = NumberFormat.currency( + locale: 'id_ID', + symbol: 'Rp', + decimalDigits: 0, + ); + return formatter.format(value); +} diff --git a/lib/app/widgets/app_drawer.dart b/lib/app/widgets/app_drawer.dart index 60544fb..0338400 100644 --- a/lib/app/widgets/app_drawer.dart +++ b/lib/app/widgets/app_drawer.dart @@ -291,34 +291,6 @@ class AppDrawer extends StatelessWidget { ), // Settings Items - _buildDrawerItem( - icon: Icons.info_outline_rounded, - title: 'Tentang Aplikasi', - subtitle: 'Informasi dan bantuan', - showTrailing: false, - onTap: () { - Navigator.pop(context); - // Show about dialog - showAboutDialog( - context: context, - applicationName: 'BumRent App', - applicationVersion: '1.0.0', - applicationIcon: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.asset( - 'assets/images/logo.png', - width: 40, - height: 40, - ), - ), - children: [ - const Text( - 'Aplikasi penyewaan dan berlangganan aset milik BUMDes untuk warga desa.', - ), - ], - ); - }, - ), _buildDrawerItem( icon: Icons.logout_rounded, title: 'Keluar', diff --git a/pubspec.lock b/pubspec.lock index e2d2322..ce0c194 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -525,6 +525,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logger: + dependency: "direct main" + description: + name: logger + sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + url: "https://pub.dev" + source: hosted + version: "2.5.0" logging: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 23b071d..5d6a7cd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: flutter_dotenv: ^5.1.0 image_picker: ^1.0.7 intl: 0.19.0 + logger: ^2.1.0 flutter_localizations: sdk: flutter get_storage: ^2.1.1 diff --git a/widget_test.dart b/widget_test.dart new file mode 100644 index 0000000..31af8b2 --- /dev/null +++ b/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:bumrent_app/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +}