5 Commits

Author SHA1 Message Date
47766bbdda kelola penyewa dan beberapa error fix 2025-07-09 16:01:10 +07:00
0423c2fdf9 semua fitur selesai 2025-06-30 15:22:38 +07:00
8284c93aa5 fitur petugas 2025-06-22 09:25:58 +07:00
c4dd4fdfa2 fitur order paket 2025-06-05 17:00:44 +07:00
046eac48e8 Merge pull request #1 from andreasmalvino/andreasmalvino-patch-1
Update order_sewa_paket_view.dart
2025-06-04 14:46:49 +07:00
137 changed files with 25118 additions and 8002 deletions

View File

@ -1,8 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="bumrent_app"
android:label="BumRent"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/launcher_icon">
<activity
android:name=".MainActivity"
android:exported="true"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
assets/images/logo_app.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@ -427,7 +427,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@ -484,7 +484,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Bumrent App</string>
<string>BumRent</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>bumrent_app</string>
<string>BumRent</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>

View File

@ -19,12 +19,12 @@ class PetugasBumdesBinding extends Bindings {
print('Error removing controller: $e');
}
// Gunakan put untuk memastikan controller selalu tersedia dan permanent
Get.put<PetugasBumdesDashboardController>(
PetugasBumdesDashboardController(),
permanent: true,
// Gunakan lazyPut untuk memastikan controller hanya diinisialisasi saat dibutuhkan
Get.lazyPut<PetugasBumdesDashboardController>(
() => PetugasBumdesDashboardController(),
fenix: true, // Akan dibuat ulang jika dihapus
);
print('✅ PetugasBumdesDashboardController registered successfully');
print('✅ PetugasBumdesDashboardController initialized successfully');
}
}

View File

@ -5,7 +5,15 @@ import '../modules/warga/controllers/warga_dashboard_controller.dart';
class WargaBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<AuthProvider>(() => AuthProvider());
Get.lazyPut<WargaDashboardController>(() => WargaDashboardController());
// Pastikan AuthProvider teregistrasi
if (!Get.isRegistered<AuthProvider>()) {
Get.put(AuthProvider());
}
// Gunakan lazyPut untuk memastikan controller hanya diinisialisasi saat dibutuhkan
Get.lazyPut<WargaDashboardController>(
() => WargaDashboardController(),
fenix: true, // Akan dibuat ulang jika dihapus
);
}
}

View File

@ -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<String> imageUrls = <String>[].obs;
// Menggunakan RxList untuk membuatnya mutable dan reaktif
RxList<Map<String, dynamic>> satuanWaktuSewa = <Map<String, dynamic>>[].obs;
final RxList<Map<String, dynamic>> satuanWaktuSewa = <Map<String, dynamic>>[].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<String, dynamic> 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<Map<String, dynamic>>.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<String>.from(json['foto_aset_tambahan']);
for (final url in additionalImages) {
model.addImageUrl(url);
}
}
return model;
}
Map<String, dynamic> toJson() {
return {
final data = <String, dynamic>{
'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;
}
}

View File

@ -1,54 +1,169 @@
import 'dart:convert';
import 'dart:developer' as developer;
class PaketModel {
final String? id;
final String? nama;
final String? deskripsi;
final int? harga;
final int? kuantitas;
final String? foto_paket;
final List<dynamic>? satuanWaktuSewa;
final String id;
final String nama;
final String deskripsi;
final double harga;
final int kuantitas;
final String status;
List<String> foto;
List<Map<String, dynamic>> satuanWaktuSewa;
final DateTime createdAt;
final DateTime updatedAt;
String? foto_paket; // Main photo URL
List<String>? images; // List of photo URLs
PaketModel({
this.id,
this.nama,
this.deskripsi,
this.harga,
this.kuantitas,
required this.id,
required this.nama,
required this.deskripsi,
required this.harga,
required this.kuantitas,
this.status = 'aktif',
required List<String> foto,
required List<Map<String, dynamic>> satuanWaktuSewa,
this.foto_paket,
this.satuanWaktuSewa,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'nama': nama,
'deskripsi': deskripsi,
'harga': harga,
'kuantitas': kuantitas,
'foto_paket': foto_paket,
'satuanWaktuSewa': satuanWaktuSewa,
};
}
factory PaketModel.fromMap(Map<String, dynamic> map) {
List<String>? 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<String>? foto,
List<Map<String, dynamic>>? satuanWaktuSewa,
String? foto_paket,
List<String>? images,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return PaketModel(
id: map['id'],
nama: map['nama'],
deskripsi: map['deskripsi'],
harga: map['harga']?.toInt(),
kuantitas: map['kuantitas']?.toInt(),
foto_paket: map['foto_paket'],
satuanWaktuSewa: map['satuanWaktuSewa'],
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,
);
}
String toJson() => json.encode(toMap());
// Alias for fromJson to maintain compatibility
factory PaketModel.fromMap(Map<String, dynamic> json) => PaketModel.fromJson(json);
factory PaketModel.fromJson(Map<String, dynamic> json) {
// Handle different possible JSON structures
final fotoList = <String>[];
// Check for different possible photo field names
if (json['foto'] != null) {
if (json['foto'] is String) {
fotoList.add(json['foto']);
} else if (json['foto'] is List) {
fotoList.addAll((json['foto'] as List).whereType<String>());
}
}
if (json['foto_paket'] != null) {
if (json['foto_paket'] is String) {
fotoList.add(json['foto_paket']);
} else if (json['foto_paket'] is List) {
fotoList.addAll((json['foto_paket'] as List).whereType<String>());
}
}
// Handle satuan_waktu_sewa
List<Map<String, dynamic>> satuanWaktuList = [];
if (json['satuan_waktu_sewa'] != null) {
if (json['satuan_waktu_sewa'] is List) {
satuanWaktuList = List<Map<String, dynamic>>.from(
json['satuan_waktu_sewa'].map((x) => x is Map ? Map<String, dynamic>.from(x) : {})
);
} else if (json['satuan_waktu_sewa'] is Map) {
satuanWaktuList = [Map<String, dynamic>.from(json['satuan_waktu_sewa'])];
}
}
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,
satuanWaktuSewa: satuanWaktuList,
foto_paket: json['foto_paket']?.toString(),
images: json['images'] != null ? List<String>.from(json['images']) : null,
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'].toString())
: DateTime.now(),
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at'].toString())
: DateTime.now(),
);
}
factory PaketModel.fromJson(String source) => PaketModel.fromMap(json.decode(source));
// Convert to JSON
Map<String, dynamic> toJson() => {
'id': id,
'nama': nama,
'deskripsi': deskripsi,
'harga': harga,
'kuantitas': kuantitas,
'foto': foto,
'foto_paket': foto_paket,
'images': images,
'satuan_waktu_sewa': satuanWaktuSewa,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
@override
String toString() {
return 'PaketModel(id: $id, nama: $nama, deskripsi: $deskripsi, harga: $harga, kuantitas: $kuantitas, foto_paket: $foto_paket, satuanWaktuSewa: $satuanWaktuSewa)';
// Get the first photo URL or a placeholder
String get firstPhotoUrl => foto.isNotEmpty ? foto.first : '';
// Get the formatted price
String get formattedPrice => 'Rp${harga.toStringAsFixed(0).replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]}.',
)}';
// Check if the package is available
bool get isAvailable => kuantitas > 0;
// Get the first available time unit
Map<String, dynamic>? get defaultTimeUnit =>
satuanWaktuSewa.isNotEmpty ? satuanWaktuSewa.first : null;
// Get the price for a specific time unit
double getPriceForTimeUnit(String timeUnitId) {
try {
final unit = satuanWaktuSewa.firstWhere(
(unit) => unit['id'] == timeUnitId || unit['id'].toString() == timeUnitId,
);
return (unit['harga'] as num?)?.toDouble() ?? 0.0;
} catch (e) {
return 0.0;
}
}
}

View File

@ -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<String, dynamic> 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),
);
}
}

View File

@ -1 +1,99 @@
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;
// Add nama_satuan_waktu field
final String? namaSatuanWaktu;
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,
this.namaSatuanWaktu,
});
factory SewaModel.fromJson(Map<String, dynamic> 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'),
namaSatuanWaktu: json['nama_satuan_waktu'],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -75,6 +75,25 @@ class AuthProvider extends GetxService {
await client.auth.signOut();
}
// Method to clear any cached data in the AuthProvider
void clearAuthData() {
// Clear any cached user data or state
// This method is called during logout to ensure all user-related data is cleared
debugPrint('Clearing AuthProvider cached data');
// Explicitly clear any cached data that might be stored in the provider
// This is important to ensure no user data remains after logout or registration
try {
// Force refresh of the auth state
client.auth.refreshSession();
// Log the cleanup action
debugPrint('AuthProvider cached data cleared successfully');
} catch (e) {
debugPrint('Error clearing AuthProvider cached data: $e');
}
}
User? get currentUser => client.auth.currentUser;
Stream<AuthState> get authChanges => client.auth.onAuthStateChange;
@ -415,28 +434,17 @@ class AuthProvider extends GetxService {
final userData =
await client
.from('warga_desa')
.select('nomor_telepon, no_telepon, phone')
.select('no_hp')
.eq('user_id', user.id)
.maybeSingle();
// Jika berhasil mendapatkan data, cek beberapa kemungkinan nama kolom
if (userData != null) {
if (userData.containsKey('nomor_telepon')) {
final phone = userData['nomor_telepon']?.toString();
if (phone != null && phone.isNotEmpty) return phone;
}
if (userData.containsKey('no_telepon')) {
final phone = userData['no_telepon']?.toString();
if (phone != null && phone.isNotEmpty) return phone;
}
if (userData.containsKey('phone')) {
final phone = userData['phone']?.toString();
if (userData.containsKey('no_hp')) {
final phone = userData['no_hp']?.toString();
if (phone != null && phone.isNotEmpty) return phone;
}
}
// Fallback ke data dari Supabase Auth
final userMetadata = user.userMetadata;
if (userMetadata != null) {
@ -496,6 +504,146 @@ class AuthProvider extends GetxService {
}
}
// Metode untuk mendapatkan tanggal lahir dari tabel warga_desa berdasarkan user_id
Future<String?> getUserTanggalLahir() async {
final user = currentUser;
if (user == null) {
debugPrint('No current user found when getting tanggal_lahir');
return null;
}
try {
debugPrint('Fetching tanggal_lahir for user_id: ${user.id}');
// Coba ambil tanggal lahir dari tabel warga_desa
final userData =
await client
.from('warga_desa')
.select('tanggal_lahir')
.eq('user_id', user.id)
.maybeSingle();
// Jika berhasil mendapatkan data
if (userData != null && userData.containsKey('tanggal_lahir')) {
final tanggalLahir = userData['tanggal_lahir']?.toString();
if (tanggalLahir != null && tanggalLahir.isNotEmpty) {
debugPrint('Found tanggal_lahir: $tanggalLahir');
return tanggalLahir;
}
}
return null;
} catch (e) {
debugPrint('Error fetching user tanggal_lahir: $e');
return null;
}
}
// Metode untuk mendapatkan RT/RW dari tabel warga_desa berdasarkan user_id
Future<String?> getUserRtRw() async {
final user = currentUser;
if (user == null) {
debugPrint('No current user found when getting rt_rw');
return null;
}
try {
debugPrint('Fetching rt_rw for user_id: ${user.id}');
// Coba ambil RT/RW dari tabel warga_desa
final userData =
await client
.from('warga_desa')
.select('rt_rw')
.eq('user_id', user.id)
.maybeSingle();
// Jika berhasil mendapatkan data
if (userData != null && userData.containsKey('rt_rw')) {
final rtRw = userData['rt_rw']?.toString();
if (rtRw != null && rtRw.isNotEmpty) {
debugPrint('Found rt_rw: $rtRw');
return rtRw;
}
}
return null;
} catch (e) {
debugPrint('Error fetching user rt_rw: $e');
return null;
}
}
// Metode untuk mendapatkan kelurahan/desa dari tabel warga_desa berdasarkan user_id
Future<String?> getUserKelurahanDesa() async {
final user = currentUser;
if (user == null) {
debugPrint('No current user found when getting kelurahan_desa');
return null;
}
try {
debugPrint('Fetching kelurahan_desa for user_id: ${user.id}');
// Coba ambil kelurahan/desa dari tabel warga_desa
final userData =
await client
.from('warga_desa')
.select('kelurahan_desa')
.eq('user_id', user.id)
.maybeSingle();
// Jika berhasil mendapatkan data
if (userData != null && userData.containsKey('kelurahan_desa')) {
final kelurahanDesa = userData['kelurahan_desa']?.toString();
if (kelurahanDesa != null && kelurahanDesa.isNotEmpty) {
debugPrint('Found kelurahan_desa: $kelurahanDesa');
return kelurahanDesa;
}
}
return null;
} catch (e) {
debugPrint('Error fetching user kelurahan_desa: $e');
return null;
}
}
// Metode untuk mendapatkan kecamatan dari tabel warga_desa berdasarkan user_id
Future<String?> getUserKecamatan() async {
final user = currentUser;
if (user == null) {
debugPrint('No current user found when getting kecamatan');
return null;
}
try {
debugPrint('Fetching kecamatan for user_id: ${user.id}');
// Coba ambil kecamatan dari tabel warga_desa
final userData =
await client
.from('warga_desa')
.select('kecamatan')
.eq('user_id', user.id)
.maybeSingle();
// Jika berhasil mendapatkan data
if (userData != null && userData.containsKey('kecamatan')) {
final kecamatan = userData['kecamatan']?.toString();
if (kecamatan != null && kecamatan.isNotEmpty) {
debugPrint('Found kecamatan: $kecamatan');
return kecamatan;
}
}
return null;
} catch (e) {
debugPrint('Error fetching user kecamatan: $e');
return null;
}
}
// Mendapatkan data sewa_aset berdasarkan status (misal: MENUNGGU PEMBAYARAN, PEMBAYARANAN DENDA)
Future<List<Map<String, dynamic>>> getSewaAsetByStatus(
List<String> statuses,
@ -507,28 +655,97 @@ class AuthProvider extends GetxService {
}
try {
debugPrint(
'Fetching sewa_aset for user_id: \\${user.id} with statuses: \\${statuses.join(', ')}',
'Fetching sewa_aset for user_id: ${user.id} with statuses: ${statuses.join(', ')}',
);
// Supabase expects the IN filter as a comma-separated string in parentheses
final statusString = '(${statuses.map((s) => '"$s"').join(',')})';
// Get sewa_aset records filtered by user_id and status
final response = await client
.from('sewa_aset')
.select('*')
.eq('user_id', user.id)
.filter('status', 'in', statusString);
debugPrint('Fetched sewa_aset count: \\${response.length}');
// Pastikan response adalah List
.filter('status', 'in', statusString)
.order('created_at', ascending: false);
debugPrint('Fetched sewa_aset count: ${response.length}');
// Process the response to handle package data
if (response is List) {
return response
.map<Map<String, dynamic>>(
(item) => Map<String, dynamic>.from(item),
)
.toList();
final List<Map<String, dynamic>> processedResponse = [];
for (var item in response) {
final Map<String, dynamic> processedItem = Map<String, dynamic>.from(
item,
);
// Ensure updated_at is not null, use created_at as fallback
if (processedItem['updated_at'] == null &&
processedItem['created_at'] != null) {
debugPrint('updated_at is null, using created_at as fallback');
processedItem['updated_at'] = processedItem['created_at'];
} else if (processedItem['updated_at'] == null &&
processedItem['created_at'] == null) {
// If both are null, use current timestamp as last resort
debugPrint(
'Both updated_at and created_at are null, using current timestamp',
);
processedItem['updated_at'] = DateTime.now().toIso8601String();
}
// Debug the updated_at field
debugPrint(
'updated_at after processing: ${processedItem['updated_at']}',
);
// If aset_id is null and paket_id is not null, fetch package data
if (item['aset_id'] == null && item['paket_id'] != null) {
final String paketId = item['paket_id'];
debugPrint(
'Found rental with paket_id: $paketId, fetching package details',
);
try {
// Get package name from paket table
final paketResponse =
await client
.from('paket')
.select('nama')
.eq('id', paketId)
.maybeSingle();
if (paketResponse != null && paketResponse['nama'] != null) {
processedItem['nama_paket'] = paketResponse['nama'];
debugPrint('Found package name: ${paketResponse['nama']}');
}
// Get package photo from foto_aset table
final fotoResponse =
await client
.from('foto_aset')
.select('foto_aset')
.eq('id_paket', paketId)
.limit(1)
.maybeSingle();
if (fotoResponse != null && fotoResponse['foto_aset'] != null) {
processedItem['foto_paket'] = fotoResponse['foto_aset'];
debugPrint('Found package photo: ${fotoResponse['foto_aset']}');
}
} catch (e) {
debugPrint('Error fetching package details: $e');
}
}
processedResponse.add(processedItem);
}
return processedResponse;
} else {
return [];
}
} catch (e) {
debugPrint('Error fetching sewa_aset by status: \\${e.toString()}');
debugPrint('Error fetching sewa_aset by status: ${e.toString()}');
return [];
}
}

View File

@ -9,6 +9,16 @@ class PesananProvider {
final SupabaseClient _supabase = Supabase.instance.client;
final _tableName = 'pesanan';
// Method to clear any cached data
void clearCache() {
print('Clearing PesananProvider cached data');
// Clear any cached order data or state
// This is useful when logging out to ensure no user data remains in memory
// Note: Since this provider doesn't currently maintain any persistent cache variables,
// this method serves as a placeholder for future cache implementations
}
Future<List<PesananModel>> getPesananByUserId(String userId) async {
try {
final response = await _supabase

10
lib/app/main.dart Normal file
View File

@ -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());
}

View File

@ -2,12 +2,18 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../data/providers/auth_provider.dart';
import '../../../routes/app_routes.dart';
import 'dart:math';
import '../../../modules/warga/controllers/warga_dashboard_controller.dart';
class AuthController extends GetxController {
final AuthProvider _authProvider = Get.find<AuthProvider>();
final emailController = TextEditingController();
final passwordController = TextEditingController();
final formKey = GlobalKey<FormState>();
final nameController = TextEditingController();
final confirmPasswordController = TextEditingController();
final RxBool isConfirmPasswordVisible = false.obs;
// Form fields for registration
final RxString email = ''.obs;
@ -15,6 +21,11 @@ class AuthController extends GetxController {
final RxString nik = ''.obs;
final RxString phoneNumber = ''.obs;
final RxString selectedRole = 'WARGA'.obs; // Default role
final RxString alamatLengkap = ''.obs;
final Rx<DateTime?> tanggalLahir = Rx<DateTime?>(null);
final RxString rtRw = ''.obs;
final RxString kelurahan = ''.obs;
final RxString kecamatan = ''.obs;
// Form status
final RxBool isLoading = false.obs;
@ -28,6 +39,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) {
@ -87,7 +102,7 @@ class AuthController extends GetxController {
// Navigate based on role name
if (roleName == null) {
_navigateToWargaDashboard(); // Default to warga if role name not found
await _checkWargaStatusAndNavigate(); // Default to warga if role name not found
return;
}
@ -96,6 +111,9 @@ class AuthController extends GetxController {
_navigateToPetugasBumdesDashboard();
break;
case 'WARGA':
// For WARGA role, check account status in warga_desa table
await _checkWargaStatusAndNavigate();
break;
default:
_navigateToWargaDashboard();
break;
@ -105,12 +123,72 @@ class AuthController extends GetxController {
}
}
// Check warga status in warga_desa table and navigate accordingly
Future<void> _checkWargaStatusAndNavigate() async {
try {
final user = _authProvider.currentUser;
if (user == null) {
errorMessage.value = 'Tidak dapat memperoleh data pengguna';
return;
}
// Get user data from warga_desa table
final userData =
await _authProvider.client
.from('warga_desa')
.select('status, keterangan')
.eq('user_id', user.id)
.maybeSingle();
if (userData == null) {
errorMessage.value = 'Data pengguna tidak ditemukan';
return;
}
final status = userData['status'] as String?;
switch (status?.toLowerCase()) {
case 'active':
// Allow login for active users
_navigateToWargaDashboard();
break;
case 'suspended':
// Show error for suspended users
final keterangan =
userData['keterangan'] as String? ?? 'Tidak ada keterangan';
errorMessage.value =
'Akun Anda dinonaktifkan oleh petugas. Keterangan: $keterangan';
// Sign out the user
await _authProvider.signOut();
break;
case 'pending':
// Show error for pending users
errorMessage.value =
'Akun Anda sedang dalam proses verifikasi. Silakan tunggu hingga verifikasi selesai.';
// Sign out the user
await _authProvider.signOut();
break;
default:
errorMessage.value = 'Status akun tidak valid';
// Sign out the user
await _authProvider.signOut();
break;
}
} catch (e) {
errorMessage.value = 'Gagal memeriksa status akun: ${e.toString()}';
// Sign out the user on error
await _authProvider.signOut();
}
}
void _navigateToPetugasBumdesDashboard() {
Get.offAllNamed(Routes.PETUGAS_BUMDES_DASHBOARD);
}
void _navigateToWargaDashboard() {
Get.offAllNamed(Routes.WARGA_DASHBOARD);
// Navigate to warga dashboard with parameter to indicate it's coming from login
// This will trigger an immediate refresh of the data
Get.offAllNamed(Routes.WARGA_DASHBOARD, arguments: {'from_login': true});
}
void forgotPassword() async {
@ -140,7 +218,7 @@ class AuthController extends GetxController {
Get.snackbar(
'Berhasil',
'Link reset password telah dikirim ke email Anda',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.green[100],
colorText: Colors.green[800],
icon: const Icon(Icons.check_circle, color: Colors.green),
@ -172,63 +250,87 @@ class AuthController extends GetxController {
void onClose() {
emailController.dispose();
passwordController.dispose();
nameController.dispose();
confirmPasswordController.dispose();
super.onClose();
}
// Register user implementation
Future<void> registerUser() async {
// Validate all required fields
if (email.value.isEmpty ||
password.value.isEmpty ||
nik.value.isEmpty ||
phoneNumber.value.isEmpty) {
errorMessage.value = 'Semua field harus diisi';
// Clear previous error messages
errorMessage.value = '';
// Validate form fields
if (!formKey.currentState!.validate()) {
return;
}
// Basic validation for email
if (!GetUtils.isEmail(email.value.trim())) {
errorMessage.value = 'Format email tidak valid';
return;
}
// Basic validation for password
if (password.value.length < 6) {
errorMessage.value = 'Password minimal 6 karakter';
return;
}
// Basic validation for NIK
if (nik.value.length != 16) {
errorMessage.value = 'NIK harus 16 digit';
return;
}
// Basic validation for phone number
if (!phoneNumber.value.startsWith('08') || phoneNumber.value.length < 10) {
errorMessage.value =
'Nomor HP tidak valid (harus diawali 08 dan minimal 10 digit)';
// Validate date of birth separately (since it's not a standard form field)
if (!validateDateOfBirth()) {
return;
}
try {
isLoading.value = true;
errorMessage.value = '';
// Create user with Supabase
final response = await _authProvider.signUp(
// Format tanggal lahir to string (YYYY-MM-DD)
final formattedTanggalLahir =
tanggalLahir.value != null
? '${tanggalLahir.value!.year}-${tanggalLahir.value!.month.toString().padLeft(2, '0')}-${tanggalLahir.value!.day.toString().padLeft(2, '0')}'
: '';
// Generate register_id with format REG-YYYY-1234567
final currentYear = DateTime.now().year.toString();
final randomDigits = _generateRandomDigits(7); // Generate 7 random digits
final registerId = 'REG-$currentYear-$randomDigits';
// 1. Register user with Supabase Auth and add role_id to metadata
final response = await _authProvider.client.auth.signUp(
email: email.value.trim(),
password: password.value,
data: {
'nik': nik.value.trim(),
'phone_number': phoneNumber.value.trim(),
'role': selectedRole.value,
'role_id':
'bb5360d5-8fd0-404e-8f6f-71ec4d8ad0ae', // Fixed role_id for WARGA
},
);
// Check if registration was successful
if (response.user != null) {
// Registration successful
Get.offNamed(Routes.REGISTRATION_SUCCESS);
// 2. Get the UID from the created auth user
final userId = response.user!.id;
// 3. Insert user data into the warga_desa table
await _authProvider.client.from('warga_desa').insert({
'user_id': userId,
'email': email.value.trim(),
'nama_lengkap': nameController.text.trim(),
'nik': nik.value.trim(),
'status': 'pending',
'tanggal_lahir': formattedTanggalLahir,
'no_hp': phoneNumber.value.trim(),
'rt_rw': rtRw.value.trim(),
'kelurahan_desa': kelurahan.value.trim(),
'kecamatan': kecamatan.value.trim(),
'alamat': alamatLengkap.value.trim(),
'register_id': registerId, // Add register_id to the warga_desa table
});
// Reset registration fields BEFORE navigation to ensure clean state
resetRegistrationFields();
// Bersihkan data auth provider untuk memastikan tidak ada data user yang tersimpan
_authProvider.clearAuthData();
// Print debug message
print(
'Registration successful: Fields and controllers have been cleared',
);
// Registration successful - navigate to success page
Get.offNamed(
Routes.REGISTRATION_SUCCESS,
arguments: {'register_id': registerId},
);
} else {
errorMessage.value = 'Gagal mendaftar. Silakan coba lagi.';
}
@ -239,4 +341,250 @@ class AuthController extends GetxController {
isLoading.value = false;
}
}
// Generate random digits of specified length
String _generateRandomDigits(int length) {
final random = Random();
final buffer = StringBuffer();
for (var i = 0; i < length; i++) {
buffer.write(random.nextInt(10));
}
return buffer.toString();
}
// Validation methods
String? validateEmail(String? value) {
if (value == null || value.isEmpty) {
return 'Email tidak boleh kosong';
}
if (!GetUtils.isEmail(value)) {
return 'Format email tidak valid';
}
return null;
}
String? validatePassword(String? value) {
if (value == null || value.isEmpty) {
return 'Password tidak boleh kosong';
}
if (value.length < 8) {
return 'Password minimal 8 karakter';
}
if (!value.contains(RegExp(r'[A-Z]'))) {
return 'Password harus memiliki minimal 1 huruf besar';
}
if (!value.contains(RegExp(r'[0-9]'))) {
return 'Password harus memiliki minimal 1 angka';
}
return null;
}
String? validateConfirmPassword(String? value) {
if (value == null || value.isEmpty) {
return 'Konfirmasi password tidak boleh kosong';
}
if (value != password.value) {
return 'Password tidak cocok';
}
return null;
}
String? validateName(String? value) {
if (value == null || value.isEmpty) {
return 'Nama lengkap tidak boleh kosong';
}
if (value.length < 3) {
return 'Nama lengkap minimal 3 karakter';
}
if (!RegExp(r"^[a-zA-Z\s\.]+$").hasMatch(value)) {
return 'Nama hanya boleh berisi huruf, spasi, titik, dan apostrof';
}
return null;
}
String? validateNIK(String? value) {
if (value == null || value.isEmpty) {
return 'NIK tidak boleh kosong';
}
if (value.length != 16) {
return 'NIK harus 16 digit';
}
if (!RegExp(r'^[0-9]+$').hasMatch(value)) {
return 'NIK hanya boleh berisi angka';
}
return null;
}
String? validatePhone(String? value) {
if (value == null || value.isEmpty) {
return 'No HP tidak boleh kosong';
}
if (!value.startsWith('08')) {
return 'Nomor HP harus diawali dengan 08';
}
if (value.length < 10 || value.length > 13) {
return 'Nomor HP harus antara 10-13 digit';
}
if (!RegExp(r'^[0-9]+$').hasMatch(value)) {
return 'Nomor HP hanya boleh berisi angka';
}
return null;
}
String? validateRTRW(String? value) {
if (value == null || value.isEmpty) {
return 'RT/RW tidak boleh kosong';
}
if (!RegExp(r'^\d{1,3}\/\d{1,3}$').hasMatch(value)) {
return 'Format RT/RW tidak valid (contoh: 001/002)';
}
return null;
}
String? validateKelurahan(String? value) {
if (value == null || value.isEmpty) {
return 'Kelurahan/Desa tidak boleh kosong';
}
if (value.length < 3) {
return 'Kelurahan/Desa minimal 3 karakter';
}
return null;
}
String? validateKecamatan(String? value) {
if (value == null || value.isEmpty) {
return 'Kecamatan tidak boleh kosong';
}
if (value.length < 3) {
return 'Kecamatan minimal 3 karakter';
}
return null;
}
String? validateAlamat(String? value) {
if (value == null || value.isEmpty) {
return 'Alamat lengkap tidak boleh kosong';
}
if (value.length < 5) {
return 'Alamat terlalu pendek, minimal 5 karakter';
}
return null;
}
bool validateDateOfBirth() {
if (tanggalLahir.value == null) {
errorMessage.value = 'Tanggal lahir harus diisi';
return false;
}
// Check if user is at least 17 years old
final DateTime today = DateTime.now();
final DateTime minimumAge = DateTime(
today.year - 17,
today.month,
today.day,
);
if (tanggalLahir.value!.isAfter(minimumAge)) {
errorMessage.value = 'Anda harus berusia minimal 17 tahun';
return false;
}
return true;
}
// Check registration status by register_id and email
Future<Map<String, dynamic>?> checkRegistrationStatus(
String registerId,
String email,
) async {
try {
isLoading.value = true;
print('Checking registration status - ID: $registerId, Email: $email');
// Validasi input
if (registerId.isEmpty || email.isEmpty) {
print('Invalid input: registerId or email is empty');
return null;
}
// Query warga_desa table where register_id and email match
final response =
await _authProvider.client
.from('warga_desa')
.select(
'*',
) // Ensure we select all columns including 'keterangan'
.eq('register_id', registerId)
.eq('email', email)
.maybeSingle();
// Log response for debugging
print('Registration status query response: $response');
// Validasi hasil query
if (response == null) {
print('No matching registration found');
return null;
}
if (response is Map<String, dynamic> && response.isEmpty) {
print('Empty response received');
return null;
}
// Jika berhasil, kembalikan data
print('Registration found with status: ${response['status']}');
return response;
} catch (e) {
print('Error checking registration status: ${e.toString()}');
return null;
} finally {
isLoading.value = false;
}
}
// Reset all registration fields
void resetRegistrationFields() {
// Reset text controllers
emailController.clear();
passwordController.clear();
nameController.clear();
confirmPasswordController.clear();
// Reset form fields
email.value = '';
password.value = '';
nik.value = '';
phoneNumber.value = '';
selectedRole.value = 'WARGA'; // Reset to default role
alamatLengkap.value = '';
tanggalLahir.value = null;
rtRw.value = '';
kelurahan.value = '';
kecamatan.value = '';
// Reset form status
isPasswordVisible.value = false;
isConfirmPasswordVisible.value = false;
errorMessage.value = '';
// Reset form key if needed
if (formKey.currentState != null) {
formKey.currentState!.reset();
}
// Bersihkan WargaDashboardController jika terdaftar
try {
if (Get.isRegistered<WargaDashboardController>()) {
print(
'Removing WargaDashboardController to ensure clean state after registration',
);
Get.delete<WargaDashboardController>(force: true);
}
} catch (e) {
print('Error removing WargaDashboardController: $e');
}
}
}

View File

@ -26,12 +26,8 @@ class ForgotPasswordView extends GetView<AuthController> {
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
),
),
),

View File

@ -9,6 +9,7 @@ class LoginView extends GetView<AuthController> {
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
body: Stack(
children: [
// Background gradient
@ -30,12 +31,8 @@ class LoginView extends GetView<AuthController> {
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
),
),
),
@ -76,20 +73,19 @@ class LoginView extends GetView<AuthController> {
),
),
// Main content
// Main content with keyboard avoidance
SafeArea(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
physics: const ClampingScrollPhysics(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 50),
SizedBox(height: MediaQuery.of(context).size.height * 0.05),
_buildHeader(),
const SizedBox(height: 40),
SizedBox(height: MediaQuery.of(context).size.height * 0.03),
_buildLoginCard(),
const SizedBox(height: 24),
_buildRegisterLink(),
const SizedBox(height: 30),
],
@ -108,12 +104,12 @@ class LoginView extends GetView<AuthController> {
tag: 'logo',
child: Image.asset(
'assets/images/logo.png',
width: 220,
height: 220,
width: 180,
height: 180,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.apartment_rounded,
size: 180,
size: 150,
color: AppColors.primary,
);
},
@ -128,7 +124,7 @@ class LoginView extends GetView<AuthController> {
shadowColor: AppColors.shadow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.all(28.0),
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -150,7 +146,7 @@ class LoginView extends GetView<AuthController> {
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: 32),
const SizedBox(height: 24),
// Email field
_buildInputLabel('Email'),
@ -161,7 +157,7 @@ class LoginView extends GetView<AuthController> {
prefixIcon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 24),
const SizedBox(height: 12),
// Password field
_buildInputLabel('Password'),
@ -204,13 +200,12 @@ class LoginView extends GetView<AuthController> {
),
),
),
const SizedBox(height: 32),
// Login button
Obx(
() => SizedBox(
width: double.infinity,
height: 56,
height: 50, // Slightly smaller height
child: ElevatedButton(
onPressed:
controller.isLoading.value ? null : controller.login,
@ -315,6 +310,16 @@ class LoginView extends GetView<AuthController> {
keyboardType: keyboardType,
obscureText: obscureText,
style: TextStyle(fontSize: 16, color: AppColors.textPrimary),
textInputAction:
keyboardType == TextInputType.emailAddress
? TextInputAction.next
: TextInputAction.done,
scrollPhysics: const ClampingScrollPhysics(),
onChanged: (_) {
if (controller.text.isNotEmpty) {
this.controller.errorMessage.value = '';
}
},
decoration: InputDecoration(
hintText: hintText,
hintStyle: TextStyle(color: AppColors.textLight),

View File

@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../../../theme/app_colors.dart';
import 'package:flutter/rendering.dart';
import '../../../routes/app_routes.dart';
class RegistrationSuccessView extends StatefulWidget {
const RegistrationSuccessView({Key? key}) : super(key: key);
@ -15,10 +18,17 @@ class _RegistrationSuccessViewState extends State<RegistrationSuccessView>
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _fadeAnimation;
String? registerId;
@override
void initState() {
super.initState();
// Get the registration ID from arguments
if (Get.arguments != null && Get.arguments is Map) {
registerId = Get.arguments['register_id'] as String?;
}
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
@ -215,7 +225,7 @@ class _RegistrationSuccessViewState extends State<RegistrationSuccessView>
Container(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
'Akun Anda telah berhasil terdaftar. Silakan masuk dengan email dan password yang telah Anda daftarkan.',
'Akun Anda telah berhasil terdaftar. Silahkan tunggu petugas untuk melakukan verifikasi data diri anda.',
style: TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
@ -224,6 +234,84 @@ class _RegistrationSuccessViewState extends State<RegistrationSuccessView>
textAlign: TextAlign.center,
),
),
if (registerId != null) ...[
const SizedBox(height: 24),
Text(
'Kode Registrasi:',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
decoration: BoxDecoration(
color: AppColors.successLight,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.success.withOpacity(0.5),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
registerId!,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.success,
letterSpacing: 1,
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
Icons.copy,
size: 20,
color: AppColors.success,
),
onPressed: () {
// Copy to clipboard
final data = ClipboardData(text: registerId!);
Clipboard.setData(data);
Get.snackbar(
'Berhasil Disalin',
'Kode registrasi telah disalin ke clipboard',
snackPosition: SnackPosition.TOP,
backgroundColor: AppColors.successLight,
colorText: AppColors.success,
margin: const EdgeInsets.all(16),
);
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
),
],
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
'Simpan kode registrasi ini untuk memeriksa status pendaftaran Anda.',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
),
],
],
),
);
@ -242,7 +330,7 @@ class _RegistrationSuccessViewState extends State<RegistrationSuccessView>
child: ElevatedButton(
onPressed: () {
// Navigate back to login page
Get.offAllNamed('/login');
Get.offNamed(Routes.LOGIN);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,16 @@
import 'package:get/get.dart';
import '../controllers/petugas_akun_bank_controller.dart';
import '../../../data/providers/aset_provider.dart';
class PetugasAkunBankBinding extends Bindings {
@override
void dependencies() {
// Register AsetProvider if not already registered
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
// Register PetugasAkunBankController
Get.lazyPut<PetugasAkunBankController>(() => PetugasAkunBankController());
}
}

View File

@ -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>(() => AsetProvider());
Get.lazyPut<PetugasAsetController>(() => PetugasAsetController());
}
}

View File

@ -0,0 +1,11 @@
import 'package:get/get.dart';
import '../controllers/petugas_detail_penyewa_controller.dart';
class PetugasDetailPenyewaBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<PetugasDetailPenyewaController>(
() => PetugasDetailPenyewaController(),
);
}
}

View File

@ -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<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
// Memastikan controller sudah tersedia
Get.lazyPut<PetugasSewaController>(
() => PetugasSewaController(),

View File

@ -0,0 +1,16 @@
import 'package:get/get.dart';
import '../controllers/petugas_laporan_controller.dart';
import '../../../data/providers/aset_provider.dart';
class PetugasLaporanBinding extends Bindings {
@override
void dependencies() {
// Register AsetProvider if not already registered
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
// Register PetugasLaporanController
Get.lazyPut<PetugasLaporanController>(() => PetugasLaporanController());
}
}

View File

@ -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<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
// Ensure dashboard controller is registered
if (!Get.isRegistered<PetugasBumdesDashboardController>()) {
Get.put(PetugasBumdesDashboardController(), permanent: true);
}
Get.lazyPut<PetugasPaketController>(() => PetugasPaketController());
// Register the controller
Get.lazyPut<PetugasPaketController>(
() => PetugasPaketController(),
fenix: true,
);
}
}

View File

@ -0,0 +1,12 @@
import 'package:get/get.dart';
import '../controllers/petugas_penyewa_controller.dart';
class PetugasPenyewaBinding extends Bindings {
@override
void dependencies() {
Get.put<PetugasPenyewaController>(
PetugasPenyewaController(),
permanent: true,
);
}
}

View File

@ -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<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
Get.lazyPut<PetugasSewaController>(() => PetugasSewaController());
}
}

View File

@ -49,7 +49,7 @@ class ListPetugasMitraController extends GetxController {
Get.snackbar(
'Sukses',
'Petugas mitra berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
@ -62,7 +62,7 @@ class ListPetugasMitraController extends GetxController {
Get.snackbar(
'Sukses',
'Data petugas mitra berhasil diperbarui',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
}
@ -73,7 +73,7 @@ class ListPetugasMitraController extends GetxController {
Get.snackbar(
'Sukses',
'Petugas mitra berhasil dihapus',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
@ -86,7 +86,7 @@ class ListPetugasMitraController extends GetxController {
Get.snackbar(
'Status Diperbarui',
'Status petugas mitra diubah menjadi ${!currentStatus ? 'Aktif' : 'Nonaktif'}',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
}

View File

@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../data/providers/aset_provider.dart';
class PetugasAkunBankController extends GetxController {
final AsetProvider asetProvider = Get.find<AsetProvider>();
// Observable variables
final isLoading = true.obs;
final bankAccounts = <Map<String, dynamic>>[].obs;
final errorMessage = ''.obs;
@override
void onInit() {
super.onInit();
loadBankAccounts();
}
// Load bank accounts from the database
Future<void> loadBankAccounts() async {
try {
isLoading.value = true;
errorMessage.value = '';
debugPrint('🏦 Loading bank accounts...');
// Fetch all bank accounts from the database
final response = await asetProvider.client
.from('akun_bank')
.select('id, nama_bank, nama_akun, no_rekening')
.order('nama_bank', ascending: true);
if (response is List) {
bankAccounts.value = List<Map<String, dynamic>>.from(response);
debugPrint('✅ Loaded ${bankAccounts.length} bank accounts');
} else {
bankAccounts.value = [];
errorMessage.value = 'Failed to load bank accounts';
debugPrint('❌ Failed to load bank accounts: Invalid response format');
}
} catch (e) {
errorMessage.value = 'Error loading bank accounts: $e';
debugPrint('❌ Error loading bank accounts: $e');
} finally {
isLoading.value = false;
}
}
// Add a new bank account
Future<bool> addBankAccount(Map<String, dynamic> accountData) async {
try {
debugPrint('🏦 Adding new bank account: ${accountData['nama_bank']}');
final response = await asetProvider.client
.from('akun_bank')
.insert(accountData)
.select('id');
if (response is List && response.isNotEmpty) {
debugPrint('✅ Bank account added successfully');
await loadBankAccounts(); // Reload the list
return true;
} else {
debugPrint('❌ Failed to add bank account: No response');
return false;
}
} catch (e) {
debugPrint('❌ Error adding bank account: $e');
return false;
}
}
// Update an existing bank account
Future<bool> updateBankAccount(
String id,
Map<String, dynamic> accountData,
) async {
try {
debugPrint('🏦 Updating bank account ID: $id');
final response = await asetProvider.client
.from('akun_bank')
.update(accountData)
.eq('id', id);
debugPrint('✅ Bank account updated successfully');
await loadBankAccounts(); // Reload the list
return true;
} catch (e) {
debugPrint('❌ Error updating bank account: $e');
return false;
}
}
// Delete a bank account
Future<bool> deleteBankAccount(String id) async {
try {
debugPrint('🏦 Deleting bank account ID: $id');
final response = await asetProvider.client
.from('akun_bank')
.delete()
.eq('id', id);
debugPrint('✅ Bank account deleted successfully');
await loadBankAccounts(); // Reload the list
return true;
} catch (e) {
debugPrint('❌ Error deleting bank account: $e');
return false;
}
}
}

View File

@ -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<AsetProvider>();
// Observable lists for asset data
final asetList = <Map<String, dynamic>>[].obs;
final filteredAsetList = <Map<String, dynamic>>[].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<void> 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<Map<String, dynamic>> 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.TOP,
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<void> 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();
}
@ -210,8 +222,63 @@ class PetugasAsetController extends GetxController {
}
// Delete an asset
void deleteAset(String id) {
asetList.removeWhere((aset) => aset['id'] == id);
applyFilters();
Future<bool> deleteAset(String id) async {
try {
debugPrint('🗑️ Starting deletion process for asset ID: $id');
// Show loading indicator
Get.dialog(
const Center(child: CircularProgressIndicator()),
barrierDismissible: false,
);
// Call the provider to delete the asset
final success = await _asetProvider.deleteAset(id);
// Close the loading dialog
Get.back();
if (success) {
// Remove the asset from our local list
asetList.removeWhere((aset) => aset['id'] == id);
// Apply filters to update the UI
applyFilters();
// Show success message
Get.snackbar(
'Sukses',
'Aset berhasil dihapus',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.green,
colorText: Colors.white,
);
return true;
} else {
// Show error message
Get.snackbar(
'Gagal',
'Terjadi kesalahan saat menghapus aset',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return false;
}
} catch (e) {
// Close the loading dialog if still open
if (Get.isDialogOpen ?? false) {
Get.back();
}
// Show error message
Get.snackbar(
'Error',
'Gagal menghapus aset: ${e.toString()}',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return false;
}
}
}

View File

@ -66,7 +66,7 @@ class PetugasBumdesCbpController extends GetxController {
Get.snackbar(
'Error',
'Gagal memuat data. Silakan coba lagi.',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
} finally {
isLoading.value = false;
@ -92,7 +92,7 @@ class PetugasBumdesCbpController extends GetxController {
Get.snackbar(
'Rekening Utama',
'Rekening ${account['bank_name']} telah dijadikan rekening utama',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
}
@ -109,7 +109,7 @@ class PetugasBumdesCbpController extends GetxController {
Get.snackbar(
'Rekening Ditambahkan',
'Rekening bank baru telah berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
@ -125,7 +125,7 @@ class PetugasBumdesCbpController extends GetxController {
Get.snackbar(
'Rekening Diperbarui',
'Informasi rekening bank telah berhasil diperbarui',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
}
@ -138,7 +138,7 @@ class PetugasBumdesCbpController extends GetxController {
Get.snackbar(
'Tidak Dapat Menghapus',
'Rekening utama tidak dapat dihapus. Silakan atur rekening lain sebagai utama terlebih dahulu.',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
return;
}
@ -148,7 +148,7 @@ class PetugasBumdesCbpController extends GetxController {
Get.snackbar(
'Rekening Dihapus',
'Rekening bank telah berhasil dihapus',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
}
@ -164,7 +164,7 @@ class PetugasBumdesCbpController extends GetxController {
Get.snackbar(
'Status Diperbarui',
'Status mitra telah diubah menjadi ${partner['is_active'] ? 'Aktif' : 'Tidak Aktif'}',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
}
@ -181,7 +181,7 @@ class PetugasBumdesCbpController extends GetxController {
Get.snackbar(
'Mitra Ditambahkan',
'Mitra baru telah berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
@ -197,7 +197,7 @@ class PetugasBumdesCbpController extends GetxController {
Get.snackbar(
'Mitra Diperbarui',
'Informasi mitra telah berhasil diperbarui',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
}
@ -210,7 +210,7 @@ class PetugasBumdesCbpController extends GetxController {
Get.snackbar(
'Mitra Dihapus',
'Mitra telah berhasil dihapus',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
}

View File

@ -1,6 +1,14 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../data/providers/auth_provider.dart';
import '../../../routes/app_routes.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:bumrent_app/app/data/providers/auth_provider.dart';
import 'package:bumrent_app/app/data/providers/aset_provider.dart';
import 'package:bumrent_app/app/data/providers/pesanan_provider.dart';
import 'package:bumrent_app/app/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,40 +16,61 @@ 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;
final totalPendapatanBulanLalu = 'Rp 7.200.000'.obs;
final persentaseKenaikan = '18%'.obs;
final totalPendapatanBulanIni = ''.obs;
final totalPendapatanBulanLalu = ''.obs;
final persentaseKenaikan = ''.obs;
final isKenaikanPositif = true.obs;
// Revenue by Category
final pendapatanSewa = 'Rp 5.200.000'.obs;
final persentaseSewa = 100.obs;
final pendapatanSewa = ''.obs;
final persentaseSewa = 0.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 = <double>[].obs; // 6 bulan terakhir
// Status Counters for Sewa Aset
final terlaksanaCount = 5.obs;
final dijadwalkanCount = 1.obs;
final aktifCount = 1.obs;
final dibatalkanCount = 3.obs;
final terlaksanaCount = 0.obs;
final dijadwalkanCount = 0.obs;
final aktifCount = 0.obs;
final dibatalkanCount = 0.obs;
// Additional Sewa Aset Status Counters
final menungguPembayaranCount = 2.obs;
final periksaPembayaranCount = 1.obs;
final diterimaCount = 3.obs;
final pembayaranDendaCount = 1.obs;
final menungguPembayaranCount = 0.obs;
final periksaPembayaranCount = 0.obs;
final diterimaCount = 0.obs;
final pembayaranDendaCount = 0.obs;
final periksaPembayaranDendaCount = 0.obs;
final selesaiCount = 4.obs;
final selesaiCount = 0.obs;
// Status counts for Sewa
final pengajuanSewaCount = 5.obs;
final pemasanganCountSewa = 3.obs;
final sewaAktifCount = 10.obs;
final tagihanAktifCountSewa = 7.obs;
final periksaPembayaranCountSewa = 2.obs;
final pengajuanSewaCount = 0.obs;
final pemasanganCountSewa = 0.obs;
final sewaAktifCount = 0.obs;
final tagihanAktifCountSewa = 0.obs;
final periksaPembayaranCountSewa = 0.obs;
// Tenant (Penyewa) Statistics
final penyewaPendingCount = 0.obs;
final penyewaActiveCount = 0.obs;
final penyewaSuspendedCount = 0.obs;
final penyewaTotalCount = 0.obs;
final isPenyewaStatsLoading = true.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 = <int>[].obs; // 6 bulan terakhir
// Dashboard statistics
final pembayaranStats = <String, dynamic>{}.obs;
final isStatsLoading = true.obs;
@override
void onInit() {
@ -49,36 +78,160 @@ class PetugasBumdesDashboardController extends GetxController {
try {
_authProvider = Get.find<AuthProvider>();
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();
fetchPenyewaStats();
}
// Method to load status counts from backend
// Future<void> 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<void> 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<void> 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<double>.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<void> 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<void> 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 = '';
}
}
Future<void> fetchPenyewaStats() async {
isPenyewaStatsLoading.value = true;
try {
if (_authProvider == null || _authProvider!.client == null) {
print('Auth provider or client is null');
return;
}
final data = await _authProvider!.client
.from('warga_desa')
.select('status, user_id')
.not('user_id', 'is', null);
if (data != null) {
final List<dynamic> penyewaList = data as List<dynamic>;
// Count penyewa by status
penyewaPendingCount.value =
penyewaList
.where(
(p) => p['status']?.toString().toLowerCase() == 'pending',
)
.length;
penyewaActiveCount.value =
penyewaList
.where((p) => p['status']?.toString().toLowerCase() == 'active')
.length;
penyewaSuspendedCount.value =
penyewaList
.where(
(p) => p['status']?.toString().toLowerCase() == 'suspended',
)
.length;
penyewaTotalCount.value = penyewaList.length;
print(
'Penyewa stats - Pending: ${penyewaPendingCount.value}, Active: ${penyewaActiveCount.value}, Suspended: ${penyewaSuspendedCount.value}, Total: ${penyewaTotalCount.value}',
);
}
} catch (e) {
print('Error fetching penyewa stats: $e');
} finally {
isPenyewaStatsLoading.value = false;
}
}
void changeTab(int index) {
try {
@ -102,6 +255,10 @@ class PetugasBumdesDashboardController extends GetxController {
// Navigate to Sewa page
navigateToSewa();
break;
case 4:
// Navigate to Penyewa page
navigateToPenyewa();
break;
}
} catch (e) {
print('Error changing tab: $e');
@ -132,16 +289,66 @@ class PetugasBumdesDashboardController extends GetxController {
}
}
void navigateToPenyewa() {
try {
Get.offAllNamed(Routes.PETUGAS_PENYEWA);
} catch (e) {
print('Error navigating to Penyewa: $e');
}
}
void logout() async {
try {
// Store login route for navigation
final loginRoute = Routes.LOGIN;
// Sign out from Supabase
if (_authProvider != null) {
await _authProvider!.signOut();
}
Get.offAllNamed(Routes.LOGIN);
// Navigate to login screen while context is still valid
Get.offAllNamed(loginRoute);
// Clear auth provider data if available
if (_authProvider != null) {
_authProvider!.clearAuthData();
}
// Clear provider caches
_clearProviderCaches();
// Clean up GetX controllers but keep navigation intact
Get.deleteAll(force: false);
} catch (e) {
print('Error during logout: $e');
// Still try to navigate to login even if sign out fails
Get.offAllNamed(Routes.LOGIN);
}
}
// Helper method to clear provider caches that need explicit clearing
void _clearProviderCaches() {
try {
// Clear AsetProvider cache
if (Get.isRegistered<AsetProvider>()) {
final asetProvider = Get.find<AsetProvider>();
asetProvider.clearCache();
}
} catch (e) {
print('Error clearing AsetProvider: $e');
}
try {
// Clear PesananProvider cache
if (Get.isRegistered<PesananProvider>()) {
final pesananProvider = Get.find<PesananProvider>();
pesananProvider.clearCache();
}
} catch (e) {
print('Error clearing PesananProvider: $e');
}
// Add other providers here that need explicit cache clearing
}
}

View File

@ -0,0 +1,232 @@
import 'package:get/get.dart';
import '../../../data/providers/auth_provider.dart';
class PetugasDetailPenyewaController extends GetxController {
final AuthProvider _authProvider = Get.find<AuthProvider>();
final isLoading = true.obs;
final penyewaDetail = Rx<Map<String, dynamic>>({});
final sewaHistory = <Map<String, dynamic>>[].obs;
final userId = ''.obs;
@override
void onInit() {
super.onInit();
if (Get.arguments != null && Get.arguments['userId'] != null) {
userId.value = Get.arguments['userId'];
fetchPenyewaDetail();
fetchSewaHistory();
} else {
Get.back();
Get.snackbar(
'Error',
'Data penyewa tidak ditemukan',
snackPosition: SnackPosition.TOP,
);
}
}
Future<void> fetchPenyewaDetail() async {
try {
isLoading.value = true;
final data =
await _authProvider.client
.from('warga_desa')
.select('*')
.eq('user_id', userId.value)
.single();
if (data != null) {
penyewaDetail.value = Map<String, dynamic>.from(data);
print('Penyewa detail fetched: ${penyewaDetail.value}');
}
} catch (e) {
print('Error fetching penyewa detail: $e');
Get.snackbar(
'Error',
'Gagal memuat data penyewa',
snackPosition: SnackPosition.TOP,
);
} finally {
isLoading.value = false;
}
}
Future<void> fetchSewaHistory() async {
try {
final data = await _authProvider.client
.from('sewa_aset')
.select('*')
.eq('user_id', userId.value)
.order('created_at', ascending: false);
if (data != null) {
sewaHistory.value = List<Map<String, dynamic>>.from(data);
print('Sewa history fetched: ${sewaHistory.length} items');
// Process data for each item
for (int i = 0; i < sewaHistory.length; i++) {
final item = sewaHistory[i];
// Fetch tagihan data for this sewa_aset
try {
final tagihanResponse =
await _authProvider.client
.from('tagihan_sewa')
.select('tagihan_awal, denda, tagihan_dibayar')
.eq('sewa_aset_id', item['id'])
.maybeSingle();
if (tagihanResponse != null) {
// Add tagihan data to the item
sewaHistory[i] = {...item, 'tagihan_sewa': tagihanResponse};
}
} catch (e) {
print('Error fetching tagihan for sewa_aset ${item['id']}: $e');
}
// Get the updated item after adding tagihan data
final updatedItem = sewaHistory[i];
// If this is a package rental (aset_id is null and paket_id exists)
if (updatedItem['aset_id'] == null &&
updatedItem['paket_id'] != null) {
final String paketId = updatedItem['paket_id'];
try {
// Get package name from paket table
final paketResponse =
await _authProvider.client
.from('paket')
.select('nama')
.eq('id', paketId)
.maybeSingle();
// Get package photo from foto_aset table
final fotoResponse =
await _authProvider.client
.from('foto_aset')
.select('foto_aset')
.eq('id_paket', paketId)
.limit(1)
.maybeSingle();
// Create a synthetic aset object for paket
Map<String, dynamic> syntheticAset = {};
if (paketResponse != null) {
syntheticAset['nama'] = paketResponse['nama'] ?? 'Paket';
}
if (fotoResponse != null) {
syntheticAset['foto_utama'] = fotoResponse['foto_aset'];
}
// Update the item with the synthetic aset
sewaHistory[i] = {
...updatedItem,
'aset': syntheticAset,
'tipe_pesanan': 'paket',
};
} catch (e) {
print('Error fetching package details: $e');
}
}
// If this is an asset rental (aset_id exists and paket_id is null)
else if (updatedItem['aset_id'] != null &&
updatedItem['paket_id'] == null) {
final String asetId = updatedItem['aset_id'];
try {
// Get asset name from aset table
final asetResponse =
await _authProvider.client
.from('aset')
.select('nama')
.eq('id', asetId)
.maybeSingle();
// Get asset photo from foto_aset table using id_aset
final fotoResponse =
await _authProvider.client
.from('foto_aset')
.select('foto_aset')
.eq('id_aset', asetId)
.limit(1)
.maybeSingle();
// Create aset object for individual asset
Map<String, dynamic> asetData = {};
if (asetResponse != null) {
asetData['nama'] = asetResponse['nama'] ?? 'Aset';
}
if (fotoResponse != null) {
asetData['foto_utama'] = fotoResponse['foto_aset'];
}
// Update the item with the aset data
sewaHistory[i] = {
...updatedItem,
'aset': asetData,
'tipe_pesanan': 'aset',
};
} catch (e) {
print('Error fetching asset details: $e');
}
}
}
}
} catch (e) {
print('Error fetching sewa history: $e');
}
}
Future<void> updatePenyewaStatus(String status, String keterangan) async {
try {
isLoading.value = true;
await _authProvider.client
.from('warga_desa')
.update({
'status': status,
'keterangan': keterangan,
'updated_at': DateTime.now().toIso8601String(),
})
.eq('user_id', userId.value);
// Refresh data
await fetchPenyewaDetail();
Get.snackbar(
'Berhasil',
'Status penyewa berhasil diperbarui',
snackPosition: SnackPosition.TOP,
);
} catch (e) {
print('Error updating penyewa status: $e');
Get.snackbar(
'Error',
'Gagal memperbarui status penyewa',
snackPosition: SnackPosition.TOP,
);
} finally {
isLoading.value = false;
}
}
String getStatusLabel(String? status) {
switch (status?.toLowerCase()) {
case 'active':
return 'Aktif';
case 'pending':
return 'Menunggu Verifikasi';
case 'suspended':
return 'Dinonaktifkan';
default:
return 'Tidak diketahui';
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -79,7 +79,7 @@ class PetugasManajemenBumdesController extends GetxController {
Get.snackbar(
'Sukses',
'Rekening utama berhasil diubah',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
@ -93,7 +93,7 @@ class PetugasManajemenBumdesController extends GetxController {
Get.snackbar(
'Sukses',
'Status mitra berhasil diubah',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
@ -110,7 +110,7 @@ class PetugasManajemenBumdesController extends GetxController {
Get.snackbar(
'Sukses',
'Rekening bank berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
@ -124,7 +124,7 @@ class PetugasManajemenBumdesController extends GetxController {
Get.snackbar(
'Sukses',
'Rekening bank berhasil diperbarui',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
@ -145,7 +145,7 @@ class PetugasManajemenBumdesController extends GetxController {
Get.snackbar(
'Sukses',
'Rekening bank berhasil dihapus',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
@ -155,7 +155,7 @@ class PetugasManajemenBumdesController extends GetxController {
Get.snackbar(
'Sukses',
'Mitra berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
@ -166,7 +166,7 @@ class PetugasManajemenBumdesController extends GetxController {
Get.snackbar(
'Sukses',
'Mitra berhasil diperbarui',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
@ -177,7 +177,7 @@ class PetugasManajemenBumdesController extends GetxController {
Get.snackbar(
'Sukses',
'Mitra berhasil dihapus',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
}

View File

@ -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;
// Dependencies
final AsetProvider _asetProvider = Get.find<AsetProvider>();
// Kategori untuk filter
final categories = <String>[
'Semua',
'Pesta',
'Rapat',
'Olahraga',
'Pernikahan',
'Lainnya',
];
// State
final RxBool isLoading = false.obs;
final RxString searchQuery = ''.obs;
final RxString selectedCategory = 'Semua'.obs;
final RxString sortBy = 'Terbaru'.obs;
final RxList<PaketModel> packages = <PaketModel>[].obs;
final RxList<PaketModel> filteredPackages = <PaketModel>[].obs;
// Opsi pengurutan
final sortOptions = <String>[
// Sort options for the dropdown
final List<String> sortOptions = [
'Terbaru',
'Terlama',
'Harga Tertinggi',
@ -27,171 +27,256 @@ class PetugasPaketController extends GetxController {
'Nama Z-A',
];
// Data dummy paket
final paketList = <Map<String, dynamic>>[].obs;
final filteredPaketList = <Map<String, dynamic>>[].obs;
// For backward compatibility
final RxList<Map<String, dynamic>> paketList = <Map<String, dynamic>>[].obs;
final RxList<Map<String, dynamic>> filteredPaketList =
<Map<String, dynamic>>[].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
/// Fetch packages from the API
Future<void> 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.TOP,
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<Map<String, dynamic>> 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<void> 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;
_logger.d(' [loadPaketData] Using fetchPackages() instead');
await fetchPackages();
}
// Filter paket berdasarkan search query dan kategori
/// Filter packages based on search query and category
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(),
);
try {
_logger.d('🔄 [filterPaket] Filtering packages...');
final matchesCategory =
selectedCategory.value == 'Semua' ||
paket['kategori'] == selectedCategory.value;
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(),
);
return matchesQuery && matchesCategory;
}).toList();
// For now, we're not using categories in the new model
// You can add category filtering if needed
final matchesCategory = selectedCategory.value == 'Semua';
// Sort the filtered list
sortFilteredList();
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
/// Sort the filtered list based on the selected sort option
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;
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,
);
}
}
@ -214,40 +299,179 @@ class PetugasPaketController extends GetxController {
}
// Tambah paket baru
void addPaket(Map<String, dynamic> paket) {
paketList.add(paket);
filterPaket();
Get.back();
Get.snackbar(
'Sukses',
'Paket baru berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM,
);
}
Future<void> addPaket(Map<String, dynamic> paketData) async {
try {
isLoading.value = true;
// Edit paket
void editPaket(String id, Map<String, dynamic> updatedPaket) {
final index = paketList.indexWhere((element) => element['id'] == id);
if (index >= 0) {
paketList[index] = updatedPaket;
// 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',
snackPosition: SnackPosition.BOTTOM,
'Paket baru berhasil ditambahkan',
snackPosition: SnackPosition.TOP,
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.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoading.value = false;
}
}
// Edit paket
Future<void> editPaket(String id, Map<String, dynamic> 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.TOP,
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.TOP,
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<void> deletePaket(String id) async {
try {
_logger.i(
'🔄 [deletePaket] Starting deletion process for package ID: $id',
);
// Show a loading dialog
Get.dialog(
const Center(child: CircularProgressIndicator()),
barrierDismissible: false,
);
// Call the provider to delete the package and all related data from Supabase
final success = await _asetProvider.deletePaket(id);
// Close the loading dialog
Get.back();
if (success) {
_logger.i('✅ [deletePaket] Package deleted successfully from database');
// Remove the package from the UI lists
packages.removeWhere((pkg) => pkg.id == id);
_updateLegacyPaketList();
filterPaket();
// Show success message
Get.snackbar(
'Sukses',
'Paket berhasil dihapus dari sistem',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.green,
colorText: Colors.white,
duration: const Duration(seconds: 3),
);
} else {
_logger.e('❌ [deletePaket] Failed to delete package from database');
// Show error message
Get.snackbar(
'Gagal',
'Terjadi kesalahan saat menghapus paket',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
duration: const Duration(seconds: 3),
);
}
} catch (e, stackTrace) {
_logger.e(
'❌ [deletePaket] Error deleting package',
error: e,
stackTrace: stackTrace,
);
// Close the loading dialog if still open
if (Get.isDialogOpen ?? false) {
Get.back();
}
// Show error message
Get.snackbar(
'Error',
'Gagal menghapus paket: ${e.toString()}',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
duration: const Duration(seconds: 3),
);
}
}
/// Format price to Rupiah currency
String formatPrice(num price) {
return 'Rp ${NumberFormat('#,##0', 'id_ID').format(price)}';
}
}

View File

@ -0,0 +1,230 @@
import 'package:get/get.dart';
import '../../../data/providers/auth_provider.dart';
class PetugasPenyewaController extends GetxController {
final AuthProvider _authProvider = Get.find<AuthProvider>();
// Reactive variables
final isLoading = true.obs;
final isRefreshing = false.obs;
final penyewaList = <Map<String, dynamic>>[].obs;
final filteredPenyewaList = <Map<String, dynamic>>[].obs;
final filterStatus = 'all'.obs;
final currentTabIndex = 0.obs;
final searchQuery = ''.obs;
@override
void onInit() {
super.onInit();
fetchPenyewaList();
}
@override
void onReady() {
super.onReady();
// Refresh data when the page is first loaded
refreshData();
}
// Method to refresh data when returning to the page
void refreshData() {
fetchPenyewaList();
}
// Method khusus untuk pull-to-refresh yang tidak menampilkan loading spinner penuh layar
Future<void> refreshPenyewaList() async {
try {
isRefreshing.value = true;
// Get all penyewa data without filtering
final data =
await _authProvider.client
.from('warga_desa')
.select(
'user_id, nama_lengkap, email, nik, no_hp, avatar, status, keterangan',
)
as List<dynamic>;
// Filter out rows where user_id is null
final filteredData = data.where((row) => row['user_id'] != null).toList();
// Get total sewa count for each user
final enrichedData = await _enrichWithSewaCount(filteredData);
penyewaList.value = enrichedData;
// Apply filters to update filteredPenyewaList
applyFilters();
// Tampilkan pesan sukses
} catch (e) {
print('Error refreshing penyewa list: $e');
} finally {
isRefreshing.value = false;
}
}
void changeTab(int index) {
currentTabIndex.value = index;
applyFilters();
}
void updateSearchQuery(String query) {
searchQuery.value = query;
applyFilters();
}
void applyFilters() {
if (penyewaList.isEmpty) return;
// First apply status filter based on current tab
String statusFilter;
switch (currentTabIndex.value) {
case 0: // Verifikasi
statusFilter = 'pending';
break;
case 1: // Aktif
statusFilter = 'active';
break;
case 2: // Ditangguhkan
statusFilter = 'suspended';
break;
default:
statusFilter = 'all';
}
// Filter by status
var result =
statusFilter == 'all'
? penyewaList
: penyewaList
.where((p) => p['status']?.toLowerCase() == statusFilter)
.toList();
// Then apply search filter if there's a query
if (searchQuery.value.isNotEmpty) {
final query = searchQuery.value.toLowerCase();
result =
result
.where(
(p) =>
(p['nama_lengkap']?.toString().toLowerCase().contains(
query,
) ??
false) ||
(p['email']?.toString().toLowerCase().contains(query) ??
false),
)
.toList();
}
filteredPenyewaList.value = result;
}
Future<void> fetchPenyewaList() async {
try {
isLoading.value = true;
// Get all penyewa data without filtering
final data =
await _authProvider.client
.from('warga_desa')
.select(
'user_id, nama_lengkap, email, nik, no_hp, avatar, status, keterangan',
)
as List<dynamic>;
// Filter out rows where user_id is null
final filteredData = data.where((row) => row['user_id'] != null).toList();
// Get total sewa count for each user
final enrichedData = await _enrichWithSewaCount(filteredData);
penyewaList.value = enrichedData;
// Apply filters to update filteredPenyewaList
applyFilters();
} catch (e) {
print('Error fetching penyewa list: $e');
penyewaList.value = [];
filteredPenyewaList.value = [];
} finally {
isLoading.value = false;
}
}
Future<List<Map<String, dynamic>>> _enrichWithSewaCount(
List<dynamic> penyewaData,
) async {
final result = <Map<String, dynamic>>[];
for (var penyewa in penyewaData) {
final userId = penyewa['user_id'];
// Count total sewa for this user
final sewaCount = await _countUserSewa(userId);
// Create a new map with all the original data plus the total_sewa count
final enrichedPenyewa = Map<String, dynamic>.from(penyewa);
enrichedPenyewa['total_sewa'] = sewaCount;
result.add(enrichedPenyewa);
}
return result;
}
Future<int> _countUserSewa(String userId) async {
try {
final response = await _authProvider.client
.from('sewa_aset')
.select('id')
.eq('user_id', userId);
return (response as List).length;
} catch (e) {
print('Error counting sewa for user $userId: $e');
return 0;
}
}
void viewPenyewaDetail(String userId) {
// Navigate to penyewa detail page (to be implemented)
print('View detail for penyewa with ID: $userId');
// Get.toNamed(Routes.PETUGAS_PENYEWA_DETAIL, arguments: {'user_id': userId});
}
void updatePenyewaStatus(
String userId,
String newStatus,
String keterangan,
) async {
try {
isLoading.value = true;
await _authProvider.client
.from('warga_desa')
.update({'status': newStatus, 'keterangan': keterangan})
.eq('user_id', userId);
// Refresh the list
await fetchPenyewaList();
Get.snackbar(
'Berhasil',
'Status penyewa berhasil diperbarui',
snackPosition: SnackPosition.TOP,
);
} catch (e) {
print('Error updating penyewa status: $e');
Get.snackbar(
'Gagal',
'Terjadi kesalahan saat memperbarui status penyewa',
snackPosition: SnackPosition.TOP,
);
} finally {
isLoading.value = false;
}
}
}

View File

@ -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 = <Map<String, dynamic>>[].obs;
final filteredSewaList = <SewaModel>[].obs;
// Filter options
final List<String> 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<Map<String, dynamic>> sewaList = <Map<String, dynamic>>[].obs;
final RxList<SewaModel> sewaList = <SewaModel>[].obs;
// Payment option state (per sewa)
final Map<String, RxBool> isFullPaymentMap = {};
final Map<String, TextEditingController> nominalControllerMap = {};
final Map<String, RxString> paymentMethodMap = {};
@override
void onInit() {
@ -41,26 +50,24 @@ 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());
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 order ID filter if provided
final matchesOrderId =
orderIdQuery.value.isEmpty ||
sewa['order_id'].toString().toLowerCase().contains(
orderIdQuery.value.toLowerCase(),
);
// Apply status filter if not 'Semua'
final matchesStatus =
selectedStatusFilter.value == 'Semua' ||
sewa.status.toUpperCase() ==
selectedStatusFilter.value.toUpperCase();
// Apply status filter if not 'Semua'
final matchesStatus =
selectedStatusFilter.value == 'Semua' ||
sewa['status'] == selectedStatusFilter.value;
return matchesSearch && matchesOrderId && matchesStatus;
}).toList();
return matchesSearch && matchesStatus;
}).toList()
// Sort filtered results by tanggal_pemesanan in descending order (newest first)
..sort((a, b) => b.tanggalPemesanan.compareTo(a.tanggalPemesanan));
}
// Load sewa data (mock data for now)
@ -68,100 +75,10 @@ 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();
// Sort data by tanggal_pemesanan in descending order (newest first)
data.sort((a, b) => b.tanggalPemesanan.compareTo(a.tanggalPemesanan));
sewaList.assignAll(data);
} catch (e) {
print('Error loading sewa data: $e');
} finally {
@ -188,22 +105,27 @@ class PetugasSewaController extends GetxController {
void resetFilters() {
selectedStatusFilter.value = 'Semua';
searchQuery.value = '';
filteredSewaList.value = sewaList;
// Assign a sorted copy of sewaList to filteredSewaList
filteredSewaList.value = List<SewaModel>.from(sewaList)
..sort((a, b) => b.tanggalPemesanan.compareTo(a.tanggalPemesanan));
}
void applyFilters() {
filteredSewaList.value =
sewaList.where((sewa) {
bool matchesStatus =
selectedStatusFilter.value == 'Semua' ||
sewa['status'] == selectedStatusFilter.value;
bool matchesSearch =
searchQuery.value.isEmpty ||
sewa['nama_warga'].toLowerCase().contains(
searchQuery.value.toLowerCase(),
);
return matchesStatus && matchesSearch;
}).toList();
bool matchesStatus =
selectedStatusFilter.value == 'Semua' ||
sewa.status.toUpperCase() ==
selectedStatusFilter.value.toUpperCase();
bool matchesSearch =
searchQuery.value.isEmpty ||
sewa.wargaNama.toLowerCase().contains(
searchQuery.value.toLowerCase(),
);
return matchesStatus && matchesSearch;
}).toList()
// Sort filtered results by tanggal_pemesanan in descending order (newest first)
..sort((a, b) => b.tanggalPemesanan.compareTo(a.tanggalPemesanan));
}
// Format price to rupiah
@ -213,102 +135,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<String, dynamic>.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<String, dynamic>.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<String, dynamic>.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<String, dynamic>.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<String, dynamic>.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<AsetProvider>();
await asetProvider.updateSewaAsetStatus(
sewaAsetId: id,
status: 'SELESAI',
);
}
}
// Mark rental as returned
void markAsReturned(String id) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id);
Future<void> markAsReturned(String id) async {
final index = sewaList.indexWhere((sewa) => sewa.id == id);
if (index != -1) {
final sewa = Map<String, dynamic>.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<AsetProvider>();
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<List<Map<String, dynamic>>> getPaketItems(String paketId) async {
final asetProvider = Get.find<AsetProvider>();
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<String?> getTagihanSewaIdBySewaAsetId(String sewaAsetId) async {
final asetProvider = Get.find<AsetProvider>();
final tagihan = await asetProvider.getTagihanSewa(sewaAsetId);
if (tagihan != null && tagihan['id'] != null) {
return tagihan['id'] as String;
}
return null;
}
Future<void> 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<AsetProvider>();
// 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,
);
}
}
}

View File

@ -1,7 +1,200 @@
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';
import 'package:bumrent_app/app/routes/app_routes.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<void> 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<String, dynamic>) {
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.TOP,
);
// 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.TOP,
);
}
// 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<AsetProvider>();
final isLoading = false.obs;
Future<void> _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.TOP,
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,28 +216,16 @@ class PetugasTambahAsetController extends GetxController {
final categoryOptions = ['Sewa', 'Langganan'];
final statusOptions = ['Tersedia', 'Pemeliharaan'];
// Images
final selectedImages = <String>[].obs;
// List to store selected images
final RxList<XFile> selectedImages = <XFile>[].obs;
// List to store network image URLs
final RxList<String> networkImageUrls = <String>[].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() {
// Dispose controllers
@ -89,17 +270,142 @@ class PetugasTambahAsetController extends GetxController {
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<String?> _createAsset(
Map<String, dynamic> assetData,
List<Map<String, dynamic>> 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<bool> _updateAsset(
String assetId,
Map<String, dynamic> assetData,
List<Map<String, dynamic>> 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<String> 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 +439,132 @@ class PetugasTambahAsetController extends GetxController {
basicValid && perHourValid && perDayValid && anyTimeOptionSelected;
}
// Submit form and save asset
// Submit form and save or update asset
Future<void> 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<String, dynamic> 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<Map<String, dynamic>> satuanWaktuSewa = [];
// Return to the asset list page
Get.back();
if (timeOptions['Per Jam']?.value == true) {
final hargaPerJam = int.tryParse(pricePerHourController.text) ?? 0;
final maxJam = int.tryParse(maxHourController.text) ?? 24;
// Show success message
Get.snackbar(
'Berhasil',
'Aset berhasil ditambahkan',
backgroundColor: Colors.green,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
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,
});
}
// Validate that at least one time option is selected
if (satuanWaktuSewa.isEmpty) {
throw Exception('Pilih setidaknya satu opsi waktu sewa (jam/hari)');
}
// Handle image uploads
List<String> 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.TOP,
backgroundColor: Colors.green,
colorText: Colors.white,
duration: const Duration(seconds: 2),
);
// Navigate back with success result instead of using offNamed
await Future.delayed(const Duration(milliseconds: 500));
Get.offNamed(Routes.PETUGAS_ASET);
} else {
throw Exception('Gagal menyimpan aset');
}
} catch (e) {
// Show error message
Get.snackbar(
@ -196,15 +572,76 @@ class PetugasTambahAsetController extends GetxController {
'Terjadi kesalahan: ${e.toString()}',
backgroundColor: Colors.red,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
} finally {
isSubmitting.value = false;
}
}
// Example method to upload images (to be implemented with your backend)
// Future<List<String>> _uploadImages(List<XFile> images) async {
// List<String> 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<void> 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.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
}
// Pick image from gallery
Future<void> pickImageFromGallery() async {
try {
final List<XFile>? 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.TOP,
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();
}
}

View File

@ -1,5 +1,12 @@
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:bumrent_app/app/routes/app_routes.dart';
import 'package:uuid/uuid.dart';
class PetugasTambahPaketController extends GetxController {
// Form controllers
@ -10,14 +17,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 = <String>[].obs;
final selectedImages = <dynamic>[].obs;
// For package name and description
final packageNameController = TextEditingController();
@ -31,21 +38,90 @@ class PetugasTambahPaketController extends GetxController {
// For asset selection
final RxList<Map<String, dynamic>> availableAssets =
<Map<String, dynamic>>[].obs;
final Rx<int?> selectedAsset = Rx<int?>(null);
final Rx<String?> selectedAsset = Rx<String?>(null);
final RxBool isLoadingAssets = false.obs;
// Form validation
final isFormValid = false.obs;
final isSubmitting = false.obs;
// New RxBool for editing
final isEditing = false.obs;
// New RxBool for viewing (read-only mode)
final isViewing = 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<String, dynamic> initialFormData = {};
final AsetProvider _asetProvider = Get.put(AsetProvider());
@override
void onInit() {
super.onInit();
// Ambil flag isEditing dan isViewing dari arguments
isEditing.value =
Get.arguments != null && Get.arguments['isEditing'] == true;
isViewing.value =
Get.arguments != null && Get.arguments['isViewing'] == true;
if (isEditing.value || isViewing.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 +137,10 @@ class PetugasTambahPaketController extends GetxController {
packageNameController.dispose();
packageDescriptionController.dispose();
packagePriceController.dispose();
pricePerHourController.dispose();
maxHourController.dispose();
pricePerDayController.dispose();
maxDayController.dispose();
super.onClose();
}
@ -68,18 +148,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 +170,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<void> 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 +221,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;
}
}
@ -144,7 +236,7 @@ class PetugasTambahPaketController extends GetxController {
Get.snackbar(
'Error',
'Pilih aset dan masukkan jumlah',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -165,7 +257,7 @@ class PetugasTambahPaketController extends GetxController {
Get.snackbar(
'Error',
'Jumlah harus lebih dari 0',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -178,7 +270,7 @@ class PetugasTambahPaketController extends GetxController {
Get.snackbar(
'Error',
'Jumlah melebihi stok yang tersedia',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -200,10 +292,12 @@ class PetugasTambahPaketController extends GetxController {
Get.snackbar(
'Sukses',
'Item berhasil ditambahkan ke paket',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.green,
colorText: Colors.white,
);
checkFormChanged();
}
// Update an existing package item
@ -212,7 +306,7 @@ class PetugasTambahPaketController extends GetxController {
Get.snackbar(
'Error',
'Pilih aset dan masukkan jumlah',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -233,7 +327,7 @@ class PetugasTambahPaketController extends GetxController {
Get.snackbar(
'Error',
'Jumlah harus lebih dari 0',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -261,7 +355,7 @@ class PetugasTambahPaketController extends GetxController {
Get.snackbar(
'Error',
'Jumlah melebihi stok yang tersedia',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -274,7 +368,7 @@ class PetugasTambahPaketController extends GetxController {
Get.snackbar(
'Error',
'Jumlah melebihi stok yang tersedia',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -297,19 +391,24 @@ class PetugasTambahPaketController extends GetxController {
Get.snackbar(
'Sukses',
'Item berhasil diperbarui',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
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',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.orange,
colorText: Colors.white,
);
@ -319,10 +418,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,42 +433,207 @@ 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<String>()
.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.offNamed(Routes.PETUGAS_PAKET);
Get.snackbar(
'Berhasil',
'Paket berhasil diperbarui',
backgroundColor: Colors.green,
colorText: Colors.white,
snackPosition: SnackPosition.TOP,
);
} 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(result: true);
Get.snackbar(
'Berhasil',
'Paket berhasil ditambahkan',
backgroundColor: Colors.green,
colorText: Colors.white,
snackPosition: SnackPosition.TOP,
);
}
} 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,
snackPosition: SnackPosition.TOP,
);
} finally {
isSubmitting.value = false;
@ -390,4 +651,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<void> 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<void> 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.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
}
Future<void> pickImageFromGallery() async {
try {
final List<XFile>? 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.TOP,
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();
}
}

View File

@ -476,7 +476,7 @@ class ListPetugasMitraView extends GetView<ListPetugasMitraController> {
Get.snackbar(
'Error',
'Harap isi semua data',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -600,7 +600,7 @@ class ListPetugasMitraView extends GetView<ListPetugasMitraController> {
Get.snackbar(
'Error',
'Harap isi semua data',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);

View File

@ -203,7 +203,7 @@ class ListTagihanPeriodeView extends GetView<ListTagihanPeriodeController> {
backgroundColor: Colors.orange.withOpacity(0.1),
colorText: Colors.orange.shade800,
duration: const Duration(seconds: 3),
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
margin: const EdgeInsets.all(8),
);
},

View File

@ -0,0 +1,365 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/petugas_akun_bank_controller.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
import '../widgets/petugas_side_navbar.dart';
import '../../../theme/app_colors_petugas.dart';
class PetugasAkunBankView extends GetView<PetugasAkunBankController> {
const PetugasAkunBankView({super.key});
@override
Widget build(BuildContext context) {
// Get dashboard controller for side navbar
final dashboardController = Get.find<PetugasBumdesDashboardController>();
return Scaffold(
appBar: AppBar(
title: const Text('Kelola Akun Bank'),
backgroundColor: AppColorsPetugas.navyBlue,
foregroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Get.back(),
),
),
body: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.errorMessage.value.isNotEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: Colors.red[300]),
const SizedBox(height: 16),
Text(
controller.errorMessage.value,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: controller.loadBankAccounts,
child: const Text('Coba Lagi'),
),
],
),
);
}
if (controller.bankAccounts.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.account_balance, size: 48, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'Belum ada akun bank',
style: TextStyle(fontSize: 18, color: Colors.grey[600]),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () => _showAddEditBankAccountDialog(context),
icon: const Icon(Icons.add),
label: const Text('Tambah Akun Bank'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
],
),
);
}
return Stack(
children: [
RefreshIndicator(
onRefresh: controller.loadBankAccounts,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: controller.bankAccounts.length,
itemBuilder: (context, index) {
final account = controller.bankAccounts[index];
return _buildBankAccountCard(context, account);
},
),
),
Positioned(
bottom: 16,
right: 16,
child: FloatingActionButton(
onPressed: () => _showAddEditBankAccountDialog(context),
backgroundColor: AppColorsPetugas.blueGrotto,
child: const Icon(Icons.add),
),
),
],
);
}),
);
}
Widget _buildBankAccountCard(
BuildContext context,
Map<String, dynamic> account,
) {
return Card(
margin: const EdgeInsets.only(bottom: 16),
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColorsPetugas.blueGrotto.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.account_balance,
color: AppColorsPetugas.blueGrotto,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
account['nama_bank'] ?? '',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
account['nama_akun'] ?? '',
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
],
),
),
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
_showAddEditBankAccountDialog(context, account);
} else if (value == 'delete') {
_showDeleteConfirmationDialog(context, account);
}
},
itemBuilder:
(context) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit, size: 18),
SizedBox(width: 8),
Text('Edit'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, size: 18, color: Colors.red),
SizedBox(width: 8),
Text(
'Hapus',
style: TextStyle(color: Colors.red),
),
],
),
),
],
),
],
),
const Divider(height: 24),
Row(
children: [
const Icon(Icons.credit_card, size: 16, color: Colors.grey),
const SizedBox(width: 8),
Text(
'No. Rekening: ${account['no_rekening'] ?? ''}',
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
],
),
],
),
),
);
}
void _showAddEditBankAccountDialog(
BuildContext context, [
Map<String, dynamic>? account,
]) {
final isEditing = account != null;
final formKey = GlobalKey<FormState>();
String bankName = account?['nama_bank'] ?? '';
String accountName = account?['nama_akun'] ?? '';
String accountNumber = account?['no_rekening'] ?? '';
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isEditing ? 'Edit Akun Bank' : 'Tambah Akun Bank',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Form(
key: formKey,
child: Column(
children: [
TextFormField(
initialValue: bankName,
decoration: const InputDecoration(
labelText: 'Nama Bank',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.account_balance),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Nama bank tidak boleh kosong';
}
return null;
},
onChanged: (value) => bankName = value,
),
const SizedBox(height: 16),
TextFormField(
initialValue: accountName,
decoration: const InputDecoration(
labelText: 'Nama Pemilik Rekening',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Nama pemilik rekening tidak boleh kosong';
}
return null;
},
onChanged: (value) => accountName = value,
),
const SizedBox(height: 16),
TextFormField(
initialValue: accountNumber,
decoration: const InputDecoration(
labelText: 'Nomor Rekening',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.credit_card),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Nomor rekening tidak boleh kosong';
}
return null;
},
onChanged: (value) => accountNumber = value,
),
],
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Batal'),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: () {
if (formKey.currentState!.validate()) {
final accountData = {
'nama_bank': bankName,
'nama_akun': accountName,
'no_rekening': accountNumber,
};
if (isEditing) {
controller.updateBankAccount(
account['id'],
accountData,
);
} else {
controller.addBankAccount(accountData);
}
Get.back();
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
foregroundColor: Colors.white,
),
child: Text(isEditing ? 'Simpan' : 'Tambah'),
),
],
),
],
),
),
),
);
}
void _showDeleteConfirmationDialog(
BuildContext context,
Map<String, dynamic> account,
) {
Get.dialog(
AlertDialog(
title: const Text('Konfirmasi Hapus'),
content: Text(
'Apakah Anda yakin ingin menghapus akun bank ${account['nama_bank']} - ${account['nama_akun']}?',
),
actions: [
TextButton(onPressed: () => Get.back(), child: const Text('Batal')),
TextButton(
onPressed: () {
controller.deleteBankAccount(account['id']);
Get.back();
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Hapus'),
),
],
),
);
}
}

View File

@ -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<PetugasAsetView>
void initState() {
super.initState();
controller = Get.find<PetugasAsetController>();
_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<PetugasAsetView>
body: Column(
children: [
_buildSearchBar(),
_buildTabBar(),
const SizedBox(height: 16),
Expanded(child: _buildAssetList()),
],
),
@ -93,7 +80,17 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_ASET),
onPressed: () {
// Navigate to PetugasTambahAsetView in add mode and refresh data when returning
Get.toNamed(
Routes.PETUGAS_TAMBAH_ASET,
arguments: {'isEditing': false, 'assetData': null},
)?.then((_) {
// Refresh data when returning from tambah_aset page
debugPrint('Returning from tambah aset page, refreshing data...');
controller.loadAsetData();
});
},
backgroundColor: AppColorsPetugas.babyBlueBright,
icon: Icon(Icons.add, color: AppColorsPetugas.blueGrotto),
label: Text(
@ -144,60 +141,19 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
);
}
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 +211,15 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
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 +227,31 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
}
Widget _buildAssetCard(BuildContext context, Map<String, dynamic> 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),
@ -286,25 +271,49 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showAssetDetails(context, aset),
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 +332,8 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
aset['nama'],
style: TextStyle(
nama,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: AppColorsPetugas.navyBlue,
@ -333,12 +342,63 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
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<Map<String, dynamic>>.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 +443,42 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
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.TOP,
);
return;
}
Get.toNamed(
Routes.PETUGAS_TAMBAH_ASET,
arguments: {
'isEditing': true,
'assetId': assetId,
},
)?.then((_) {
// Refresh data when returning from edit page
debugPrint(
'Returning from edit aset page, refreshing data...',
);
controller.loadAsetData();
});
},
child: Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
@ -589,590 +680,16 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
}
}
void _showAssetDetails(BuildContext context, Map<String, dynamic> aset) {
final isAvailable = aset['tersedia'] == true;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return Container(
height: MediaQuery.of(context).size.height * 0.85,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header with gradient
Container(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 24),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColorsPetugas.blueGrotto,
AppColorsPetugas.navyBlue,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Close button and availability badge
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color:
isAvailable
? AppColorsPetugas.successLight
: AppColorsPetugas.errorLight,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color:
isAvailable
? AppColorsPetugas.success
: AppColorsPetugas.error,
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isAvailable ? Icons.check_circle : Icons.cancel,
color:
isAvailable
? AppColorsPetugas.success
: AppColorsPetugas.error,
size: 16,
),
const SizedBox(width: 4),
Text(
isAvailable ? 'Tersedia' : 'Tidak Tersedia',
style: TextStyle(
fontSize: 12,
color:
isAvailable
? AppColorsPetugas.success
: AppColorsPetugas.error,
fontWeight: FontWeight.bold,
),
),
],
),
),
GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
color: Colors.white,
size: 20,
),
),
),
],
),
const SizedBox(height: 16),
// Category badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
),
child: Text(
aset['kategori'],
style: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 12),
// Asset name
Text(
aset['nama'],
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 8),
// Price
Row(
children: [
const Icon(
Icons.monetization_on,
color: Colors.white,
size: 18,
),
const SizedBox(width: 8),
Text(
'${controller.formatPrice(aset['harga'])} ${aset['satuan']}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
],
),
),
// Asset details
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Quick info cards
Row(
children: [
_buildInfoCard(
Icons.inventory_2,
'Stok',
'${aset['stok']} unit',
flex: 1,
),
const SizedBox(width: 16),
_buildInfoCard(
Icons.category,
'Jenis',
aset['jenis'],
flex: 1,
),
],
),
const SizedBox(height: 24),
// Description section
Text(
'Deskripsi',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 8),
Text(
aset['deskripsi'],
style: TextStyle(
fontSize: 14,
color: AppColorsPetugas.textPrimary,
height: 1.5,
),
),
const SizedBox(height: 32),
],
),
),
),
// Action buttons
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: AppColorsPetugas.shadowColor,
blurRadius: 10,
offset: const Offset(0, -5),
),
],
),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Navigator.pop(context);
_showAddEditAssetDialog(context, aset: aset);
},
icon: const Icon(Icons.edit),
label: const Text('Edit'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColorsPetugas.blueGrotto,
side: BorderSide(color: AppColorsPetugas.blueGrotto),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context);
_showDeleteConfirmation(context, aset);
},
icon: const Icon(Icons.delete),
label: const Text('Hapus'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.error,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
),
),
],
),
);
},
);
}
Widget _buildInfoCard(
IconData icon,
String label,
String value, {
int flex = 1,
}) {
return Expanded(
flex: flex,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueBright,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColorsPetugas.babyBlue),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 16, color: AppColorsPetugas.blueGrotto),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 12,
color: AppColorsPetugas.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
],
),
),
);
}
Widget _buildDetailItem(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 13,
color: AppColorsPetugas.textSecondary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 6),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.textPrimary,
),
),
],
),
);
}
void _showAddEditAssetDialog(
BuildContext context, {
Map<String, dynamic>? aset,
}) {
final isEditing = aset != null;
final jenisOptions = ['Sewa', 'Langganan'];
final typeOptions = ['Elektronik', 'Furniture', 'Kendaraan', 'Lainnya'];
// In a real app, this would have proper form handling with controllers
showDialog(
context: context,
builder: (context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlue,
shape: BoxShape.circle,
),
child: Icon(
isEditing ? Icons.edit : Icons.add,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isEditing ? 'Edit Aset' : 'Tambah Aset Baru',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 4),
Text(
'Silakan lengkapi form di bawah ini',
style: TextStyle(
fontSize: 14,
color: AppColorsPetugas.textSecondary,
),
),
],
),
),
],
),
const SizedBox(height: 24),
// Mock form - In a real app this would have actual form fields
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueBright,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColorsPetugas.babyBlue),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Form pengelolaan aset akan ditampilkan di sini dengan field untuk:',
style: TextStyle(
fontSize: 14,
color: AppColorsPetugas.textPrimary,
),
),
const SizedBox(height: 16),
_buildMockFormField('Nama Aset', 'Contoh: Meja Rapat'),
_buildMockFormField('Kategori', 'Pilih kategori aset'),
_buildMockFormField(
'Harga',
'Masukkan harga per unit/periode',
),
_buildMockFormField(
'Satuan',
'Contoh: per hari, per bulan',
),
_buildMockFormField('Stok', 'Jumlah unit tersedia'),
_buildMockFormField(
'Deskripsi',
'Keterangan lengkap aset',
),
_buildMockToggle(
'Status Ketersediaan',
isEditing && aset?['tersedia'] == true,
),
],
),
),
const SizedBox(height: 24),
// Action buttons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Batal',
style: TextStyle(
color: AppColorsPetugas.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
// In a real app, we would save the form data
Get.snackbar(
isEditing ? 'Aset Diperbarui' : 'Aset Ditambahkan',
isEditing
? 'Aset berhasil diperbarui'
: 'Aset baru berhasil ditambahkan',
backgroundColor: AppColorsPetugas.success,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
margin: const EdgeInsets.all(16),
borderRadius: 10,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(isEditing ? 'Simpan' : 'Tambah'),
),
],
),
],
),
),
);
},
);
}
Widget _buildMockFormField(String label, String hint) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.textPrimary,
),
),
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColorsPetugas.babyBlue),
),
child: Row(
children: [
Expanded(
child: Text(
hint,
style: TextStyle(
fontSize: 14,
color: AppColorsPetugas.textLight,
),
),
),
Icon(
Icons.keyboard_arrow_down,
color: AppColorsPetugas.textSecondary,
size: 20,
),
],
),
),
],
),
);
}
Widget _buildMockToggle(String label, bool value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.textPrimary,
),
),
Switch(
value: value,
onChanged: (_) {},
activeColor: AppColorsPetugas.blueGrotto,
),
],
),
);
void _showAddEditAssetDialog(BuildContext context) {
// Navigate to PetugasTambahAsetView in add mode and refresh data when returning
Get.toNamed(
Routes.PETUGAS_TAMBAH_ASET,
arguments: {'isEditing': false, 'assetData': null},
)?.then((_) {
// Refresh data when returning from tambah_aset page
debugPrint('Returning from tambah aset page, refreshing data...');
controller.loadAsetData();
});
}
void _showDeleteConfirmation(
@ -1251,22 +768,11 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: () {
onPressed: () async {
Navigator.pop(context);
controller.deleteAset(aset['id']);
Get.snackbar(
'Aset Dihapus',
'Aset berhasil dihapus dari sistem',
backgroundColor: AppColorsPetugas.error,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
margin: const EdgeInsets.all(16),
borderRadius: 10,
icon: const Icon(
Icons.check_circle,
color: Colors.white,
),
);
// Let the controller handle the deletion and showing the snackbar
await controller.deleteAset(aset['id']);
// The controller will show appropriate success or error messages
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.error,

View File

@ -327,7 +327,7 @@ class PetugasBumdesCbpView extends GetView<PetugasBumdesCbpController> {
leading: const Icon(Icons.subscriptions_outlined),
title: const Text('Kelola Langganan'),
onTap: () {
Get.offAllNamed(Routes.PETUGAS_LANGGANAN);
Get.offAllNamed(Routes.PETUGAS_BUMDES_DASHBOARD);
},
),
ListTile(

View File

@ -5,6 +5,8 @@ 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';
import '../views/petugas_penyewa_view.dart';
class PetugasBumdesDashboardView
extends GetView<PetugasBumdesDashboardController> {
@ -23,12 +25,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,
@ -68,6 +65,8 @@ class PetugasBumdesDashboardView
case 3:
return 'Permintaan Sewa';
case 4:
return 'Penyewa';
case 5:
return 'Profil BUMDes';
default:
return 'Dashboard Petugas BUMDES';
@ -85,6 +84,8 @@ class PetugasBumdesDashboardView
case 3:
return _buildSewaTab();
case 4:
return const PetugasPenyewaView();
case 5:
return _buildBumdesTab();
default:
return _buildDashboardTab();
@ -100,6 +101,16 @@ class PetugasBumdesDashboardView
_buildWelcomeCard(),
const SizedBox(height: 24),
// Tenant Statistics Section
_buildSectionHeader(
'Statistik Penyewa',
AppColorsPetugas.blueGrotto,
Icons.people_outline,
),
_buildTenantStatistics(),
const SizedBox(height: 24),
// Detail Status Sewa Aset section with improved header
_buildSectionHeader(
'Detail Status Sewa Aset',
@ -118,8 +129,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 +165,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 +243,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 +679,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 +705,7 @@ class PetugasBumdesDashboardView
),
decoration: BoxDecoration(
color:
controller.isKenaikanPositif.value
isPositive
? AppColorsPetugas.success.withOpacity(
0.1,
)
@ -676,23 +718,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 +751,8 @@ class PetugasBumdesDashboardView
),
),
],
),
),
);
}),
],
),
),
@ -744,16 +786,29 @@ class PetugasBumdesDashboardView
}
Widget _buildRevenueSummary() {
return Row(
return Column(
children: [
Expanded(
child: _buildRevenueQuickInfo(
'Pendapatan Sewa',
controller.pendapatanSewa.value,
Obx(() {
final stats = controller.pembayaranStats;
final totalTunai = stats['totalTunai'] ?? 0.0;
return _buildRevenueQuickInfo(
'Tunai',
formatRupiah(totalTunai),
AppColorsPetugas.navyBlue,
Icons.shopping_cart_outlined,
),
),
Icons.payments,
);
}),
const SizedBox(height: 12),
Obx(() {
final stats = controller.pembayaranStats;
final totalTransfer = stats['totalTransfer'] ?? 0.0;
return _buildRevenueQuickInfo(
'Transfer',
formatRupiah(totalTransfer),
AppColorsPetugas.blueGrotto,
Icons.account_balance,
);
}),
],
);
}
@ -811,81 +866,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 +892,9 @@ class PetugasBumdesDashboardView
child: Obx(() {
// Get the trend data from controller
final List<double> 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 +908,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,
@ -1004,7 +987,13 @@ class PetugasBumdesDashboardView
children: [
Container(
width: 35,
height: 170 * percentage,
height:
percentage.isNaN || percentage <= 0
? 10.0
: (170 * percentage).clamp(
10.0,
170.0,
),
decoration: BoxDecoration(
borderRadius:
const BorderRadius.vertical(
@ -1271,6 +1260,288 @@ class PetugasBumdesDashboardView
),
);
}
// New widget for tenant statistics
Widget _buildTenantStatistics() {
return Obx(() {
if (controller.isPenyewaStatsLoading.value) {
return const Center(
child: Padding(
padding: EdgeInsets.all(20.0),
child: CircularProgressIndicator(),
),
);
}
return Card(
elevation: 2,
shadowColor: AppColorsPetugas.shadowColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
// Use LayoutBuilder to make the grid responsive
LayoutBuilder(
builder: (context, constraints) {
return GridView.count(
crossAxisCount: 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 14,
mainAxisSpacing: 14,
childAspectRatio: 0.75,
children: [
_buildTenantStatusItem(
'Menunggu Verifikasi',
controller.penyewaPendingCount.value.toString(),
AppColorsPetugas.warning,
Icons.pending_outlined,
),
_buildTenantStatusItem(
'Aktif',
controller.penyewaActiveCount.value.toString(),
AppColorsPetugas.success,
Icons.check_circle_outline,
),
_buildTenantStatusItem(
'Ditangguhkan',
controller.penyewaSuspendedCount.value.toString(),
AppColorsPetugas.error,
Icons.block_outlined,
),
],
);
},
),
const SizedBox(height: 24),
// Tenant distribution visualization
_buildTenantDistributionBar(),
],
),
),
);
});
}
Widget _buildTenantStatusItem(
String title,
String value,
Color color,
IconData icon,
) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: color.withOpacity(0.15),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
border: Border.all(color: color.withOpacity(0.1), width: 1),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, color: color, size: 24),
),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
title,
style: TextStyle(
fontSize: 10,
height: 1.2,
color: AppColorsPetugas.textSecondary,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
Widget _buildTenantDistributionBar() {
// Calculate the total count for all tenant statuses
final total = controller.penyewaTotalCount.value;
// Calculate percentages for each status (avoid division by zero)
final pendingPercent =
total > 0 ? controller.penyewaPendingCount.value / total : 0.0;
final activePercent =
total > 0 ? controller.penyewaActiveCount.value / total : 0.0;
final suspendedPercent =
total > 0 ? controller.penyewaSuspendedCount.value / total : 0.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Distribusi Status Penyewa',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.blueGrotto,
),
),
const SizedBox(height: 16),
// Only show distribution bar if there are any tenants
if (total > 0)
Stack(
children: [
// Background for the progress bar
Container(
height: 12,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(6),
),
),
// Actual progress bar segments
Row(
children: [
if (pendingPercent > 0)
_buildProgressSegment(
pendingPercent,
AppColorsPetugas.warning,
isFirst: true,
),
if (activePercent > 0)
_buildProgressSegment(
activePercent,
AppColorsPetugas.success,
),
if (suspendedPercent > 0)
_buildProgressSegment(
suspendedPercent,
AppColorsPetugas.error,
isLast: true,
),
],
),
],
)
else
Container(
height: 12,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Text(
'Belum ada data penyewa',
style: TextStyle(
fontSize: 10,
color: AppColorsPetugas.textSecondary,
),
),
),
),
const SizedBox(height: 16),
// Use row layout for legends
Wrap(
spacing: 16,
runSpacing: 8,
children: [
if (pendingPercent > 0 || total == 0)
_buildStatusLegend(
'Menunggu Verifikasi',
AppColorsPetugas.warning,
pendingPercent,
),
if (activePercent > 0 || total == 0)
_buildStatusLegend(
'Aktif',
AppColorsPetugas.success,
activePercent,
),
if (suspendedPercent > 0 || total == 0)
_buildStatusLegend(
'Ditangguhkan',
AppColorsPetugas.error,
suspendedPercent,
),
],
),
],
);
}
Widget _buildProgressSegment(
double percentage,
Color color, {
bool isFirst = false,
bool isLast = false,
}) {
final flex = (percentage * 100).round();
if (flex <= 0) return const SizedBox.shrink();
return Flexible(
flex: flex,
child: Container(
height: 12,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.horizontal(
left: isFirst ? const Radius.circular(6) : Radius.zero,
right: isLast ? const Radius.circular(6) : Radius.zero,
),
),
),
);
}
Widget _buildStatusLegend(String text, Color color, double percentage) {
final count = (percentage * 100).round();
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 4),
Text(
'$text ${count > 0 ? '($count%)' : ''}',
style: TextStyle(
fontSize: 10,
color: Colors.black87,
fontWeight: count > 20 ? FontWeight.w500 : FontWeight.normal,
),
),
],
);
}
}
// Custom clipper for creating pie/donut chart segments

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,275 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:printing/printing.dart';
import 'package:pdf/pdf.dart';
import '../controllers/petugas_laporan_controller.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
import '../widgets/petugas_side_navbar.dart';
import '../../../theme/app_colors_petugas.dart';
class PetugasLaporanView extends GetView<PetugasLaporanController> {
const PetugasLaporanView({super.key});
@override
Widget build(BuildContext context) {
// Get dashboard controller for side navbar
final dashboardController = Get.find<PetugasBumdesDashboardController>();
return Scaffold(
appBar: AppBar(
title: const Text('Laporan Bulanan'),
backgroundColor: AppColorsPetugas.navyBlue,
foregroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Get.back(),
),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Filter Section
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Filter Laporan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Obx(
() => DropdownButtonFormField<int>(
decoration: const InputDecoration(
labelText: 'Bulan',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
value: controller.selectedMonth.value,
items:
controller.months.map<DropdownMenuItem<int>>((
month,
) {
return DropdownMenuItem<int>(
value: month['value'] as int,
child: Text(month['label'] as String),
);
}).toList(),
onChanged: controller.onMonthChanged,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Obx(
() => DropdownButtonFormField<int>(
decoration: const InputDecoration(
labelText: 'Tahun',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
value: controller.selectedYear.value,
items:
controller.years.map<DropdownMenuItem<int>>((
year,
) {
return DropdownMenuItem<int>(
value: year,
child: Text(year.toString()),
);
}).toList(),
onChanged: controller.onYearChanged,
),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Obx(
() => ElevatedButton.icon(
onPressed:
controller.isLoading.value
? null
: controller.generateReport,
icon:
controller.isLoading.value
? Container(
width: 24,
height: 24,
padding: const EdgeInsets.all(2.0),
child: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
)
: const Icon(Icons.refresh),
label: Text(
controller.isLoading.value
? 'Memproses...'
: 'Generate Laporan',
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
vertical: 12,
),
disabledBackgroundColor: AppColorsPetugas
.blueGrotto
.withOpacity(0.6),
),
),
),
),
],
),
],
),
),
),
const SizedBox(height: 16),
// Report Preview Section
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Menghasilkan laporan...'),
],
),
);
}
if (!controller.isPdfReady.value ||
controller.pdfBytes.value == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.description_outlined,
size: 80,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Belum ada laporan',
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
const Text(
'Pilih bulan dan tahun lalu klik "Generate Laporan"',
textAlign: TextAlign.center,
),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Preview Laporan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Expanded(
child: Card(
elevation: 2,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: PdfPreview(
build: (format) => controller.pdfBytes.value!,
canChangeOrientation: false,
canChangePageFormat: false,
canDebug: false,
allowPrinting: false,
allowSharing: false,
initialPageFormat: PdfPageFormat.a4,
pdfFileName:
'Laporan_${controller.reportData['period']?['monthName'] ?? ''}_${controller.reportData['period']?['year'] ?? ''}.pdf',
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton.icon(
onPressed: controller.savePdf,
icon: const Icon(Icons.save_alt),
label: const Text('Simpan PDF'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.navyBlue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
const SizedBox(width: 16),
ElevatedButton.icon(
onPressed: controller.printPdf,
icon: const Icon(Icons.print),
label: const Text('Cetak'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
],
),
],
);
}),
),
],
),
),
);
}
}

View File

@ -567,7 +567,7 @@ class PetugasManajemenBumdesView
Get.snackbar(
'Info',
'Fitur tambah rekening bank sedang dalam pengembangan',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
},
style: ElevatedButton.styleFrom(
@ -639,7 +639,7 @@ class PetugasManajemenBumdesView
Get.snackbar(
'Info',
'Fitur edit rekening bank sedang dalam pengembangan',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
},
style: ElevatedButton.styleFrom(
@ -689,7 +689,7 @@ class PetugasManajemenBumdesView
Get.snackbar(
'Info',
'Fitur hapus rekening bank sedang dalam pengembangan',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
},
style: ElevatedButton.styleFrom(
@ -765,7 +765,7 @@ class PetugasManajemenBumdesView
Get.snackbar(
'Info',
'Fitur tambah mitra sedang dalam pengembangan',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
},
style: ElevatedButton.styleFrom(
@ -845,7 +845,7 @@ class PetugasManajemenBumdesView
Get.snackbar(
'Info',
'Fitur edit mitra sedang dalam pengembangan',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
},
style: ElevatedButton.styleFrom(
@ -895,7 +895,7 @@ class PetugasManajemenBumdesView
Get.snackbar(
'Info',
'Fitur hapus mitra sedang dalam pengembangan',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
},
style: ElevatedButton.styleFrom(

View File

@ -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<PetugasPaketController> {
const PetugasPaketView({Key? key}) : super(key: key);
@ -53,7 +55,17 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_PAKET),
onPressed: () async {
final result = await Get.toNamed(
Routes.PETUGAS_TAMBAH_PAKET,
arguments: {'isEditing': false},
);
// Refresh the package list if a package was added
if (result == true) {
controller.loadPaketData();
}
},
label: Text(
'Tambah Paket',
style: TextStyle(
@ -115,7 +127,7 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
);
}
if (controller.filteredPaketList.isEmpty) {
if (controller.filteredPackages.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@ -136,7 +148,17 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_PAKET),
onPressed: () async {
final result = await Get.toNamed(
Routes.PETUGAS_TAMBAH_PAKET,
arguments: {'isEditing': false},
);
// Refresh the package list if a package was added
if (result == true) {
controller.loadPaketData();
}
},
icon: const Icon(Icons.add),
label: const Text('Tambah Paket'),
style: ElevatedButton.styleFrom(
@ -161,18 +183,192 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
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<String, dynamic> 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<Map<String, dynamic>> 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),
@ -192,26 +388,101 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showPaketDetails(context, paket),
onTap: () async {
final result = await Get.toNamed(
Routes.PETUGAS_TAMBAH_PAKET,
arguments: {
'isEditing': false,
'isViewing': true,
'paket': paket,
},
);
// Refresh the package list if data was modified
if (result == true) {
controller.loadPaketData();
}
},
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 +499,10 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
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 +511,119 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
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<Map<String, dynamic>> 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<Map<String, dynamic>>.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 +636,31 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
),
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 +674,12 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
// 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 +737,42 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
);
}
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,273 +822,104 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
);
}
void _showPaketDetails(BuildContext context, Map<String, dynamic> paket) {
showModalBottomSheet(
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,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
return Container(
padding: const EdgeInsets.all(16),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
paket['nama'],
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Warning icon
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColorsPetugas.errorLight,
shape: BoxShape.circle,
),
IconButton(
icon: Icon(Icons.close, color: AppColorsPetugas.blueGrotto),
onPressed: () => Navigator.pop(context),
child: Icon(
Icons.delete_forever,
color: AppColorsPetugas.error,
size: 32,
),
],
),
const SizedBox(height: 16),
Expanded(
child: ListView(
),
const SizedBox(height: 24),
// Title and message
Text(
'Konfirmasi Hapus',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Apakah Anda yakin ingin menghapus paket "$nama"? Tindakan ini tidak dapat dibatalkan.',
style: TextStyle(
fontSize: 14,
color: AppColorsPetugas.textPrimary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Action buttons
Row(
children: [
Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailItem('Kategori', paket['kategori']),
_buildDetailItem(
'Harga',
controller.formatPrice(paket['harga']),
),
_buildDetailItem(
'Status',
paket['tersedia'] ? 'Tersedia' : 'Tidak Tersedia',
),
_buildDetailItem('Deskripsi', paket['deskripsi']),
],
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
foregroundColor: AppColorsPetugas.textPrimary,
side: BorderSide(color: AppColorsPetugas.divider),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text('Batal'),
),
),
const SizedBox(height: 16),
Text(
'Item dalam Paket',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 8),
Card(
margin: EdgeInsets.zero,
child: ListView.separated(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: paket['items'].length,
separatorBuilder:
(context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final item = paket['items'][index];
return ListTile(
leading: CircleAvatar(
backgroundColor: AppColorsPetugas.babyBlue,
child: Icon(
Icons.inventory_2_outlined,
color: AppColorsPetugas.blueGrotto,
size: 16,
),
),
title: Text(item['nama']),
trailing: Text(
'${item['jumlah']} unit',
style: TextStyle(
color: AppColorsPetugas.blueGrotto,
fontWeight: FontWeight.bold,
),
),
);
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: () {
Navigator.pop(context);
controller.deletePaket(id);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.error,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text('Hapus'),
),
),
],
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Navigator.pop(context);
Get.toNamed(
Routes.PETUGAS_TAMBAH_PAKET,
arguments: paket,
);
},
icon: const Icon(Icons.edit),
label: const Text('Edit'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColorsPetugas.blueGrotto,
side: BorderSide(color: AppColorsPetugas.blueGrotto),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context);
_showDeleteConfirmation(context, paket);
},
icon: const Icon(Icons.delete),
label: const Text('Hapus'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.error,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
],
),
);
},
);
}
Widget _buildDetailItem(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 14, color: AppColorsPetugas.blueGrotto),
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.navyBlue,
],
),
),
],
),
);
}
void _showAddEditPaketDialog(
BuildContext context, {
Map<String, dynamic>? paket,
}) {
final isEditing = paket != null;
// This would be implemented with proper form validation in a real app
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(
isEditing ? 'Edit Paket' : 'Tambah Paket Baru',
style: TextStyle(color: AppColorsPetugas.navyBlue),
),
content: const Text(
'Form pengelolaan paket akan ditampilkan di sini dengan field untuk nama, kategori, harga, deskripsi, status, dan item-item dalam paket.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Batal',
style: TextStyle(color: Colors.grey.shade600),
),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
// In a real app, we would save the form data
Get.snackbar(
isEditing ? 'Paket Diperbarui' : 'Paket Ditambahkan',
isEditing
? 'Paket berhasil diperbarui'
: 'Paket baru berhasil ditambahkan',
backgroundColor: AppColorsPetugas.success,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
),
child: Text(isEditing ? 'Simpan' : 'Tambah'),
),
],
);
},
);
}
void _showDeleteConfirmation(
BuildContext context,
Map<String, dynamic> paket,
) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(
'Konfirmasi Hapus',
style: TextStyle(color: AppColorsPetugas.navyBlue),
),
content: Text(
'Apakah Anda yakin ingin menghapus paket "${paket['nama']}"?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Batal',
style: TextStyle(color: Colors.grey.shade600),
),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
controller.deletePaket(paket['id']);
Get.snackbar(
'Paket Dihapus',
'Paket berhasil dihapus dari sistem',
backgroundColor: AppColorsPetugas.error,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.error,
),
child: const Text('Hapus'),
),
],
);
},
);

View File

@ -0,0 +1,777 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/petugas_penyewa_controller.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
import '../../../theme/app_colors_petugas.dart';
import '../widgets/petugas_bumdes_bottom_navbar.dart';
import '../widgets/petugas_side_navbar.dart';
import '../../../routes/app_routes.dart';
class PetugasPenyewaView extends StatefulWidget {
const PetugasPenyewaView({Key? key}) : super(key: key);
@override
State<PetugasPenyewaView> createState() => _PetugasPenyewaViewState();
}
class _PetugasPenyewaViewState extends State<PetugasPenyewaView>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late PetugasPenyewaController controller;
late PetugasBumdesDashboardController dashboardController;
final List<String> tabTitles = ['Verifikasi', 'Aktif', 'Ditangguhkan'];
@override
void initState() {
super.initState();
controller = Get.find<PetugasPenyewaController>();
dashboardController = Get.find<PetugasBumdesDashboardController>();
_tabController = TabController(length: 3, vsync: this);
// Add listener to sync tab selection with controller's filter
_tabController.addListener(_onTabChanged);
}
void _onTabChanged() {
if (!_tabController.indexIsChanging) {
controller.changeTab(_tabController.index);
}
}
@override
void dispose() {
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
dashboardController.changeTab(0);
return false;
},
child: Scaffold(
appBar: AppBar(
title: const Text(
'Daftar Penyewa',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18),
),
backgroundColor: AppColorsPetugas.navyBlue,
foregroundColor: Colors.white,
elevation: 0,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Container(
decoration: const BoxDecoration(
color: AppColorsPetugas.navyBlue,
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
),
),
child: TabBar(
controller: _tabController,
isScrollable: true,
indicator: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(30),
),
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 4,
),
labelColor: Colors.white,
unselectedLabelColor: Colors.white.withOpacity(0.7),
labelStyle: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.w400,
fontSize: 14,
),
tabs:
tabTitles
.map(
(title) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Tab(text: title),
),
)
.toList(),
dividerColor: Colors.transparent,
),
),
),
actions: [
// Tambahkan indikator refresh
Obx(
() =>
controller.isRefreshing.value
? Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
),
),
)
: SizedBox.shrink(),
),
],
),
drawer: PetugasSideNavbar(controller: dashboardController),
drawerEdgeDragWidth: 60,
drawerScrimColor: Colors.black.withOpacity(0.6),
backgroundColor: Colors.grey.shade50,
body: Column(
children: [
_buildSearchBar(),
Expanded(
child: TabBarView(
controller: _tabController,
children:
[0, 1, 2].map((index) {
return Obx(() {
if (controller.isLoading.value) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: AppColorsPetugas.blueGrotto,
strokeWidth: 3,
),
const SizedBox(height: 16),
Text(
'Memuat data...',
style: TextStyle(
color: AppColorsPetugas.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
if (controller.filteredPenyewaList.isEmpty) {
return RefreshIndicator(
onRefresh: () async {
await controller.refreshPenyewaList();
},
color: AppColorsPetugas.blueGrotto,
child: _buildEmptyStateWithScrollView(),
);
}
return _buildPenyewaList();
});
}).toList(),
),
),
],
),
bottomNavigationBar: Obx(
() => PetugasBumdesBottomNavbar(
selectedIndex: dashboardController.currentTabIndex.value,
onItemTapped: (index) => dashboardController.changeTab(index),
),
),
),
);
}
Widget _buildSearchBar() {
// Add controller for TextField so it can be cleared
final TextEditingController searchController = TextEditingController(
text: controller.searchQuery.value,
);
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: TextField(
controller: searchController,
onChanged: controller.updateSearchQuery,
decoration: InputDecoration(
hintText: 'Cari nama atau email...',
hintStyle: TextStyle(color: Colors.grey.shade400, fontSize: 14),
prefixIcon: Container(
padding: const EdgeInsets.all(12),
child: Icon(
Icons.search_rounded,
color: AppColorsPetugas.blueGrotto,
size: 22,
),
),
filled: true,
fillColor: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(50),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(50),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(50),
borderSide: BorderSide.none,
),
contentPadding: EdgeInsets.zero,
isDense: true,
suffixIcon: Obx(
() =>
controller.searchQuery.value.isNotEmpty
? IconButton(
icon: Icon(
Icons.close,
color: AppColorsPetugas.textSecondary,
size: 20,
),
onPressed: () {
searchController.clear();
controller.updateSearchQuery('');
},
)
: SizedBox.shrink(),
),
),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people_outline, size: 80, color: Colors.grey[400]),
const SizedBox(height: 16),
Obx(() {
String message = 'Belum ada data penyewa';
if (controller.searchQuery.isNotEmpty) {
message = 'Tidak ada hasil yang cocok dengan pencarian';
} else {
switch (controller.currentTabIndex.value) {
case 0:
message = 'Tidak ada penyewa yang menunggu verifikasi';
break;
case 1:
message = 'Tidak ada penyewa aktif';
break;
case 2:
message = 'Tidak ada penyewa yang ditangguhkan';
break;
}
}
return Text(
message,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey[600],
),
);
}),
const SizedBox(height: 8),
Text(
'Data penyewa akan muncul di sini',
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
),
);
}
// Widget untuk menampilkan empty state dengan ScrollView untuk mendukung RefreshIndicator
Widget _buildEmptyStateWithScrollView() {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height: MediaQuery.of(Get.context!).size.height * 0.7,
child: _buildEmptyState(),
),
],
);
}
Widget _buildPenyewaList() {
return RefreshIndicator(
onRefresh: () async {
await controller.refreshPenyewaList();
},
color: AppColorsPetugas.blueGrotto,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: controller.filteredPenyewaList.length,
itemBuilder: (context, index) {
final penyewa = controller.filteredPenyewaList[index];
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with avatar and badge
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueLight.withOpacity(
0.2,
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
children: [
// Avatar with border
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: CircleAvatar(
radius: 24,
backgroundColor: AppColorsPetugas.babyBlueLight,
backgroundImage:
penyewa['avatar'] != null &&
penyewa['avatar']
.toString()
.isNotEmpty
? NetworkImage(penyewa['avatar'])
: null,
child:
penyewa['avatar'] == null ||
penyewa['avatar'].toString().isEmpty
? const Icon(
Icons.person,
color: Colors.white,
)
: null,
),
),
const SizedBox(width: 16),
// Name and email
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
penyewa['nama_lengkap'] ??
'Nama tidak tersedia',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.email_outlined,
size: 14,
color: Colors.grey[600],
),
const SizedBox(width: 4),
Expanded(
child: Text(
penyewa['email'] ??
'Email tidak tersedia',
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
_buildStatusBadge(penyewa['status']),
],
),
),
// Content section
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Show additional info only for Aktif and Ditangguhkan tabs
if (controller.currentTabIndex.value != 0) ...[
Row(
children: [
_buildInfoChip(
Icons.credit_card_outlined,
'NIK: ${penyewa['nik'] ?? 'Tidak tersedia'}',
),
const SizedBox(width: 8),
_buildInfoChip(
Icons.phone_outlined,
penyewa['no_hp'] ?? 'No. HP tidak tersedia',
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
_buildInfoTile(
'Total Sewa',
penyewa['total_sewa']?.toString() ?? '0',
Icons.shopping_bag_outlined,
AppColorsPetugas.blueGrotto,
),
if (controller.currentTabIndex.value == 1 ||
controller.currentTabIndex.value == 2)
_buildActionChip(
label: 'Lihat Detail',
color: AppColorsPetugas.blueGrotto,
icon: Icons.visibility,
onTap: () {
Get.toNamed(
Routes.PETUGAS_DETAIL_PENYEWA,
arguments: {
'userId': penyewa['user_id'],
},
);
},
),
],
),
],
// Add "Detail" button for Verifikasi tab
if (controller.currentTabIndex.value == 0) ...[
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: _buildActionChip(
label: 'Lihat Detail & Verifikasi',
color: AppColorsPetugas.blueGrotto,
icon: Icons.visibility,
onTap: () {
Get.toNamed(
Routes.PETUGAS_DETAIL_PENYEWA,
arguments: {
'userId': penyewa['user_id'],
},
);
},
),
),
],
),
],
],
),
),
],
),
),
),
),
);
},
),
);
}
Widget _buildInfoChip(IconData icon, String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.grey.shade200),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildActionChip({
required String label,
required Color color,
required VoidCallback onTap,
IconData? icon,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withOpacity(0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
],
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
),
);
}
Widget _buildInfoTile(
String label,
String value,
IconData icon,
Color color,
) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: color.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 14, color: color),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
],
),
);
}
Widget _buildStatusBadge(String? status) {
Color bgColor;
Color textColor;
String label;
switch (status?.toLowerCase()) {
case 'active':
bgColor = Colors.green[100]!;
textColor = Colors.green[800]!;
label = 'Aktif';
break;
case 'pending':
bgColor = Colors.orange[100]!;
textColor = Colors.orange[800]!;
label = 'Menunggu';
break;
case 'suspended':
bgColor = Colors.red[100]!;
textColor = Colors.red[800]!;
label = 'Dinonaktifkan';
break;
default:
bgColor = Colors.grey[100]!;
textColor = Colors.grey[800]!;
label = 'Tidak diketahui';
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(20),
),
child: Text(
label,
style: TextStyle(
color: textColor,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
);
}
void _showApproveDialog(BuildContext context, String userId) {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Konfirmasi Aktivasi'),
content: const Text(
'Apakah Anda yakin ingin mengaktifkan penyewa ini?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
controller.updatePenyewaStatus(
userId,
'active',
'Akun diaktifkan oleh petugas',
);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
child: const Text('Aktifkan'),
),
],
),
);
}
void _showRejectDialog(BuildContext context, String userId) {
final reasonController = TextEditingController();
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Konfirmasi Penolakan'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Apakah Anda yakin ingin menolak penyewa ini?'),
const SizedBox(height: 16),
TextField(
controller: reasonController,
decoration: const InputDecoration(
labelText: 'Alasan Penolakan',
border: OutlineInputBorder(),
),
maxLines: 3,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
final reason =
reasonController.text.isNotEmpty
? reasonController.text
: 'Ditolak oleh petugas';
controller.updatePenyewaStatus(userId, 'suspended', reason);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Tolak'),
),
],
),
);
}
}

View File

@ -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<PetugasSewaView>
}
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<PetugasSewaView>
],
),
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<PetugasSewaView>
),
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<PetugasSewaView>
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<PetugasSewaView>
});
}
Widget _buildSewaCard(BuildContext context, Map<String, dynamic> 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<PetugasSewaView>
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<PetugasSewaView>
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,79 +460,47 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
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,
),
),
],
),
),
// Price
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: AppColorsPetugas.blueGrotto.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
controller.formatPrice(sewa['total_biaya']),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.blueGrotto,
// Price - only show if total_tagihan > 0
if (sewa.totalTagihan > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: AppColorsPetugas.blueGrotto.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
controller.formatPrice(sewa.totalTagihan),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.blueGrotto,
),
),
),
),
],
),
),
@ -481,33 +514,51 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
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 +575,7 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
),
const SizedBox(width: 4),
Text(
'${sewa['tanggal_mulai']} - ${sewa['tanggal_selesai']}',
_formatDateRange(sewa),
style: TextStyle(
fontSize: 12,
color: AppColorsPetugas.textSecondary,
@ -552,6 +603,37 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
);
}
String _formatDateRange(SewaModel sewa) {
final startDate = sewa.waktuMulai;
final endDate = sewa.waktuSelesai;
// Format dates as dd-mm-yyyy
String formattedStartDate =
'${startDate.day.toString().padLeft(2, '0')}-${startDate.month.toString().padLeft(2, '0')}-${startDate.year}';
String formattedEndDate =
'${endDate.day.toString().padLeft(2, '0')}-${endDate.month.toString().padLeft(2, '0')}-${endDate.year}';
// Check if rental unit is "jam" (hour)
if (sewa.namaSatuanWaktu?.toLowerCase() == 'jam') {
// Format as "dd-mm-yyyy icon jam 09.00-15.00"
String startTime =
'${startDate.hour.toString().padLeft(2, '0')}.${startDate.minute.toString().padLeft(2, '0')}';
String endTime =
'${endDate.hour.toString().padLeft(2, '0')}.${endDate.minute.toString().padLeft(2, '0')}';
return '$formattedStartDate$startTime-$endTime';
}
// If same day but not hourly, just show the date
else if (startDate.day == endDate.day &&
startDate.month == endDate.month &&
startDate.year == endDate.year) {
return formattedStartDate;
}
// Different days - show date range
else {
return '$formattedStartDate - $formattedEndDate';
}
}
void _showFilterBottomSheet() {
Get.bottomSheet(
Container(

View File

@ -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<PetugasTambahAsetController> {
@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<Color>(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<PetugasTambahAsetController> {
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<PetugasTambahAsetController> {
_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<PetugasTambahAsetController> {
);
}
// 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<PetugasTambahAsetController> {
children: [
// Add button
GestureDetector(
onTap: () => controller.addSampleImage(),
onTap: _showImageSourceOptions,
child: Container(
width: 100,
height: 100,
@ -732,69 +785,107 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
),
// 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<Widget>.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<File>(
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<PetugasTambahAsetController> {
),
)
: 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,

View File

@ -64,6 +64,14 @@ class PetugasBumdesBottomNavbar extends StatelessWidget {
isSelected: selectedIndex == 3,
onTap: () => onItemTapped(3),
),
_buildNavItem(
context: context,
icon: Icons.people_outlined,
activeIcon: Icons.people,
label: 'Penyewa',
isSelected: selectedIndex == 4,
onTap: () => onItemTapped(4),
),
],
),
);
@ -79,7 +87,7 @@ class PetugasBumdesBottomNavbar extends StatelessWidget {
required VoidCallback onTap,
}) {
final primaryColor = AppColors.primary;
final tabWidth = MediaQuery.of(context).size.width / 4;
final tabWidth = MediaQuery.of(context).size.width / 5;
return Material(
color: Colors.transparent,

View File

@ -1,7 +1,9 @@
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';
import '../../../routes/app_routes.dart';
class PetugasSideNavbar extends StatelessWidget {
final PetugasBumdesDashboardController controller;
@ -11,7 +13,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 +34,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(
@ -125,6 +149,30 @@ class PetugasSideNavbar extends StatelessWidget {
isSelected: controller.currentTabIndex.value == 3,
onTap: () => controller.changeTab(3),
),
_buildMenuItem(
icon: Icons.people_outlined,
activeIcon: Icons.people,
title: 'Penyewa',
subtitle: 'Kelola data penyewa',
isSelected: controller.currentTabIndex.value == 4,
onTap: () => controller.changeTab(4),
),
_buildMenuItem(
icon: Icons.account_balance_outlined,
activeIcon: Icons.account_balance,
title: 'Kelola Akun Bank',
subtitle: 'Kelola akun bank BUMDes',
isSelected: false,
onTap: () => Get.toNamed(Routes.PETUGAS_AKUN_BANK),
),
_buildMenuItem(
icon: Icons.bar_chart_outlined,
activeIcon: Icons.bar_chart,
title: 'Laporan Bulanan',
subtitle: 'Cetak laporan bulanan',
isSelected: false,
onTap: () => Get.toNamed(Routes.PETUGAS_LAPORAN),
),
],
),
);

View File

@ -41,7 +41,7 @@ class PetugasMitraDashboardController extends GetxController {
Get.snackbar(
'Error',
'Gagal keluar dari aplikasi',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
}

View File

@ -34,7 +34,7 @@ class SplashView extends GetView<SplashController> {
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,
),

View File

@ -1,7 +1,6 @@
import 'package:get/get.dart';
import '../controllers/order_sewa_paket_controller.dart';
import '../../../data/providers/aset_provider.dart';
import '../../../data/providers/sewa_provider.dart';
class OrderSewaPaketBinding extends Bindings {
@override
@ -11,10 +10,6 @@ class OrderSewaPaketBinding extends Bindings {
Get.put(AsetProvider());
}
if (!Get.isRegistered<SewaProvider>()) {
Get.put(SewaProvider());
}
Get.lazyPut<OrderSewaPaketController>(
() => OrderSewaPaketController(),
);

View File

@ -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<NavigationService>()) {
final navService = Get.find<NavigationService>();
navService.setNavIndex(1); // Set to Sewa tab
}
// Ensure AuthProvider is registered
if (!Get.isRegistered<AuthProvider>()) {
Get.put(AuthProvider(), permanent: true);
}
// Ensure AsetProvider is registered
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);

View File

@ -378,7 +378,7 @@ class PembayaranSewaController extends GetxController
Get.snackbar(
'Pesanan Dibatalkan',
'Batas waktu pembayaran telah berakhir',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
duration: Duration(seconds: 5),
@ -417,7 +417,7 @@ class PembayaranSewaController extends GetxController
Get.snackbar(
'Error',
'Gagal mengambil foto: ${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -443,7 +443,7 @@ class PembayaranSewaController extends GetxController
Get.snackbar(
'Error',
'Gagal memilih foto dari galeri: ${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -459,7 +459,7 @@ class PembayaranSewaController extends GetxController
Get.snackbar(
'Error',
'Mohon unggah bukti pembayaran terlebih dahulu',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -541,7 +541,7 @@ class PembayaranSewaController extends GetxController
Get.snackbar(
'Sukses',
'Bukti pembayaran berhasil diunggah',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.green,
colorText: Colors.white,
);
@ -550,7 +550,7 @@ class PembayaranSewaController extends GetxController
Get.snackbar(
'Error',
'Gagal mengunggah bukti pembayaran: ${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -654,7 +654,7 @@ class PembayaranSewaController extends GetxController
Get.snackbar(
'Sukses',
'Pembayaran tunai berhasil disubmit',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.green,
colorText: Colors.white,
);
@ -1090,7 +1090,7 @@ class PembayaranSewaController extends GetxController
Get.snackbar(
'Berhasil',
'Data berhasil diperbarui',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.green,
colorText: Colors.white,
duration: const Duration(seconds: 2),
@ -1104,7 +1104,7 @@ class PembayaranSewaController extends GetxController
Get.snackbar(
'Error',
'Gagal memperbarui data',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);

View File

@ -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
@ -155,7 +168,7 @@ class SewaAsetController extends GetxController
Get.snackbar(
'Error',
'Terjadi kesalahan saat memuat data aset',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -266,7 +279,7 @@ class SewaAsetController extends GetxController
Get.snackbar(
'Error',
message,
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -308,7 +321,7 @@ class SewaAsetController extends GetxController
Get.snackbar(
'Sukses',
'Pesanan berhasil dibuat',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.green,
colorText: Colors.white,
);
@ -370,7 +383,7 @@ class SewaAsetController extends GetxController
Get.snackbar(
'Error',
'Terjadi kesalahan saat memuat data paket',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -416,7 +429,7 @@ class SewaAsetController extends GetxController
Get.snackbar(
'Error',
'Gagal memuat data paket. Silakan coba lagi nanti.',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -457,7 +470,7 @@ class SewaAsetController extends GetxController
Get.snackbar(
'Sukses',
'Pesanan paket berhasil dibuat',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.green,
colorText: Colors.white,
);

View File

@ -1,12 +1,20 @@
import 'package:get/get.dart';
import 'package:flutter/material.dart';
import '../../../data/providers/auth_provider.dart';
import '../../../routes/app_routes.dart';
import '../../../services/navigation_service.dart';
import '../../../data/providers/aset_provider.dart';
import '../../../theme/app_colors.dart';
import 'package:intl/intl.dart';
import 'package:flutter/foundation.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:image_picker/image_picker.dart';
class WargaDashboardController extends GetxController {
// Dependency injection
final AuthProvider _authProvider = Get.find<AuthProvider>();
final NavigationService navigationService = Get.find<NavigationService>();
final AsetProvider _asetProvider = Get.find<AsetProvider>();
// User data
final userName = 'Pengguna Warga'.obs;
@ -16,6 +24,10 @@ class WargaDashboardController extends GetxController {
final userNik = ''.obs;
final userPhone = ''.obs;
final userAddress = ''.obs;
final userTanggalLahir = ''.obs;
final userRtRw = ''.obs;
final userKelurahanDesa = ''.obs;
final userKecamatan = ''.obs;
// Navigation state is now managed by NavigationService
@ -28,24 +40,57 @@ class WargaDashboardController extends GetxController {
// Active penalties
final activePenalties = <Map<String, dynamic>>[].obs;
// Summary counts
final diterimaCount = 0.obs;
final tagihanAktifCount = 0.obs;
final dendaAktifCount = 0.obs;
@override
void onInit() {
void onInit() async {
super.onInit();
// Set navigation index to Home (0)
navigationService.setNavIndex(0);
// Load user data
_loadUserData();
// Check if navigation is coming from login
final args = Get.arguments;
final bool isFromLogin = args != null && args['from_login'] == true;
// Load sample data
_loadSampleData();
if (isFromLogin) {
print('onInit: Navigation from login detected, prioritizing data fetch');
}
// Load dummy data for bills and penalties
loadDummyData();
// Verifikasi bahwa pengguna sudah login sebelum melakukan fetch data
if (_authProvider.currentUser != null) {
// Prioritize loading user profile data first
await fetchProfileFromWargaDesa();
// Load unpaid rentals
loadUnpaidRentals();
// If the profile data was not loaded successfully, try again after a short delay
if (userName.value == 'Pengguna Warga' || userNik.value.isEmpty) {
print('onInit: Profile data not loaded, retrying after delay');
await Future.delayed(const Duration(milliseconds: 800));
await fetchProfileFromWargaDesa();
}
// Load other user data
await _loadUserData();
// Load other data in parallel to speed up the dashboard initialization
Future.wait([
_loadSampleData(),
loadDummyData(),
loadUnpaidRentals(),
_debugCountSewaAset(),
loadActiveRentals(),
]).then((_) => print('onInit: All data loaded successfully'));
// If coming from login, make sure UI is updated
if (isFromLogin) {
update();
}
} else {
print('onInit: User not logged in, skipping data fetch');
}
}
Future<void> _loadUserData() async {
@ -75,12 +120,24 @@ class WargaDashboardController extends GetxController {
userNik.value = await _authProvider.getUserNIK() ?? '';
userPhone.value = await _authProvider.getUserPhone() ?? '';
userAddress.value = await _authProvider.getUserAddress() ?? '';
// Load additional profile data
final tanggalLahir = await _authProvider.getUserTanggalLahir();
final rtRw = await _authProvider.getUserRtRw();
final kelurahanDesa = await _authProvider.getUserKelurahanDesa();
final kecamatan = await _authProvider.getUserKecamatan();
// Set values for additional profile data
userTanggalLahir.value = tanggalLahir ?? 'Tidak tersedia';
userRtRw.value = rtRw ?? 'Tidak tersedia';
userKelurahanDesa.value = kelurahanDesa ?? 'Tidak tersedia';
userKecamatan.value = kecamatan ?? 'Tidak tersedia';
} catch (e) {
print('Error loading user data: $e');
}
}
void _loadSampleData() {
Future<void> _loadSampleData() async {
// Clear any existing data
activeRentals.clear();
@ -111,10 +168,37 @@ class WargaDashboardController extends GetxController {
navigationService.toSewaAset();
}
void refreshData() {
// Refresh data from repository
_loadSampleData();
loadDummyData();
Future<void> refreshData() async {
print('refreshData: Refreshing dashboard data');
try {
// First fetch profile data
await fetchProfileFromWargaDesa();
await _loadUserData();
// Then load all other data in parallel
await Future.wait([
_loadSampleData(),
loadDummyData(),
loadUnpaidRentals(),
loadActiveRentals(),
_debugCountSewaAset(),
]);
// Update UI
update();
print('refreshData: Dashboard data refreshed successfully');
} catch (e) {
print('refreshData: Error refreshing data: $e');
// Show error message to user
Get.snackbar(
'Perhatian',
'Terjadi kesalahan saat memuat data',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red.shade100,
colorText: Colors.red.shade900,
duration: const Duration(seconds: 3),
);
}
}
void onNavItemTapped(int index) {
@ -129,18 +213,25 @@ 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 logout() async {
await _authProvider.signOut();
navigationService.toLogin();
void toWargaSewaTabAktif() {
// Navigasi ke halaman warga sewa dan tab Aktif (index 3)
Get.toNamed(Routes.WARGA_SEWA, arguments: {'tab': 3});
}
void loadDummyData() {
Future<void> logout() async {
print('logout: Logging out user');
await _authProvider.signOut();
navigationService.toLogin();
print('logout: User logged out and redirected to login screen');
}
Future<void> loadDummyData() async {
// Dummy active bills
activeBills.clear();
activeBills.add({
@ -177,4 +268,555 @@ class WargaDashboardController extends GetxController {
print('Error loading unpaid rentals: $e');
}
}
Future<void> _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<void> 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<void> fetchProfileFromWargaDesa() async {
try {
final user = _authProvider.currentUser;
if (user == null) {
print(
'fetchProfileFromWargaDesa: No current user found, skipping fetch',
);
return; // Exit early if no user is logged in
}
final userId = user.id;
print('fetchProfileFromWargaDesa: Fetching data for user: $userId');
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) {
print('fetchProfileFromWargaDesa: Data retrieved successfully');
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() ?? '';
// Trigger UI refresh
update();
print('fetchProfileFromWargaDesa: Profile data updated');
} else {
print('fetchProfileFromWargaDesa: No data found for user: $userId');
}
} catch (e) {
print('Error fetching profile from warga_desa: $e');
// If it fails, try again after a delay
await Future.delayed(const Duration(seconds: 1));
try {
await _retryFetchProfile();
} catch (retryError) {
print('Retry error fetching profile: $retryError');
}
}
}
// Helper method to retry fetching profile
Future<void> _retryFetchProfile() async {
final user = _authProvider.currentUser;
if (user == null) {
print('_retryFetchProfile: No current user found, skipping retry');
return; // Exit early if no user is logged in
}
print('_retryFetchProfile: Retrying fetch for user: ${user.id}');
final data =
await _authProvider.client
.from('warga_desa')
.select('nik, alamat, email, nama_lengkap, no_hp, avatar')
.eq('user_id', user.id)
.maybeSingle();
if (data != null) {
print('_retryFetchProfile: Data retrieved successfully on retry');
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() ?? '';
update();
print('_retryFetchProfile: Profile data updated');
}
}
// Method to update user profile data in warga_desa table
Future<bool> updateUserProfile({
required String namaLengkap,
required String noHp,
}) async {
try {
final user = _authProvider.currentUser;
if (user == null) {
print('Cannot update profile: No current user');
return false;
}
final userId = user.id;
// Update data in warga_desa table
await _authProvider.client
.from('warga_desa')
.update({'nama_lengkap': namaLengkap, 'no_hp': noHp})
.eq('user_id', userId);
// Update local values
userName.value = namaLengkap;
userPhone.value = noHp;
print('Profile updated successfully for user: $userId');
return true;
} catch (e) {
print('Error updating user profile: $e');
return false;
}
}
// Method to delete user avatar
Future<bool> deleteUserAvatar() async {
try {
final user = _authProvider.currentUser;
if (user == null) {
print('Cannot delete avatar: No current user');
return false;
}
final userId = user.id;
final currentAvatarUrl = userAvatar.value;
// If there's an avatar URL, delete it from storage
if (currentAvatarUrl != null && currentAvatarUrl.isNotEmpty) {
try {
print('Attempting to delete avatar from URL: $currentAvatarUrl');
// Extract filename from URL
// The URL format is typically:
// https://[project-ref].supabase.co/storage/v1/object/public/warga/[filename]
final uri = Uri.parse(currentAvatarUrl);
final path = uri.path;
// Find the filename after the last slash
final filename = path.substring(path.lastIndexOf('/') + 1);
if (filename.isNotEmpty) {
print('Extracted filename: $filename');
// Delete from storage bucket 'warga'
final response = await _authProvider.client.storage
.from('warga')
.remove([filename]);
print('Storage deletion response: $response');
} else {
print('Failed to extract filename from avatar URL');
}
} catch (e) {
print('Error deleting avatar from storage: $e');
// Continue with database update even if storage delete fails
}
}
// Update warga_desa table to set avatar to null
await _authProvider.client
.from('warga_desa')
.update({'avatar': null})
.eq('user_id', userId);
// Update local value
userAvatar.value = '';
print('Avatar deleted successfully for user: $userId');
return true;
} catch (e) {
print('Error deleting user avatar: $e');
return false;
}
}
// Method to update user avatar URL
Future<bool> updateUserAvatar(String avatarUrl) async {
try {
final user = _authProvider.currentUser;
if (user == null) {
print('Cannot update avatar: No current user');
return false;
}
final userId = user.id;
// Update data in warga_desa table
await _authProvider.client
.from('warga_desa')
.update({'avatar': avatarUrl})
.eq('user_id', userId);
// Update local value
userAvatar.value = avatarUrl;
print('Avatar updated successfully for user: $userId');
return true;
} catch (e) {
print('Error updating user avatar: $e');
return false;
}
}
// Method to upload avatar image to Supabase storage
Future<String?> uploadAvatar(Uint8List fileBytes, String fileName) async {
try {
final user = _authProvider.currentUser;
if (user == null) {
print('Cannot upload avatar: No current user');
return null;
}
// Generate a unique filename using timestamp and user ID
final timestamp = DateTime.now().millisecondsSinceEpoch;
final extension = fileName.split('.').last;
final uniqueFileName = 'avatar_${user.id}_$timestamp.$extension';
// Upload to 'warga' bucket
final response = await _authProvider.client.storage
.from('warga')
.uploadBinary(
uniqueFileName,
fileBytes,
fileOptions: const FileOptions(cacheControl: '3600', upsert: true),
);
// Get the public URL
final publicUrl = _authProvider.client.storage
.from('warga')
.getPublicUrl(uniqueFileName);
print('Avatar uploaded successfully: $publicUrl');
return publicUrl;
} catch (e) {
print('Error uploading avatar: $e');
return null;
}
}
// Method to handle image picking from camera or gallery
Future<XFile?> pickImage(ImageSource source) async {
try {
// Pick image directly without permission checks
final ImagePicker picker = ImagePicker();
final XFile? pickedFile = await picker.pickImage(
source: source,
maxWidth: 800,
maxHeight: 800,
imageQuality: 85,
);
if (pickedFile != null) {
print('Image picked: ${pickedFile.path}');
}
return pickedFile;
} catch (e) {
print('Error picking image: $e');
// Show error message if there's an issue
Get.snackbar(
'Gagal',
'Tidak dapat mengakses ${source == ImageSource.camera ? 'kamera' : 'galeri'}',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red.shade700,
colorText: Colors.white,
duration: const Duration(seconds: 3),
);
return null;
}
}
// Method to show image source selection dialog
Future<void> showImageSourceDialog() async {
await Get.bottomSheet(
Container(
padding: const EdgeInsets.symmetric(vertical: 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
const Text(
'Pilih Sumber Gambar',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildImageSourceOption(
icon: Icons.camera_alt_rounded,
label: 'Kamera',
onTap: () async {
Get.back();
final pickedFile = await pickImage(ImageSource.camera);
if (pickedFile != null) {
await processPickedImage(pickedFile);
}
},
),
_buildImageSourceOption(
icon: Icons.photo_library_rounded,
label: 'Galeri',
onTap: () async {
Get.back();
final pickedFile = await pickImage(ImageSource.gallery);
if (pickedFile != null) {
await processPickedImage(pickedFile);
}
},
),
],
),
const SizedBox(height: 20),
],
),
),
isDismissible: true,
enableDrag: true,
);
}
// Helper method to build image source option
Widget _buildImageSourceOption({
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, color: AppColors.primary, size: 32),
),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey.shade800,
),
),
],
),
);
}
// Method to process picked image (temporary preview before saving)
Future<void> processPickedImage(XFile pickedFile) async {
try {
// Read file as bytes
final bytes = await pickedFile.readAsBytes();
// Store the picked file temporarily for later use when saving
tempPickedFile.value = pickedFile;
// Update UI with temporary avatar preview
tempAvatarBytes.value = bytes;
print('Image processed for preview');
} catch (e) {
print('Error processing picked image: $e');
}
}
// Method to save the picked image to Supabase and update profile
Future<bool> saveNewAvatar() async {
try {
if (tempPickedFile.value == null || tempAvatarBytes.value == null) {
print('No temporary image to save');
return false;
}
final pickedFile = tempPickedFile.value!;
final bytes = tempAvatarBytes.value!;
// First delete the old avatar if exists
final currentAvatarUrl = userAvatar.value;
if (currentAvatarUrl != null && currentAvatarUrl.isNotEmpty) {
try {
await deleteUserAvatar();
} catch (e) {
print('Error deleting old avatar: $e');
// Continue with upload even if delete fails
}
}
// Upload new avatar
final newAvatarUrl = await uploadAvatar(bytes, pickedFile.name);
if (newAvatarUrl == null) {
print('Failed to upload new avatar');
return false;
}
// Update avatar URL in database
final success = await updateUserAvatar(newAvatarUrl);
if (success) {
// Clear temporary data
tempPickedFile.value = null;
tempAvatarBytes.value = null;
print('Avatar updated successfully');
}
return success;
} catch (e) {
print('Error saving new avatar: $e');
return false;
}
}
// Method to cancel avatar change
void cancelAvatarChange() {
tempPickedFile.value = null;
tempAvatarBytes.value = null;
print('Avatar change canceled');
}
// Temporary storage for picked image
final Rx<XFile?> tempPickedFile = Rx<XFile?>(null);
final Rx<Uint8List?> tempAvatarBytes = Rx<Uint8List?>(null);
}

View File

@ -12,10 +12,10 @@ class WargaSewaController extends GetxController
// Get navigation service
final NavigationService navigationService = Get.find<NavigationService>();
// Get auth provider for user data and sewa_aset queries
final AuthProvider authProvider = Get.find<AuthProvider>();
// Get aset provider for asset data
final AsetProvider asetProvider = Get.find<AsetProvider>();
@ -25,33 +25,35 @@ class WargaSewaController extends GetxController
final acceptedRentals = <Map<String, dynamic>>[].obs;
final completedRentals = <Map<String, dynamic>>[].obs;
final cancelledRentals = <Map<String, dynamic>>[].obs;
final returnedRentals = <Map<String, dynamic>>[].obs;
final activeRentals = <Map<String, dynamic>>[].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
@ -107,110 +133,152 @@ class WargaSewaController extends GetxController
super.onClose();
}
// Helper method to process rental data
Future<Map<String, dynamic>> _processRentalData(
Map<String, dynamic> sewaAset,
) async {
// Get asset details if aset_id is available
String assetName = 'Aset';
String? imageUrl;
String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam';
// Check if this is a package or single asset rental
bool isPaket = sewaAset['aset_id'] == null && sewaAset['paket_id'] != null;
if (isPaket) {
// Use package data that was fetched in getSewaAsetByStatus
assetName = sewaAset['nama_paket'] ?? 'Paket';
imageUrl = sewaAset['foto_paket'];
debugPrint(
'Using package data: name=${assetName}, imageUrl=${imageUrl ?? "none"}',
);
} else if (sewaAset['aset_id'] != null) {
// Regular asset rental
final asetData = await asetProvider.getAsetById(sewaAset['aset_id']);
if (asetData != null) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
// Parse waktu mulai and waktu selesai
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']);
// 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
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
// For days, show date range
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
// Default format
rentangWaktu = '$jamMulai - $jamSelesai';
}
// Full time format for waktuSewa
waktuSewa =
'${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
// Format price
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
// Return processed rental data
return {
'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'] ?? '',
'totalPrice': totalPrice,
'countdown': '00:59:59', // Default countdown
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
'updated_at': sewaAset['updated_at'],
'isPaket': isPaket,
'paketId': isPaket ? sewaAset['paket_id'] : null,
};
}
// Load real data from sewa_aset table
Future<void> 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');
// Debug the structure of the first record if available
if (sewaAsetList.isNotEmpty) {
debugPrint('Sample sewa_aset record: ${sewaAsetList.first}');
debugPrint('updated_at field: ${sewaAsetList.first['updated_at']}');
}
// 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) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
// Parse waktu mulai and waktu selesai
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']);
// 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
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
// For days, show date range
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
// Default format
rentangWaktu = '$jamMulai - $jamSelesai';
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
// Format price
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
final processedData = await _processRentalData(sewaAset);
processedData['status'] = sewaAset['status'] ?? 'MENUNGGU PEMBAYARAN';
// Ensure updated_at is set correctly
if (sewaAset['updated_at'] == null &&
processedData['status'] == 'MENUNGGU PEMBAYARAN') {
// If updated_at is null but status is MENUNGGU PEMBAYARAN, use created_at as fallback
processedData['updated_at'] =
sewaAset['created_at'] ?? DateTime.now().toIso8601String();
debugPrint(
'Using created_at as fallback for updated_at: ${processedData['updated_at']}',
);
totalPrice = formatter.format(sewaAset['total']);
}
// Add to rentals list
rentals.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'] ?? 'MENUNGGU PEMBAYARAN',
'totalPrice': totalPrice,
'countdown': '00:59:59', // Default countdown
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
});
rentals.add(processedData);
}
debugPrint('Processed ${rentals.length} rental records');
} catch (e) {
debugPrint('Error loading rentals data: $e');
@ -245,353 +313,237 @@ 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<bool>(
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.TOP,
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.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
}
}
// Navigate to payment page with the selected rental data
void viewRentalDetail(Map<String, dynamic> 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},
);
}
// Navigate directly to payment tab of payment page with the selected rental data
void viewPaymentTab(Map<String, dynamic> rental) {
debugPrint('Navigating to payment tab with rental ID: ${rental['id']}');
// Navigate to payment page with rental data and initialTab set to 2 (payment tab)
Get.toNamed(
Routes.PEMBAYARAN_SEWA,
arguments: {
'orderId': rental['id'],
'rentalData': rental,
'initialTab': 2, // Index 2 corresponds to the payment tab
'isPaket': rental['isPaket'] ?? false,
'paketId': rental['paketId'],
},
);
}
void payRental(String id) {
Get.snackbar(
'Info',
'Navigasi ke halaman pembayaran',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
);
}
// Load data for the Selesai tab (status: SELESAI)
Future<void> 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) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
// Parse waktu mulai and waktu selesai
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']);
// 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
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
// For days, show date range
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
// Default format
rentangWaktu = '$jamMulai - $jamSelesai';
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
// Format price
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
// Add to completed rentals list
completedRentals.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'] ?? 'SELESAI',
'totalPrice': totalPrice,
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
});
final processedData = await _processRentalData(sewaAset);
processedData['status'] = sewaAset['status'] ?? 'SELESAI';
completedRentals.add(processedData);
}
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<void> 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) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
// Parse waktu mulai and waktu selesai
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']);
// 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
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
// For days, show date range
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
// Default format
rentangWaktu = '$jamMulai - $jamSelesai';
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
// Format price
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
// Add to cancelled rentals list
cancelledRentals.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'] ?? 'DIBATALKAN',
'totalPrice': totalPrice,
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
'alasanPembatalan': sewaAset['alasan_pembatalan'] ?? '-',
});
final processedData = await _processRentalData(sewaAset);
processedData['status'] = sewaAset['status'] ?? 'DIBATALKAN';
processedData['alasanPembatalan'] =
sewaAset['alasan_pembatalan'] ?? '-';
cancelledRentals.add(processedData);
}
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 Dikembalikan tab (status: DIKEMBALIKAN)
Future<void> loadReturnedRentals() async {
try {
isLoadingReturned.value = true;
// Clear existing data
returnedRentals.clear();
// Get sewa_aset data with status "DIKEMBALIKAN"
final sewaAsetList = await authProvider.getSewaAsetByStatus([
'DIKEMBALIKAN',
]);
debugPrint('Fetched ${sewaAsetList.length} returned sewa_aset records');
// Process each sewa_aset record
for (var sewaAset in sewaAsetList) {
final processedData = await _processRentalData(sewaAset);
processedData['status'] = sewaAset['status'] ?? 'DIKEMBALIKAN';
returnedRentals.add(processedData);
}
debugPrint('Processed ${returnedRentals.length} returned rental records');
} catch (e) {
debugPrint('Error loading returned rentals data: $e');
} finally {
isLoadingReturned.value = false;
}
}
// Load data for the Aktif tab (status: AKTIF)
Future<void> loadActiveRentals() async {
try {
isLoadingActive.value = true;
// Clear existing data
activeRentals.clear();
// Get sewa_aset data with status "AKTIF"
final sewaAsetList = await authProvider.getSewaAsetByStatus(['AKTIF']);
debugPrint('Fetched ${sewaAsetList.length} active sewa_aset records');
// Process each sewa_aset record
for (var sewaAset in sewaAsetList) {
final processedData = await _processRentalData(sewaAset);
processedData['status'] = sewaAset['status'] ?? 'AKTIF';
activeRentals.add(processedData);
}
debugPrint('Processed ${activeRentals.length} active rental records');
} catch (e) {
debugPrint('Error loading active rentals data: $e');
} finally {
isLoadingActive.value = false;
}
}
// Load data for the Pending tab (status: PERIKSA PEMBAYARAN)
Future<void> 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) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
// Parse waktu mulai and waktu selesai
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']);
// 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
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
// For days, show date range
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
// Default format
rentangWaktu = '$jamMulai - $jamSelesai';
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
// Format price
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
// Add to pending rentals list
pendingRentals.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'] ?? 'PERIKSA PEMBAYARAN',
'totalPrice': totalPrice,
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
});
final processedData = await _processRentalData(sewaAset);
processedData['status'] = sewaAset['status'] ?? 'PERIKSA PEMBAYARAN';
pendingRentals.add(processedData);
}
debugPrint('Processed ${pendingRentals.length} pending rental records');
} catch (e) {
debugPrint('Error loading pending rentals data: $e');
@ -599,107 +551,27 @@ class WargaSewaController extends GetxController
isLoadingPending.value = false;
}
}
// Load data for the Diterima tab (status: DITERIMA)
Future<void> 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) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
// Parse waktu mulai and waktu selesai
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']);
// 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
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
// For days, show date range
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
// Default format
rentangWaktu = '$jamMulai - $jamSelesai';
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
// Format price
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
// Add to accepted rentals list
acceptedRentals.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'] ?? 'DITERIMA',
'totalPrice': totalPrice,
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
});
final processedData = await _processRentalData(sewaAset);
processedData['status'] = sewaAset['status'] ?? 'DITERIMA';
acceptedRentals.add(processedData);
}
debugPrint('Processed ${acceptedRentals.length} accepted rental records');
} catch (e) {
debugPrint('Error loading accepted rentals data: $e');

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -318,7 +318,7 @@
Get.snackbar(
'Perhatian',
'Pilih jam mulai terlebih dahulu',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: AppColors.warning,
colorText: Colors.white,
);

File diff suppressed because it is too large Load Diff

View File

@ -68,7 +68,7 @@ class SewaAsetView extends GetView<SewaAsetController> {
child: TextField(
controller: controller.searchController,
decoration: InputDecoration(
hintText: 'Cari aset...',
hintText: 'Cari aset atau paket...',
hintStyle: TextStyle(color: Colors.grey[400]),
prefixIcon: Icon(Icons.search, color: Colors.grey[600]),
border: InputBorder.none,
@ -117,6 +117,7 @@ class SewaAsetView extends GetView<SewaAsetController> {
),
],
),
dividerColor: Colors.transparent,
labelColor: Colors.white,
unselectedLabelColor: const Color(
0xFF718093,
@ -363,246 +364,271 @@ class SewaAsetView extends GetView<SewaAsetController> {
);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.50, // Make cards taller to avoid overflow
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: controller.filteredPakets.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
final paket = controller.filteredPakets[index];
final List<dynamic> satuanWaktuSewa =
paket['satuanWaktuSewa'] ?? [];
return RefreshIndicator(
onRefresh: controller.loadPakets,
color: const Color(0xFF3A6EA5), // Primary blue
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: GridView.builder(
padding: const EdgeInsets.only(top: 16.0, bottom: 16.0),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.50, // Make cards taller to avoid overflow
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: controller.filteredPakets.length,
itemBuilder: (context, index) {
final paket = controller.filteredPakets[index];
final List<dynamic> satuanWaktuSewa =
paket['satuanWaktuSewa'] ?? [];
// Find the lowest price
int lowestPrice =
satuanWaktuSewa.isEmpty
? 0
: satuanWaktuSewa
.map<int>((sws) => sws['harga'] ?? 0)
.reduce((a, b) => a < b ? a : b);
// Find the lowest price
int lowestPrice =
satuanWaktuSewa.isEmpty
? 0
: satuanWaktuSewa
.map<int>((sws) => sws['harga'] ?? 0)
.reduce((a, b) => a < b ? a : b);
// Get image URL or default
String imageUrl = paket['gambar_url'] ?? '';
// Get image URL or default
String imageUrl = paket['gambar_url'] ?? '';
return GestureDetector(
onTap: () => _showPaketDetailModal(paket),
child: Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12.0),
boxShadow: [
BoxShadow(
color: AppColors.shadow,
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image section
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
return GestureDetector(
onTap: () {
// No action when tapping on the card
},
child: Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12.0),
boxShadow: [
BoxShadow(
color: AppColors.shadow,
blurRadius: 10,
offset: const Offset(0, 2),
),
child: AspectRatio(
aspectRatio: 1.0,
child: CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
placeholder:
(context, url) => const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
Colors.purple,
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image section
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: AspectRatio(
aspectRatio: 1.0,
child: CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
placeholder:
(context, url) => const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
Colors.purple,
),
),
),
),
errorWidget:
(context, url, error) => Container(
color: Colors.grey[200],
child: Center(
child: Icon(
Icons.image_not_supported,
size: 32,
color: Colors.grey[400],
errorWidget:
(context, url, error) => Container(
color: Colors.grey[200],
child: Center(
child: Icon(
Icons.image_not_supported,
size: 32,
color: Colors.grey[400],
),
),
),
),
),
),
),
),
// Content section
Expanded(
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Package name
Text(
paket['nama'] ?? 'Paket',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
// Content section
Expanded(
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Package name
Text(
paket['nama'] ?? 'Paket',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
const SizedBox(height: 4),
// Status availability
Row(
children: [
// Status availability
Row(
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppColors.success,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
'Tersedia',
style: TextStyle(
color: AppColors.success,
fontWeight: FontWeight.w500,
fontSize: 11,
),
),
],
),
const SizedBox(height: 6),
// Package pricing - show all pricing options with scrolling
if (satuanWaktuSewa.isNotEmpty)
SizedBox(
width: double.infinity,
child: Wrap(
spacing: 4,
runSpacing: 4,
children: [
...satuanWaktuSewa.map((sws) {
// Pastikan data yang ditampilkan valid
final harga = sws['harga'] ?? 0;
final namaSatuan =
sws['nama_satuan_waktu'] ??
'Satuan';
return Container(
margin: const EdgeInsets.only(
bottom: 4,
),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(
4,
),
border: Border.all(
color: Colors.grey[300]!,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"Rp ${_formatNumber(harga)}",
style: const TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
Text(
"/$namaSatuan",
style: TextStyle(
color: Colors.grey[700],
fontSize: 10,
),
),
],
),
);
}),
],
),
)
else
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppColors.success,
shape: BoxShape.circle,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: Colors.grey[300]!,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Mulai dari Rp ${NumberFormat('#,###').format(lowestPrice)}',
style: const TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
],
),
),
const SizedBox(width: 4),
Text(
'Tersedia',
style: TextStyle(
color: AppColors.success,
fontWeight: FontWeight.w500,
fontSize: 11,
),
),
],
),
const SizedBox(height: 6),
// Package pricing - show all pricing options with scrolling
if (satuanWaktuSewa.isNotEmpty)
const Spacer(),
// Remove the items count badge and replace with direct Order button
SizedBox(
width: double.infinity,
child: Wrap(
spacing: 4,
runSpacing: 4,
children: [
...satuanWaktuSewa.map((sws) {
// Pastikan data yang ditampilkan valid
final harga = sws['harga'] ?? 0;
final namaSatuan =
sws['nama_satuan_waktu'] ?? 'Satuan';
return Container(
margin: const EdgeInsets.only(
bottom: 4,
),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(
4,
),
border: Border.all(
color: Colors.grey[300]!,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"Rp ${_formatNumber(harga)}",
style: const TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
Text(
"/$namaSatuan",
style: TextStyle(
color: Colors.grey[700],
fontSize: 10,
),
),
],
),
);
}).toList(),
],
),
)
else
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.grey[300]!),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Mulai dari Rp ${NumberFormat('#,###').format(lowestPrice)}',
style: const TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.bold,
fontSize: 11,
),
child: ElevatedButton(
onPressed: () {
// Navigate to order sewa aset page with package data and isPaket flag
Get.toNamed(
Routes.ORDER_SEWA_ASET,
arguments: {
'asetId': paket['id'],
'paketId': paket['id'],
'paketData': paket,
'satuanWaktuSewa': satuanWaktuSewa,
'isPaket':
true, // Add flag to indicate this is a package
},
preventDuplicates: false,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
],
),
),
const Spacer(),
// Remove the items count badge and replace with direct Order button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => _showPaketDetailModal(paket),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
padding: const EdgeInsets.symmetric(
vertical: 6,
),
minimumSize: const Size(
double.infinity,
30,
),
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
padding: const EdgeInsets.symmetric(
vertical: 6,
),
minimumSize: const Size(double.infinity, 30),
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
child: const Text(
'Pesan',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
child: const Text(
'Pesan',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
],
),
),
),
),
],
],
),
),
),
);
},
);
},
),
),
);
});
@ -893,35 +919,37 @@ class SewaAsetView extends GetView<SewaAsetController> {
// Order button
Padding(
padding: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.only(top: 16.0, bottom: 24.0),
child: SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: () {
if (satuanWaktuSewa.isEmpty) {
Get.snackbar(
'Tidak Dapat Memesan',
'Pilihan harga belum tersedia untuk paket ini',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red[100],
colorText: Colors.red[800],
);
return;
}
_showOrderPaketForm(paket, satuanWaktuSewa);
// Close the modal
Get.back();
// Navigate to order_sewa_paket page with package data
Get.toNamed(
Routes.ORDER_SEWA_PAKET,
arguments: {
'paket': paket,
'satuanWaktuSewa': satuanWaktuSewa,
},
);
},
style: ElevatedButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: AppColors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
child: const Text(
'Pesan Paket Ini',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
'Pesan Sekarang',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
@ -929,6 +957,9 @@ class SewaAsetView extends GetView<SewaAsetController> {
],
),
),
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: Colors.black54,
);
}
@ -945,10 +976,11 @@ class SewaAsetView extends GetView<SewaAsetController> {
final RxInt duration = RxInt(selectedSWS.value?['durasi_min'] ?? 1);
// Calculate total price
final calculateTotal = () {
calculateTotal() {
if (selectedSWS.value == null) return 0;
return (selectedSWS.value!['harga'] ?? 0) * duration.value;
};
}
final RxInt totalPrice = RxInt(calculateTotal());
// Update total when duration or pricing option changes
@ -1231,7 +1263,7 @@ class SewaAsetView extends GetView<SewaAsetController> {
],
),
Text(
'Minimum ${minDuration} ${namaSatuanWaktu.toLowerCase()}',
'Minimum $minDuration ${namaSatuanWaktu.toLowerCase()}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
@ -1285,20 +1317,12 @@ class SewaAsetView extends GetView<SewaAsetController> {
onPressed: () {
Get.back(); // Close the form
// Navigate to order_sewa_paket page
// Get the navigation service from the controller
final navigationService = controller.navigationService;
// Store the selected parameters in a controller or pass as arguments
Get.toNamed(
Routes.ORDER_SEWA_PAKET,
arguments: {
'paketId': paket['id'],
'satuanWaktuSewaId': selectedSWS.value?['id'] ?? '',
'durasi': duration.value,
'totalHarga': totalPrice.value,
'paketData': paket,
},
// Order the package
controller.placeOrderPaket(
paketId: paket['id'],
satuanWaktuSewaId: selectedSWS.value?['id'] ?? '',
durasi: duration.value,
totalHarga: totalPrice.value,
);
},
style: ElevatedButton.styleFrom(
@ -1500,7 +1524,7 @@ class SewaAsetView extends GetView<SewaAsetController> {
Get.snackbar(
'Error',
'ID aset tidak valid',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -1730,7 +1754,7 @@ class SewaAsetView extends GetView<SewaAsetController> {
Get.snackbar(
'Error',
'ID aset tidak valid',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
@ -1777,15 +1801,22 @@ class SewaAsetView extends GetView<SewaAsetController> {
Get.snackbar(
'Error',
'ID aset tidak valid',
snackPosition: SnackPosition.BOTTOM,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
// Use the static navigation method to ensure consistent behavior
OrderSewaAsetController.navigateToOrderPage(aset.id);
// Navigate to order page with asset ID and isAset flag
Get.toNamed(
Routes.ORDER_SEWA_ASET,
arguments: {
'asetId': aset.id,
'isAset': true, // Add flag to indicate this is a single asset
},
preventDuplicates: false,
);
}
// Helper to format numbers for display

Some files were not shown because too many files have changed in this diff Show More