fitur petugas

This commit is contained in:
Andreas Malvino
2025-06-22 09:25:58 +07:00
parent c4dd4fdfa2
commit 8284c93aa5
48 changed files with 8688 additions and 3436 deletions

View File

@ -5,6 +5,7 @@ class AsetModel {
final String nama; final String nama;
final String deskripsi; final String deskripsi;
final String kategori; final String kategori;
final String jenis; // Add this line
final int harga; final int harga;
final int? denda; final int? denda;
final String status; final String status;
@ -14,17 +15,21 @@ class AsetModel {
final int? kuantitasTerpakai; final int? kuantitasTerpakai;
final String? satuanUkur; final String? satuanUkur;
// Untuk menampung URL gambar pertama dari tabel foto_aset // URL gambar utama (untuk backward compatibility)
String? imageUrl; String? imageUrl;
// List untuk menyimpan semua URL gambar aset
final RxList<String> imageUrls = <String>[].obs;
// Menggunakan RxList untuk membuatnya mutable dan reaktif // 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({ AsetModel({
required this.id, required this.id,
required this.nama, required this.nama,
required this.deskripsi, required this.deskripsi,
required this.kategori, required this.kategori,
this.jenis = 'Sewa', // Add this line with default value
required this.harga, required this.harga,
this.denda, this.denda,
required this.status, 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) { factory AsetModel.fromJson(Map<String, dynamic> json) {
return AsetModel( final model = AsetModel(
id: json['id'] ?? '', id: json['id'] ?? '',
nama: json['nama'] ?? '', nama: json['nama'] ?? '',
deskripsi: json['deskripsi'] ?? '', deskripsi: json['deskripsi'] ?? '',
kategori: json['kategori'] ?? '', kategori: json['kategori'] ?? '',
jenis: json['jenis'] ?? 'Sewa',
harga: json['harga'] ?? 0, harga: json['harga'] ?? 0,
denda: json['denda'], denda: json['denda'],
status: json['status'] ?? '', status: json['status'] ?? '',
createdAt: createdAt: json['created_at'] != null
json['created_at'] != null
? DateTime.parse(json['created_at']) ? DateTime.parse(json['created_at'])
: null, : null,
updatedAt: updatedAt: json['updated_at'] != null
json['updated_at'] != null
? DateTime.parse(json['updated_at']) ? DateTime.parse(json['updated_at'])
: null, : null,
kuantitas: json['kuantitas'], kuantitas: json['kuantitas'],
kuantitasTerpakai: json['kuantitas_terpakai'], kuantitasTerpakai: json['kuantitas_terpakai'],
satuanUkur: json['satuan_ukur'], 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() { Map<String, dynamic> toJson() {
return { final data = <String, dynamic>{
'id': id, 'id': id,
'nama': nama, 'nama': nama,
'deskripsi': deskripsi, 'deskripsi': deskripsi,
@ -80,5 +123,23 @@ class AsetModel {
'kuantitas_terpakai': kuantitasTerpakai, 'kuantitas_terpakai': kuantitasTerpakai,
'satuan_ukur': satuanUkur, '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,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:developer' as developer;
class PaketModel { class PaketModel {
final String id; final String id;
@ -6,12 +7,13 @@ class PaketModel {
final String deskripsi; final String deskripsi;
final double harga; final double harga;
final int kuantitas; final int kuantitas;
final List<String> foto; final String status;
final List<Map<String, dynamic>> satuanWaktuSewa; List<String> foto;
List<Map<String, dynamic>> satuanWaktuSewa;
final DateTime createdAt; final DateTime createdAt;
final DateTime updatedAt; final DateTime updatedAt;
final String? foto_paket; // Main photo URL String? foto_paket; // Main photo URL
final List<String>? images; // List of photo URLs List<String>? images; // List of photo URLs
PaketModel({ PaketModel({
required this.id, required this.id,
@ -19,13 +21,47 @@ class PaketModel {
required this.deskripsi, required this.deskripsi,
required this.harga, required this.harga,
required this.kuantitas, required this.kuantitas,
required this.foto, this.status = 'aktif',
required this.satuanWaktuSewa, required List<String> foto,
required List<Map<String, dynamic>> satuanWaktuSewa,
this.foto_paket, this.foto_paket,
this.images, List<String>? images,
required this.createdAt, required this.createdAt,
required this.updatedAt, 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: id ?? this.id,
nama: nama ?? this.nama,
deskripsi: deskripsi ?? this.deskripsi,
harga: harga ?? this.harga,
kuantitas: kuantitas ?? this.kuantitas,
status: status ?? this.status,
foto: foto ?? List.from(this.foto),
satuanWaktuSewa: satuanWaktuSewa ?? List.from(this.satuanWaktuSewa),
foto_paket: foto_paket ?? this.foto_paket,
images: images ?? (this.images != null ? List.from(this.images!) : null),
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
// Alias for fromJson to maintain compatibility // Alias for fromJson to maintain compatibility
factory PaketModel.fromMap(Map<String, dynamic> json) => PaketModel.fromJson(json); factory PaketModel.fromMap(Map<String, dynamic> json) => PaketModel.fromJson(json);
@ -63,10 +99,15 @@ class PaketModel {
} }
} }
developer.log('📦 [PaketModel.fromJson] Raw status: ${json['status']} (type: ${json['status']?.runtimeType})');
final status = json['status']?.toString().toLowerCase() ?? 'aktif';
developer.log(' 🏷️ Processed status: $status');
return PaketModel( return PaketModel(
id: json['id']?.toString() ?? '', id: json['id']?.toString() ?? '',
nama: json['nama']?.toString() ?? '', nama: json['nama']?.toString() ?? '',
deskripsi: json['deskripsi']?.toString() ?? '', deskripsi: json['deskripsi']?.toString() ?? '',
status: status,
harga: (json['harga'] is num) ? (json['harga'] as num).toDouble() : 0.0, harga: (json['harga'] is num) ? (json['harga'] as num).toDouble() : 0.0,
kuantitas: (json['kuantitas'] is num) ? (json['kuantitas'] as num).toInt() : 1, kuantitas: (json['kuantitas'] is num) ? (json['kuantitas'] as num).toInt() : 1,
foto: fotoList, foto: fotoList,
@ -97,34 +138,6 @@ class PaketModel {
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(),
}; };
// Create a copy of the model with some fields updated
PaketModel copyWith({
String? id,
String? nama,
String? deskripsi,
double? harga,
int? kuantitas,
List<String>? foto,
List<Map<String, dynamic>>? satuanWaktuSewa,
String? foto_paket,
List<String>? images,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return PaketModel(
id: id ?? this.id,
nama: nama ?? this.nama,
deskripsi: deskripsi ?? this.deskripsi,
harga: harga ?? this.harga,
kuantitas: kuantitas ?? this.kuantitas,
foto: foto ?? List.from(this.foto),
satuanWaktuSewa: satuanWaktuSewa ?? List.from(this.satuanWaktuSewa),
foto_paket: foto_paket ?? this.foto_paket,
images: images ?? (this.images != null ? List.from(this.images!) : null),
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
// Get the first photo URL or a placeholder // Get the first photo URL or a placeholder
String get firstPhotoUrl => foto.isNotEmpty ? foto.first : ''; String get firstPhotoUrl => foto.isNotEmpty ? foto.first : '';

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,95 @@
class SewaModel {
final String id;
final String userId;
final String status;
final DateTime waktuMulai;
final DateTime waktuSelesai;
final DateTime tanggalPemesanan;
final String tipePesanan;
final int kuantitas;
// Untuk tunggal
final String? asetId;
final String? asetNama;
final String? asetFoto;
// Untuk paket
final String? paketId;
final String? paketNama;
final String? paketFoto;
// Tagihan
final double totalTagihan;
// Data warga
final String wargaNama;
final String wargaNoHp;
final String wargaAvatar;
final double? denda;
final double? dibayar;
final double? paidAmount;
SewaModel({
required this.id,
required this.userId,
required this.status,
required this.waktuMulai,
required this.waktuSelesai,
required this.tanggalPemesanan,
required this.tipePesanan,
required this.kuantitas,
this.asetId,
this.asetNama,
this.asetFoto,
this.paketId,
this.paketNama,
this.paketFoto,
required this.totalTagihan,
required this.wargaNama,
required this.wargaNoHp,
required this.wargaAvatar,
this.denda,
this.dibayar,
this.paidAmount,
});
factory SewaModel.fromJson(Map<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'),
);
}
}

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
@ -6,6 +8,8 @@ import '../models/foto_aset_model.dart';
import '../models/satuan_waktu_model.dart'; import '../models/satuan_waktu_model.dart';
import '../models/satuan_waktu_sewa_model.dart'; import '../models/satuan_waktu_sewa_model.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../models/paket_model.dart';
import '../providers/auth_provider.dart';
class AsetProvider extends GetxService { class AsetProvider extends GetxService {
late final SupabaseClient client; late final SupabaseClient client;
@ -24,8 +28,16 @@ class AsetProvider extends GetxService {
.from('aset') .from('aset')
.select('*') .select('*')
.eq('kategori', 'sewa') .eq('kategori', 'sewa')
.eq('status', 'tersedia') // Hanya yang tersedia .ilike('status', 'tersedia') // Hanya yang tersedia
.order('nama', ascending: true); // Urutan berdasarkan nama .order('nama', ascending: true) // Urutan berdasarkan nama
.withConverter<List<Map<String, dynamic>>>(
(data) =>
data.map<Map<String, dynamic>>((item) {
// Ensure 'jenis' is set to 'Sewa' for sewa assets
item['jenis'] = 'Sewa';
return item;
}).toList(),
);
debugPrint('Fetched ${response.length} aset'); debugPrint('Fetched ${response.length} aset');
@ -56,8 +68,16 @@ class AsetProvider extends GetxService {
.from('aset') .from('aset')
.select('*') .select('*')
.eq('kategori', 'langganan') .eq('kategori', 'langganan')
.eq('status', 'tersedia') // Hanya yang tersedia .ilike('status', 'tersedia') // Hanya yang tersedia
.order('nama', ascending: true); // Urutan berdasarkan nama .order('nama', ascending: true) // Urutan berdasarkan nama
.withConverter<List<Map<String, dynamic>>>(
(data) =>
data.map<Map<String, dynamic>>((item) {
// Ensure 'jenis' is set to 'Langganan' for langganan assets
item['jenis'] = 'Langganan';
return item;
}).toList(),
);
debugPrint('Fetched ${response.length} langganan aset'); debugPrint('Fetched ${response.length} langganan aset');
@ -120,9 +140,26 @@ class AsetProvider extends GetxService {
Future<void> loadAssetPhotos(AsetModel aset) async { Future<void> loadAssetPhotos(AsetModel aset) async {
try { try {
final photos = await getAsetPhotos(aset.id); final photos = await getAsetPhotos(aset.id);
if (photos.isNotEmpty && if (photos.isNotEmpty) {
(aset.imageUrl == null || aset.imageUrl!.isEmpty)) { // Clear existing images
aset.imageUrl = photos.first.fotoAset; aset.imageUrls.clear();
// Add all photos to the imageUrls list
for (final photo in photos) {
if (photo.fotoAset != null && photo.fotoAset!.isNotEmpty) {
aset.addImageUrl(photo.fotoAset);
}
}
// Set the main image URL if it's not already set
if ((aset.imageUrl == null || aset.imageUrl!.isEmpty) &&
aset.imageUrls.isNotEmpty) {
aset.imageUrl = aset.imageUrls.first;
}
debugPrint(
'✅ Loaded ${aset.imageUrls.length} photos for asset ${aset.id}',
);
} }
} catch (e) { } catch (e) {
debugPrint('Error loading asset photos for ID ${aset.id}: $e'); debugPrint('Error loading asset photos for ID ${aset.id}: $e');
@ -172,6 +209,376 @@ class AsetProvider extends GetxService {
} }
} }
// Create a new asset
Future<Map<String, dynamic>?> createAset(
Map<String, dynamic> asetData,
) async {
try {
debugPrint('🔄 Creating new aset with data:');
asetData.forEach((key, value) {
debugPrint(' $key: $value');
});
final response =
await client.from('aset').insert(asetData).select().single();
debugPrint('✅ Aset created successfully with ID: ${response['id']}');
return response;
} catch (e) {
debugPrint('❌ Error creating aset: $e');
debugPrint('❌ Stack trace: ${StackTrace.current}');
return null;
}
}
// Update an existing asset
Future<bool> updateAset(String asetId, Map<String, dynamic> asetData) async {
try {
debugPrint('🔄 Updating aset with ID: $asetId');
asetData.forEach((key, value) {
debugPrint(' $key: $value');
});
final response = await client
.from('aset')
.update(asetData)
.eq('id', asetId);
debugPrint('✅ Aset updated successfully');
return true;
} catch (e) {
debugPrint('❌ Error updating aset: $e');
debugPrint('❌ Stack trace: ${StackTrace.current}');
return false;
}
}
/// Adds a photo URL to the foto_aset table for a specific asset
Future<bool> addFotoAset({
required String asetId,
required String fotoUrl,
}) async {
try {
debugPrint('💾 Attempting to save foto to database:');
debugPrint(' - asetId: $asetId');
debugPrint(' - fotoUrl: $fotoUrl');
final data = {
'id_aset': asetId,
'foto_aset': fotoUrl,
'created_at': DateTime.now().toIso8601String(),
};
debugPrint('📤 Inserting into foto_aset table...');
final response = await client.from('foto_aset').insert(data).select();
debugPrint('📝 Database insert response: $response');
if (response == null) {
debugPrint('❌ Failed to insert into foto_aset: Response is null');
return false;
}
if (response is List && response.isNotEmpty) {
debugPrint('✅ Successfully added foto for aset ID: $asetId');
return true;
} else {
debugPrint('❌ Failed to add foto: Empty or invalid response');
return false;
}
} catch (e, stackTrace) {
debugPrint('❌ Error adding foto aset: $e');
debugPrint('Stack trace: $stackTrace');
return false;
}
}
/// Add satuan waktu sewa for an asset
Future<bool> addSatuanWaktuSewa({
required String asetId,
required String satuanWaktu,
required int harga,
required int maksimalWaktu,
}) async {
try {
// First, get the satuan_waktu_id from the satuan_waktu table
final response =
await client
.from('satuan_waktu')
.select('id')
.ilike('nama_satuan_waktu', satuanWaktu)
.maybeSingle();
if (response == null) {
debugPrint('❌ Satuan waktu "$satuanWaktu" not found in the database');
return false;
}
final satuanWaktuId = response['id'] as String;
final data = {
'aset_id': asetId,
'satuan_waktu_id': satuanWaktuId,
'harga': harga,
'maksimal_waktu': maksimalWaktu,
};
debugPrint('🔄 Adding satuan waktu sewa:');
data.forEach((key, value) {
debugPrint(' $key: $value');
});
await client.from('satuan_waktu_sewa').insert(data);
debugPrint('✅ Satuan waktu sewa added successfully');
return true;
} catch (e) {
debugPrint('❌ Error adding satuan waktu sewa: $e');
debugPrint('❌ Stack trace: ${StackTrace.current}');
return false;
}
}
// Delete all satuan waktu sewa for an asset
Future<bool> deleteSatuanWaktuSewaByAsetId(String asetId) async {
try {
await client
.from('satuan_waktu_sewa')
.delete()
.eq('aset_id', asetId); // Changed from 'id_aset' to 'aset_id'
debugPrint('✅ Deleted satuan waktu sewa for aset ID: $asetId');
return true;
} catch (e) {
debugPrint('❌ Error deleting satuan waktu sewa: $e');
return false;
}
}
/// Uploads a file to Supabase Storage root
/// Returns the public URL of the uploaded file, or null if upload fails
Future<String?> uploadFileToStorage(File file) async {
try {
if (!await file.exists()) {
debugPrint('❌ File does not exist: ${file.path}');
return null;
}
final fileName =
'${DateTime.now().millisecondsSinceEpoch}_${file.path.split(Platform.pathSeparator).last}';
debugPrint('🔄 Preparing to upload file: $fileName');
final uploadResponse = await client.storage
.from('foto.aset')
.upload(fileName, file, fileOptions: FileOptions(upsert: true));
debugPrint('📤 Upload response: $uploadResponse');
final publicUrl = client.storage.from('foto.aset').getPublicUrl(fileName);
debugPrint('✅ File uploaded successfully. Public URL: $publicUrl');
return publicUrl;
} catch (e, stackTrace) {
debugPrint('❌ Error uploading file to storage: $e');
debugPrint('Stack trace: $stackTrace');
return null;
}
}
/// Helper method to delete a file from Supabase Storage
Future<bool> deleteFileFromStorage(String fileUrl) async {
try {
debugPrint('🔄 Preparing to delete file from storage');
// Extract the file path from the full URL
final uri = Uri.parse(fileUrl);
final pathSegments = uri.pathSegments;
// Find the index of 'foto.aset' in the path
final fotoAsetIndex = pathSegments.indexWhere(
(segment) => segment == 'foto.aset',
);
if (fotoAsetIndex == -1 || fotoAsetIndex == pathSegments.length - 1) {
debugPrint(
'⚠️ Invalid file URL format, cannot extract file path: $fileUrl',
);
return false;
}
// Get the file path relative to the bucket
final filePath = pathSegments.sublist(fotoAsetIndex + 1).join('/');
debugPrint('🗑️ Deleting file from storage - Path: $filePath');
// Delete the file from storage
await client.storage.from('foto.aset').remove([filePath]);
debugPrint('✅ Successfully deleted file from storage');
return true;
} catch (e) {
debugPrint('❌ Error deleting file from storage: $e');
return false;
}
}
/// Updates the photos for an asset
/// Handles both local file uploads and existing URLs
/// Returns true if all operations were successful
Future<bool> updateFotoAset({
required String asetId,
required List<String> fotoUrls,
}) async {
if (fotoUrls.isEmpty) {
debugPrint(' No photos to update for asset: $asetId');
return true;
}
try {
debugPrint('🔄 Starting photo update for asset: $asetId');
// 1. Get existing photo URLs before deleting them
debugPrint('📋 Fetching existing photos for asset: $asetId');
final existingPhotos = await client
.from('foto_aset')
.select('foto_aset')
.eq('id_aset', asetId);
// 2. Delete files from storage first
if (existingPhotos is List && existingPhotos.isNotEmpty) {
debugPrint('🗑️ Deleting ${existingPhotos.length} files from storage');
for (final photo in existingPhotos) {
final url = photo['foto_aset'] as String?;
if (url != null && url.isNotEmpty) {
await deleteFileFromStorage(url);
} else {
debugPrint('⚠️ Skipping invalid photo URL: $photo');
}
}
} else {
debugPrint(' No existing photos found in database');
}
// 3. Remove duplicates from new fotoUrls
final uniqueFotoUrls = fotoUrls.toSet().toList();
debugPrint(
'📸 Processing ${uniqueFotoUrls.length} unique photos (was ${fotoUrls.length})',
);
// 4. Delete existing photo records from database
debugPrint('🗑️ Removing existing photo records from database');
try {
final deleteResponse = await client
.from('foto_aset')
.delete()
.eq('id_aset', asetId);
debugPrint('🗑️ Database delete response: $deleteResponse');
// Verify deletion
final remainingPhotos = await client
.from('foto_aset')
.select()
.eq('id_aset', asetId);
if (remainingPhotos is List && remainingPhotos.isNotEmpty) {
debugPrint(
'⚠️ Warning: ${remainingPhotos.length} photos still exist in database after delete',
);
}
} catch (e) {
debugPrint('❌ Error deleting existing photo records: $e');
// Continue with the update even if deletion fails
}
// 5. Process each unique new photo
bool allSuccess = true;
int processedCount = 0;
for (final fotoUrl in uniqueFotoUrls) {
if (fotoUrl.isEmpty) {
debugPrint('⏭️ Skipping empty photo URL');
continue;
}
try {
debugPrint(
'\n🔄 Processing photo ${processedCount + 1}/${uniqueFotoUrls.length}: ${fotoUrl.length > 50 ? '${fotoUrl.substring(0, 50)}...' : fotoUrl}',
);
// Check if it's a local file
if (fotoUrl.startsWith('file://') ||
fotoUrl.startsWith('/') ||
!fotoUrl.startsWith('http')) {
final file = File(fotoUrl.replaceFirst('file://', ''));
if (!await file.exists()) {
debugPrint('❌ File does not exist: ${file.path}');
allSuccess = false;
continue;
}
debugPrint('📤 Uploading local file...');
final uploadedUrl = await uploadFileToStorage(file);
if (uploadedUrl == null) {
debugPrint('❌ Failed to upload file');
allSuccess = false;
continue;
}
debugPrint('💾 Saving to database...');
final success = await addFotoAset(
asetId: asetId,
fotoUrl: uploadedUrl,
);
if (success) {
processedCount++;
debugPrint('✅ Successfully saved photo #$processedCount');
} else {
allSuccess = false;
debugPrint('❌ Failed to save photo URL to database');
}
}
// Skip placeholder values
else if (fotoUrl == 'pending_upload') {
debugPrint('⏭️ Skipping placeholder URL');
continue;
}
// Handle existing URLs
else if (fotoUrl.startsWith('http')) {
debugPrint('🌐 Processing existing URL...');
final success = await addFotoAset(asetId: asetId, fotoUrl: fotoUrl);
if (success) {
processedCount++;
debugPrint('✅ Successfully saved URL #$processedCount');
} else {
allSuccess = false;
debugPrint('❌ Failed to save URL to database');
}
} else {
debugPrint('⚠️ Unrecognized URL format, skipping');
}
} catch (e, stackTrace) {
allSuccess = false;
debugPrint('❌ Error processing photo: $e');
debugPrint('Stack trace: $stackTrace');
}
}
debugPrint('\n📊 Photo update complete');
debugPrint('✅ Success: $allSuccess');
debugPrint(
'📸 Processed: $processedCount/${uniqueFotoUrls.length} unique photos',
);
return allSuccess && processedCount > 0;
} catch (e) {
debugPrint('❌ Error updating foto aset: $e');
debugPrint('Stack trace: ${StackTrace.current}');
return false;
}
}
// Retrieve bookings for a specific asset on a specific date // Retrieve bookings for a specific asset on a specific date
Future<List<Map<String, dynamic>>> getAsetBookings( Future<List<Map<String, dynamic>>> getAsetBookings(
String asetId, String asetId,
@ -1061,7 +1468,9 @@ class AsetProvider extends GetxService {
.order('created_at'); .order('created_at');
if (response != null && response.isNotEmpty) { if (response != null && response.isNotEmpty) {
return response.map<String>((item) => item['foto_aset'] as String).toList(); return response
.map<String>((item) => item['foto_aset'] as String)
.toList();
} }
return []; return [];
} catch (e) { } catch (e) {
@ -1095,16 +1504,22 @@ class AsetProvider extends GetxService {
} }
if (response.isEmpty) { if (response.isEmpty) {
debugPrint(' [INFO] No items found in paket_item for paket ID: $paketId'); debugPrint(
' [INFO] No items found in paket_item for paket ID: $paketId',
);
return []; return [];
} }
debugPrint('✅ [SUCCESS] Found ${response.length} items in paket_item'); debugPrint(
'✅ [SUCCESS] Found ${response.length} items in paket_item',
);
final List<Map<String, dynamic>> enrichedItems = []; final List<Map<String, dynamic>> enrichedItems = [];
// Process each item to fetch additional details // Process each item to fetch additional details
debugPrint('🔄 [3/3] Processing ${response.length} items to fetch asset details'); debugPrint(
'🔄 [3/3] Processing ${response.length} items to fetch asset details',
);
for (var item in response) { for (var item in response) {
final String? asetId = item['aset_id']?.toString(); final String? asetId = item['aset_id']?.toString();
@ -1123,13 +1538,16 @@ class AsetProvider extends GetxService {
try { try {
// 1. Get asset name from aset table // 1. Get asset name from aset table
debugPrint(' - Querying aset table for id: $asetId'); debugPrint(' - Querying aset table for id: $asetId');
final asetResponse = await client final asetResponse =
await client
.from('aset') .from('aset')
.select('id, nama, deskripsi') .select('id, nama, deskripsi')
.eq('id', asetId) .eq('id', asetId)
.maybeSingle(); .maybeSingle();
debugPrint(' - Aset response: ${asetResponse?.toString() ?? 'null'}'); debugPrint(
' - Aset response: ${asetResponse?.toString() ?? 'null'}',
);
if (asetResponse == null) { if (asetResponse == null) {
debugPrint('⚠️ [WARNING] No asset found with id: $asetId'); debugPrint('⚠️ [WARNING] No asset found with id: $asetId');
@ -1139,7 +1557,7 @@ class AsetProvider extends GetxService {
'nama_aset': 'Item tidak diketahui', 'nama_aset': 'Item tidak diketahui',
'foto_aset': '', 'foto_aset': '',
'semua_foto': <String>[], 'semua_foto': <String>[],
'error': 'Asset not found' 'error': 'Asset not found',
}); });
continue; continue;
} }
@ -1173,15 +1591,18 @@ class AsetProvider extends GetxService {
final enrichedItem = { final enrichedItem = {
'aset_id': asetId, 'aset_id': asetId,
'kuantitas': kuantitas, 'kuantitas': kuantitas,
'nama_aset': asetResponse['nama']?.toString() ?? 'Nama tidak tersedia', 'nama_aset':
asetResponse['nama']?.toString() ?? 'Nama tidak tersedia',
'foto_aset': fotoUtama, 'foto_aset': fotoUtama,
'semua_foto': semuaFoto, 'semua_foto': semuaFoto,
'debug': { 'debug': {
'aset_query': asetResponse, 'aset_query': asetResponse,
'foto_count': semuaFoto.length 'foto_count': semuaFoto.length,
} },
}; };
debugPrint('✅ [ENRICHED ITEM] $enrichedItem');
enrichedItems.add(enrichedItem); enrichedItems.add(enrichedItem);
// Debug log // Debug log
@ -1193,7 +1614,6 @@ class AsetProvider extends GetxService {
if (semuaFoto.isNotEmpty) { if (semuaFoto.isNotEmpty) {
debugPrint(' - Foto Utama: ${semuaFoto.first}'); debugPrint(' - Foto Utama: ${semuaFoto.first}');
} }
} catch (e) { } catch (e) {
debugPrint('❌ Error processing asset $asetId: $e'); debugPrint('❌ Error processing asset $asetId: $e');
// Still add the basic item even if we couldn't fetch additional details // Still add the basic item even if we couldn't fetch additional details
@ -1207,9 +1627,13 @@ class AsetProvider extends GetxService {
} }
} }
debugPrint('✅ Successfully fetched ${enrichedItems.length} items with details'); debugPrint(
'✅ Successfully fetched ${enrichedItems.length} items with details:',
);
for (var item in enrichedItems) {
debugPrint(' - $item');
}
return enrichedItems; return enrichedItems;
} catch (e, stackTrace) { } catch (e, stackTrace) {
debugPrint('❌ Error getting package items for paket $paketId: $e'); debugPrint('❌ Error getting package items for paket $paketId: $e');
debugPrint('Stack trace: $stackTrace'); debugPrint('Stack trace: $stackTrace');
@ -1221,10 +1645,9 @@ class AsetProvider extends GetxService {
Future<List<Map<String, dynamic>>> getBankAccounts() async { Future<List<Map<String, dynamic>>> getBankAccounts() async {
try { try {
final response = await client final response = await client
.from('bank_accounts') .from('akun_bank')
.select('*') .select('*')
.eq('is_active', true) .order('nama_bank');
.order('bank_name');
if (response != null && response.isNotEmpty) { if (response != null && response.isNotEmpty) {
return List<Map<String, dynamic>>.from(response); return List<Map<String, dynamic>>.from(response);
@ -1235,4 +1658,325 @@ class AsetProvider extends GetxService {
return []; return [];
} }
} }
/// Fetch all packages with their related data (photos and rental time units)
Future<List<PaketModel>> getAllPaket() async {
final stopwatch = Stopwatch()..start();
final String debugId = DateTime.now().millisecondsSinceEpoch
.toString()
.substring(8);
void log(String message, {bool isError = false, bool isSection = false}) {
final prefix =
isError
? ''
: isSection
? '📌'
: ' ';
debugPrint('[$debugId] $prefix $message');
}
try {
log('🚀 Memulai pengambilan data paket...', isSection: true);
log('📡 Mengambil data paket dari database...');
// 1) Get all packages
final paketResponse = await client
.from('paket')
.select('*')
.order('created_at', ascending: false);
log('📥 Diterima ${paketResponse.length} paket dari database');
if (paketResponse.isEmpty) {
log(' Tidak ada paket yang ditemukan');
return [];
}
// Convert to list of PaketModel (without relations yet)
log('\n🔍 Memproses data paket...');
final List<PaketModel> paketList = [];
int successCount = 0;
for (var p in paketResponse) {
try {
final paket = PaketModel.fromMap(p as Map<String, dynamic>);
paketList.add(paket);
successCount++;
log(' ✅ Berhasil memproses paket: ${paket.id} - ${paket.nama}');
} catch (e) {
log('⚠️ Gagal memproses paket: $e', isError: true);
log(' Data paket: $p');
}
}
log('\n📊 Ringkasan Pemrosesan:');
log(' - Total data: ${paketResponse.length}');
log(' - Berhasil: $successCount');
log(' - Gagal: ${paketResponse.length - successCount}');
if (paketList.isEmpty) {
log(' Tidak ada paket yang valid setelah diproses');
return [];
}
// Kumpulkan semua ID paket
final List<String> paketIds = paketList.map((p) => p.id).toList();
log('\n📦 Mengambil data tambahan untuk ${paketList.length} paket...');
log(' ID Paket: ${paketIds.join(', ')}');
// 2) Ambil semua foto untuk paket-paket ini
log('\n🖼️ Mengambil data foto...');
final fotoResp = await client
.from('foto_aset')
.select('id_paket, foto_aset')
.inFilter('id_paket', paketIds);
log(' Ditemukan ${fotoResp.length} foto');
// Map packageId -> List<String> photos
final Map<String, List<String>> mapFoto = {};
int fotoCount = 0;
for (var row in fotoResp) {
try {
final pid = row['id_paket']?.toString() ?? '';
final url = row['foto_aset']?.toString() ?? '';
if (pid.isNotEmpty && url.isNotEmpty) {
mapFoto.putIfAbsent(pid, () => []).add(url);
fotoCount++;
} else {
log(' ⚠️ Data foto tidak valid: ${row.toString()}');
}
} catch (e) {
log('⚠️ Gagal memproses data foto: $e', isError: true);
}
}
log(' Berhasil memetakan $fotoCount foto ke ${mapFoto.length} paket');
// 3) Get all satuan_waktu_sewa for these packages
log('\n⏱️ Mengambil data satuan waktu sewa...');
final swsResp = await client
.from('satuan_waktu_sewa')
.select('paket_id, satuan_waktu_id, harga, maksimal_waktu')
.inFilter('paket_id', paketIds);
log(' Ditemukan ${swsResp.length} entri satuan waktu sewa');
// Process satuan waktu sewa
final Map<String, List<Map<String, dynamic>>> paketSatuanWaktu = {};
int swsCount = 0;
for (var row in swsResp) {
try {
final pid = row['paket_id']?.toString() ?? '';
if (pid.isNotEmpty) {
final swsData = {
'satuan_waktu_id': row['satuan_waktu_id'],
'harga': row['harga'],
'maksimal_waktu': row['maksimal_waktu'],
};
paketSatuanWaktu.putIfAbsent(pid, () => []).add(swsData);
swsCount++;
}
} catch (e) {
log('⚠️ Gagal memproses satuan waktu sewa: $e', isError: true);
log(' Data: $row');
}
}
log(
' Berhasil memetakan $swsCount satuan waktu ke ${paketSatuanWaktu.length} paket',
);
// 4) Gabungkan semua data
log('\n🔗 Menggabungkan data...');
final List<PaketModel> result = [];
int combinedCount = 0;
for (var paket in paketList) {
final pid = paket.id;
log('\n📦 Memproses paket: ${paket.nama} ($pid)');
try {
final updatedPaket = paket.copyWith();
// Lampirkan foto
if (mapFoto.containsKey(pid)) {
final fotoList = mapFoto[pid]!;
updatedPaket.images = List<String>.from(fotoList);
// Set foto utama jika belum ada
if (updatedPaket.images!.isNotEmpty &&
updatedPaket.foto_paket == null) {
updatedPaket.foto_paket = updatedPaket.images!.first;
log(' 📷 Menambahkan ${fotoList.length} foto');
log(' 🖼️ Foto utama: ${updatedPaket.foto_paket}');
}
} else {
log(' Tidak ada foto untuk paket ini');
}
// Lampirkan satuan waktu sewa
if (paketSatuanWaktu.containsKey(pid)) {
final swsList = List<Map<String, dynamic>>.from(
paketSatuanWaktu[pid] ?? [],
);
updatedPaket.satuanWaktuSewa = swsList;
log(' ⏱️ Menambahkan ${swsList.length} satuan waktu sewa');
// Log detail harga
for (var sws in swsList.take(2)) {
// Tampilkan maksimal 2 harga
log(
' - ${sws['harga']} / satuan waktu (ID: ${sws['satuan_waktu_id']})',
);
}
if (swsList.length > 2) {
log(' - ...dan ${swsList.length - 2} lainnya');
}
} else {
log(' Tidak ada satuan waktu sewa untuk paket ini');
}
result.add(updatedPaket);
combinedCount++;
log(' ✅ Berhasil memproses paket $pid');
} catch (e) {
log('⚠️ Gagal memproses paket $pid: $e', isError: true);
// Tetap tambahkan paket asli jika gagal diproses
result.add(paket);
}
}
// Ringkasan eksekusi
stopwatch.stop();
log('\n🎉 Selesai!', isSection: true);
log('📊 Ringkasan Eksekusi:');
log(' - Total paket: ${paketList.length}');
log(' - Berhasil diproses: $combinedCount/${paketList.length}');
log(' - Total foto: $fotoCount');
log(' - Total satuan waktu: $swsCount');
log(' - Waktu eksekusi: ${stopwatch.elapsedMilliseconds}ms');
log(' - ID Debug: $debugId');
return result;
} catch (e, stackTrace) {
log('\n❌ ERROR KRITIS', isError: true);
log('Pesan error: $e', isError: true);
log('Stack trace: $stackTrace', isError: true);
log('ID Debug: $debugId', isError: true);
rethrow;
debugPrint('❌ [getAllPaket] Error: $e');
debugPrint('Stack trace: $stackTrace');
rethrow;
}
}
// Update tagihan_dibayar and insert pembayaran
Future<bool> processPembayaranTagihan({
required String tagihanSewaId,
required int nominal,
required String metodePembayaran,
}) async {
try {
// 1. Get current tagihan_dibayar
final tagihan =
await client
.from('tagihan_sewa')
.select('tagihan_dibayar')
.eq('id', tagihanSewaId)
.maybeSingle();
int currentDibayar = 0;
if (tagihan != null && tagihan['tagihan_dibayar'] != null) {
currentDibayar =
int.tryParse(tagihan['tagihan_dibayar'].toString()) ?? 0;
}
final newDibayar = currentDibayar + nominal;
// 2. Update tagihan_dibayar
await client
.from('tagihan_sewa')
.update({'tagihan_dibayar': newDibayar})
.eq('id', tagihanSewaId);
// 3. Insert pembayaran
final authProvider = Get.find<AuthProvider>();
final idPetugas = authProvider.getCurrentUserId();
final pembayaranData = {
'tagihan_sewa_id': tagihanSewaId,
'metode_pembayaran': metodePembayaran,
'total_pembayaran': nominal,
'status': 'lunas',
'created_at': DateTime.now().toIso8601String(),
'id_petugas': idPetugas,
};
await client.from('pembayaran').insert(pembayaranData);
return true;
} catch (e) {
debugPrint('❌ Error processing pembayaran tagihan: $e');
return false;
}
}
// Update status of sewa_aset by ID
Future<bool> updateSewaAsetStatus({
required String sewaAsetId,
required String status,
}) async {
try {
debugPrint('🔄 Updating status of sewa_aset ID: $sewaAsetId to $status');
final response = await client
.from('sewa_aset')
.update({'status': status})
.eq('id', sewaAsetId);
debugPrint('✅ Status updated for sewa_aset ID: $sewaAsetId');
return true;
} catch (e) {
debugPrint('❌ Error updating sewa_aset status: $e');
return false;
}
}
// Get all payment proof image URLs for a sewa_aset (by tagihan_sewa)
Future<List<String>> getFotoPembayaranUrlsByTagihanSewaId(
String sewaAsetId,
) async {
try {
// 1. Get tagihan_sewa by sewaAsetId
final tagihan = await getTagihanSewa(sewaAsetId);
if (tagihan == null || tagihan['id'] == null) return [];
final tagihanSewaId = tagihan['id'];
// 2. Fetch all foto_pembayaran for this tagihan_sewa_id
final List<dynamic> response = await client
.from('foto_pembayaran')
.select('foto_pembayaran')
.eq('tagihan_sewa_id', tagihanSewaId)
.order('created_at', ascending: false);
// 3. Extract URLs
return response
.map<String>((row) => row['foto_pembayaran']?.toString() ?? '')
.where((url) => url.isNotEmpty)
.toList();
} catch (e) {
debugPrint('❌ Error fetching foto pembayaran: $e');
return [];
}
}
Future<int> countSewaAsetByStatus(List<String> statuses) async {
// Supabase expects the IN filter as a comma-separated string in parentheses
final statusString = '(${statuses.map((s) => '"$s"').join(',')})';
final response = await client
.from('sewa_aset')
.select('id')
.filter('status', 'in', statusString);
if (response is List) {
return response.length;
}
return 0;
}
} }

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

@ -8,6 +8,10 @@ class AuthController extends GetxController {
final emailController = TextEditingController(); final emailController = TextEditingController();
final passwordController = TextEditingController(); final passwordController = TextEditingController();
final formKey = GlobalKey<FormState>();
final nameController = TextEditingController();
final confirmPasswordController = TextEditingController();
final RxBool isConfirmPasswordVisible = false.obs;
// Form fields for registration // Form fields for registration
final RxString email = ''.obs; final RxString email = ''.obs;
@ -15,6 +19,7 @@ class AuthController extends GetxController {
final RxString nik = ''.obs; final RxString nik = ''.obs;
final RxString phoneNumber = ''.obs; final RxString phoneNumber = ''.obs;
final RxString selectedRole = 'WARGA'.obs; // Default role final RxString selectedRole = 'WARGA'.obs; // Default role
final RxString alamatLengkap = ''.obs;
// Form status // Form status
final RxBool isLoading = false.obs; final RxBool isLoading = false.obs;
@ -28,6 +33,10 @@ class AuthController extends GetxController {
isPasswordVisible.value = !isPasswordVisible.value; isPasswordVisible.value = !isPasswordVisible.value;
} }
void toggleConfirmPasswordVisibility() {
isConfirmPasswordVisible.value = !isConfirmPasswordVisible.value;
}
// Change role selection // Change role selection
void setRole(String? role) { void setRole(String? role) {
if (role != null) { if (role != null) {
@ -172,6 +181,8 @@ class AuthController extends GetxController {
void onClose() { void onClose() {
emailController.dispose(); emailController.dispose();
passwordController.dispose(); passwordController.dispose();
nameController.dispose();
confirmPasswordController.dispose();
super.onClose(); super.onClose();
} }
@ -181,7 +192,8 @@ class AuthController extends GetxController {
if (email.value.isEmpty || if (email.value.isEmpty ||
password.value.isEmpty || password.value.isEmpty ||
nik.value.isEmpty || nik.value.isEmpty ||
phoneNumber.value.isEmpty) { phoneNumber.value.isEmpty ||
alamatLengkap.value.isEmpty) {
errorMessage.value = 'Semua field harus diisi'; errorMessage.value = 'Semua field harus diisi';
return; return;
} }
@ -222,6 +234,7 @@ class AuthController extends GetxController {
data: { data: {
'nik': nik.value.trim(), 'nik': nik.value.trim(),
'phone_number': phoneNumber.value.trim(), 'phone_number': phoneNumber.value.trim(),
'alamat_lengkap': alamatLengkap.value.trim(),
'role': selectedRole.value, 'role': selectedRole.value,
}, },
); );

View File

@ -26,12 +26,8 @@ class ForgotPasswordView extends GetView<AuthController> {
Opacity( Opacity(
opacity: 0.03, opacity: 0.03,
child: Container( child: Container(
decoration: const BoxDecoration( decoration: BoxDecoration(
image: DecorationImage( color: Colors.blue[50], // Temporary solid color
image: AssetImage('assets/images/pattern.png'),
repeat: ImageRepeat.repeat,
scale: 4.0,
),
), ),
), ),
), ),

View File

@ -30,12 +30,8 @@ class LoginView extends GetView<AuthController> {
Opacity( Opacity(
opacity: 0.03, opacity: 0.03,
child: Container( child: Container(
decoration: const BoxDecoration( decoration: BoxDecoration(
image: DecorationImage( color: Colors.blue[50], // Temporary solid color
image: AssetImage('assets/images/pattern.png'),
repeat: ImageRepeat.repeat,
scale: 4.0,
),
), ),
), ),
), ),
@ -89,7 +85,6 @@ class LoginView extends GetView<AuthController> {
_buildHeader(), _buildHeader(),
const SizedBox(height: 40), const SizedBox(height: 40),
_buildLoginCard(), _buildLoginCard(),
const SizedBox(height: 24),
_buildRegisterLink(), _buildRegisterLink(),
const SizedBox(height: 30), const SizedBox(height: 30),
], ],
@ -161,7 +156,7 @@ class LoginView extends GetView<AuthController> {
prefixIcon: Icons.email_outlined, prefixIcon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
), ),
const SizedBox(height: 24), const SizedBox(height: 12),
// Password field // Password field
_buildInputLabel('Password'), _buildInputLabel('Password'),
@ -204,7 +199,6 @@ class LoginView extends GetView<AuthController> {
), ),
), ),
), ),
const SizedBox(height: 32),
// Login button // Login button
Obx( Obx(

View File

@ -187,7 +187,7 @@ class RegistrationView extends GetView<AuthController> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Pendaftaran hanya dapat dilakukan oleh warga dan mitra yang sudah terverivikasi. Silahkan hubungi petugas atau kunjungi kantor untuk informasi lebih lanjut.', 'Setelah pendaftaran lengkapi data diri untuk dapat melakukan sewa',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
color: AppColors.textPrimary, color: AppColors.textPrimary,
@ -203,121 +203,32 @@ class RegistrationView extends GetView<AuthController> {
} }
Widget _buildRegistrationForm() { Widget _buildRegistrationForm() {
return Column( return Form(
crossAxisAlignment: CrossAxisAlignment.start, key: controller.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Email Input
_buildInputLabel('Email'), _buildInputLabel('Email'),
const SizedBox(height: 8),
_buildEmailField(), _buildEmailField(),
const SizedBox(height: 20), const SizedBox(height: 16),
// Password Input
_buildInputLabel('Password'), _buildInputLabel('Password'),
const SizedBox(height: 8),
_buildPasswordField(), _buildPasswordField(),
const SizedBox(height: 20), const SizedBox(height: 16),
_buildInputLabel('Konfirmasi Password'),
// NIK Input _buildConfirmPasswordField(),
_buildInputLabel('NIK'), const SizedBox(height: 16),
const SizedBox(height: 8), _buildInputLabel('Nama Lengkap'),
_buildNikField(), _buildNameField(),
const SizedBox(height: 20), const SizedBox(height: 16),
_buildInputLabel('No HP'),
// Phone Number Input
_buildInputLabel('No. Hp'),
const SizedBox(height: 8),
_buildPhoneField(), _buildPhoneField(),
const SizedBox(height: 20), const SizedBox(height: 16),
_buildInputLabel('Alamat Lengkap'),
// Role Selection Dropdown _buildAlamatField(),
Column( const SizedBox(height: 16),
crossAxisAlignment: CrossAxisAlignment.start, // Removed: NIK, No HP, and Dropdown Daftar Sebagai
children: [
const Text(
'Daftar Sebagai',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[300]!, width: 1),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Obx(
() => DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: controller.selectedRole.value,
hint: const Text('Pilih Peran'),
items: [
DropdownMenuItem(
value: 'WARGA',
child: const Text('Warga'),
),
DropdownMenuItem(
value: 'PETUGAS_MITRA',
child: const Text('Mitra'),
),
],
onChanged: (value) {
controller.setRole(value);
},
icon: const Icon(Icons.arrow_drop_down),
style: const TextStyle(
color: Colors.black87,
fontSize: 14,
),
),
),
),
),
),
], ],
), ),
const SizedBox(height: 20),
// Error message
Obx(
() =>
controller.errorMessage.value.isNotEmpty
? Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.errorLight,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: AppColors.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
controller.errorMessage.value,
style: TextStyle(
color: AppColors.error,
fontSize: 13,
),
),
),
],
),
)
: const SizedBox.shrink(),
),
],
); );
} }
@ -415,78 +326,101 @@ class RegistrationView extends GetView<AuthController> {
); );
} }
Widget _buildNikField() { Widget _buildConfirmPasswordField() {
return Container( return Obx(
decoration: BoxDecoration( () => TextFormField(
color: AppColors.surface, controller: controller.confirmPasswordController,
borderRadius: BorderRadius.circular(16), obscureText: !controller.isConfirmPasswordVisible.value,
boxShadow: [
BoxShadow(
color: AppColors.shadow,
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: TextField(
onChanged: (value) => controller.nik.value = value,
keyboardType: TextInputType.number,
style: TextStyle(fontSize: 16, color: AppColors.textPrimary),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Masukkan NIK anda', hintText: 'Masukkan ulang password anda',
hintStyle: TextStyle(color: AppColors.textLight), suffixIcon: IconButton(
prefixIcon: Icon( icon: Icon(
Icons.credit_card_outlined, controller.isConfirmPasswordVisible.value
color: AppColors.primary, ? Icons.visibility
: Icons.visibility_off,
), ),
border: InputBorder.none, onPressed: controller.toggleConfirmPasswordVisibility,
contentPadding: const EdgeInsets.symmetric(vertical: 16),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
), ),
focusedBorder: OutlineInputBorder( border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
borderRadius: BorderRadius.circular(16), contentPadding: const EdgeInsets.symmetric(
borderSide: BorderSide(color: AppColors.primary, width: 1.5), horizontal: 16,
vertical: 14,
), ),
), ),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Konfirmasi password tidak boleh kosong';
}
if (value != controller.passwordController.text) {
return 'Password tidak cocok';
}
return null;
},
), ),
); );
} }
Widget _buildNameField() {
return TextFormField(
controller: controller.nameController,
decoration: InputDecoration(
hintText: 'Masukkan nama lengkap anda',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Nama lengkap tidak boleh kosong';
}
return null;
},
);
}
Widget _buildPhoneField() { Widget _buildPhoneField() {
return Container( return TextFormField(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColors.shadow,
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: TextField(
onChanged: (value) => controller.phoneNumber.value = value,
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
style: TextStyle(fontSize: 16, color: AppColors.textPrimary),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Masukkan nomor HP anda', hintText: 'Masukkan nomor HP anda',
hintStyle: TextStyle(color: AppColors.textLight), border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
prefixIcon: Icon(Icons.phone_outlined, color: AppColors.primary), contentPadding: const EdgeInsets.symmetric(
border: InputBorder.none, horizontal: 16,
contentPadding: const EdgeInsets.symmetric(vertical: 16), vertical: 14,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: AppColors.primary, width: 1.5),
), ),
), ),
onChanged: (value) => controller.phoneNumber.value = value,
validator: (value) {
if (value == null || value.isEmpty) {
return 'No HP tidak boleh kosong';
}
if (!value.startsWith('08') || value.length < 10) {
return 'Nomor HP tidak valid (harus diawali 08 dan minimal 10 digit)';
}
return null;
},
);
}
Widget _buildAlamatField() {
return TextFormField(
decoration: InputDecoration(
hintText: 'Masukkan alamat lengkap anda',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
), ),
),
onChanged: (value) => controller.alamatLengkap.value = value,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Alamat lengkap tidak boleh kosong';
}
return null;
},
); );
} }

View File

@ -1,6 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../controllers/petugas_aset_controller.dart'; import '../controllers/petugas_aset_controller.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart'; import '../controllers/petugas_bumdes_dashboard_controller.dart';
import '../../../data/providers/aset_provider.dart';
class PetugasAsetBinding extends Bindings { class PetugasAsetBinding extends Bindings {
@override @override
@ -10,6 +11,7 @@ class PetugasAsetBinding extends Bindings {
Get.put(PetugasBumdesDashboardController(), permanent: true); Get.put(PetugasBumdesDashboardController(), permanent: true);
} }
Get.lazyPut<AsetProvider>(() => AsetProvider());
Get.lazyPut<PetugasAsetController>(() => PetugasAsetController()); Get.lazyPut<PetugasAsetController>(() => PetugasAsetController());
} }
} }

View File

@ -1,9 +1,14 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../controllers/petugas_sewa_controller.dart'; import '../controllers/petugas_sewa_controller.dart';
import '../../../data/providers/aset_provider.dart';
class PetugasDetailSewaBinding extends Bindings { class PetugasDetailSewaBinding extends Bindings {
@override @override
void dependencies() { void dependencies() {
// Ensure AsetProvider is registered
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
// Memastikan controller sudah tersedia // Memastikan controller sudah tersedia
Get.lazyPut<PetugasSewaController>( Get.lazyPut<PetugasSewaController>(
() => PetugasSewaController(), () => PetugasSewaController(),

View File

@ -1,15 +1,25 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:bumrent_app/app/data/providers/aset_provider.dart';
import '../controllers/petugas_paket_controller.dart'; import '../controllers/petugas_paket_controller.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart'; import '../controllers/petugas_bumdes_dashboard_controller.dart';
class PetugasPaketBinding extends Bindings { class PetugasPaketBinding extends Bindings {
@override @override
void dependencies() { void dependencies() {
// Register AsetProvider first
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
// Ensure dashboard controller is registered // Ensure dashboard controller is registered
if (!Get.isRegistered<PetugasBumdesDashboardController>()) { if (!Get.isRegistered<PetugasBumdesDashboardController>()) {
Get.put(PetugasBumdesDashboardController(), permanent: true); Get.put(PetugasBumdesDashboardController(), permanent: true);
} }
Get.lazyPut<PetugasPaketController>(() => PetugasPaketController()); // Register the controller
Get.lazyPut<PetugasPaketController>(
() => PetugasPaketController(),
fenix: true,
);
} }
} }

View File

@ -1,9 +1,14 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../controllers/petugas_sewa_controller.dart'; import '../controllers/petugas_sewa_controller.dart';
import '../../../data/providers/aset_provider.dart';
class PetugasSewaBinding extends Bindings { class PetugasSewaBinding extends Bindings {
@override @override
void dependencies() { void dependencies() {
// Ensure AsetProvider is registered
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
Get.lazyPut<PetugasSewaController>(() => PetugasSewaController()); Get.lazyPut<PetugasSewaController>(() => PetugasSewaController());
} }
} }

View File

@ -1,6 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../../data/providers/aset_provider.dart';
import '../../../data/models/aset_model.dart';
class PetugasAsetController extends GetxController { class PetugasAsetController extends GetxController {
final AsetProvider _asetProvider = Get.find<AsetProvider>();
// Observable lists for asset data // Observable lists for asset data
final asetList = <Map<String, dynamic>>[].obs; final asetList = <Map<String, dynamic>>[].obs;
final filteredAsetList = <Map<String, dynamic>>[].obs; final filteredAsetList = <Map<String, dynamic>>[].obs;
@ -27,95 +32,100 @@ class PetugasAsetController extends GetxController {
loadAsetData(); loadAsetData();
} }
// Load sample asset data (would be replaced with API call in production) // Load asset data from AsetProvider
Future<void> loadAsetData() async { Future<void> loadAsetData() async {
isLoading.value = true;
try { try {
// Simulate API call with a delay isLoading.value = true;
await Future.delayed(const Duration(seconds: 1)); debugPrint('PetugasAsetController: Starting to load asset data...');
// Sample assets data // Fetch data using AsetProvider
final sampleData = [ final asetData = await _asetProvider.getSewaAsets();
{ debugPrint(
'id': '1', 'PetugasAsetController: Fetched ${asetData.length} assets from Supabase',
'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,
},
];
asetList.assignAll(sampleData); if (asetData.isEmpty) {
applyFilters(); // Apply default filters debugPrint('PetugasAsetController: No assets found in Supabase');
} catch (e) { }
print('Error loading asset data: $e');
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.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
@ -170,8 +180,10 @@ class PetugasAsetController extends GetxController {
} }
// Change tab (Sewa or Langganan) // Change tab (Sewa or Langganan)
void changeTab(int index) { Future<void> changeTab(int index) async {
selectedTabIndex.value = index; selectedTabIndex.value = index;
// Reload data when changing tabs to ensure we have the correct data for the selected tab
await loadAsetData();
applyFilters(); applyFilters();
} }

View File

@ -1,6 +1,10 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../../data/providers/auth_provider.dart'; import '../../../data/providers/auth_provider.dart';
import '../../../routes/app_routes.dart'; import '../../../routes/app_routes.dart';
import '../../../services/sewa_service.dart';
import '../../../services/service_manager.dart';
import '../../../data/models/pembayaran_model.dart';
import '../../../services/pembayaran_service.dart';
class PetugasBumdesDashboardController extends GetxController { class PetugasBumdesDashboardController extends GetxController {
AuthProvider? _authProvider; AuthProvider? _authProvider;
@ -8,6 +12,8 @@ class PetugasBumdesDashboardController extends GetxController {
// Reactive variables // Reactive variables
final userEmail = ''.obs; final userEmail = ''.obs;
final currentTabIndex = 0.obs; final currentTabIndex = 0.obs;
final avatarUrl = ''.obs;
final userName = ''.obs;
// Revenue Statistics // Revenue Statistics
final totalPendapatanBulanIni = 'Rp 8.500.000'.obs; final totalPendapatanBulanIni = 'Rp 8.500.000'.obs;
@ -20,7 +26,7 @@ class PetugasBumdesDashboardController extends GetxController {
final persentaseSewa = 100.obs; final persentaseSewa = 100.obs;
// Revenue Trends (last 6 months) // 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 // Status Counters for Sewa Aset
final terlaksanaCount = 5.obs; final terlaksanaCount = 5.obs;
@ -43,42 +49,128 @@ class PetugasBumdesDashboardController extends GetxController {
final tagihanAktifCountSewa = 7.obs; final tagihanAktifCountSewa = 7.obs;
final periksaPembayaranCountSewa = 2.obs; final periksaPembayaranCountSewa = 2.obs;
// Statistik pendapatan
final totalPendapatan = 0.obs;
final pendapatanBulanIni = 0.obs;
final pendapatanBulanLalu = 0.obs;
final pendapatanTunai = 0.obs;
final pendapatanTransfer = 0.obs;
final trenPendapatan = <int>[].obs; // 6 bulan terakhir
// Dashboard statistics
final pembayaranStats = <String, dynamic>{}.obs;
final isStatsLoading = true.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
try { try {
_authProvider = Get.find<AuthProvider>(); _authProvider = Get.find<AuthProvider>();
userEmail.value = _authProvider?.currentUser?.email ?? 'Tidak ada email'; userEmail.value = _authProvider?.currentUser?.email ?? 'Tidak ada email';
fetchPetugasAvatar();
fetchPetugasName();
} catch (e) { } catch (e) {
print('Error finding AuthProvider: $e'); print('Error finding AuthProvider: $e');
userEmail.value = 'Tidak ada email'; userEmail.value = 'Tidak ada email';
} }
print('\u2705 PetugasBumdesDashboardController initialized successfully');
// In a real app, these counts would be fetched from backend countSewaByStatus();
// loadStatusCounts(); fetchPembayaranStats();
print('✅ PetugasBumdesDashboardController initialized successfully');
} }
// Method to load status counts from backend Future<void> countSewaByStatus() async {
// Future<void> loadStatusCounts() async { try {
// try { final data = await SewaService().fetchAllSewa();
// final response = await _asetProvider.getSewaStatusCounts(); menungguPembayaranCount.value =
// if (response != null) { data.where((s) => s.status == 'MENUNGGU PEMBAYARAN').length;
// terlaksanaCount.value = response['terlaksana'] ?? 0; periksaPembayaranCount.value =
// dijadwalkanCount.value = response['dijadwalkan'] ?? 0; data.where((s) => s.status == 'PERIKSA PEMBAYARAN').length;
// aktifCount.value = response['aktif'] ?? 0; diterimaCount.value = data.where((s) => s.status == 'DITERIMA').length;
// dibatalkanCount.value = response['dibatalkan'] ?? 0; pembayaranDendaCount.value =
// menungguPembayaranCount.value = response['menunggu_pembayaran'] ?? 0; data.where((s) => s.status == 'PEMBAYARAN DENDA').length;
// periksaPembayaranCount.value = response['periksa_pembayaran'] ?? 0; periksaPembayaranDendaCount.value =
// diterimaCount.value = response['diterima'] ?? 0; data.where((s) => s.status == 'PERIKSA PEMBAYARAN DENDA').length;
// pembayaranDendaCount.value = response['pembayaran_denda'] ?? 0; selesaiCount.value = data.where((s) => s.status == 'SELESAI').length;
// periksaPembayaranDendaCount.value = response['periksa_pembayaran_denda'] ?? 0; print(
// selesaiCount.value = response['selesai'] ?? 0; 'Count for MENUNGGU PEMBAYARAN: \\${menungguPembayaranCount.value}',
// } );
// } catch (e) { print('Count for PERIKSA PEMBAYARAN: \\${periksaPembayaranCount.value}');
// print('Error loading status counts: $e'); 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 = '';
}
}
void changeTab(int index) { void changeTab(int index) {
try { try {

View File

@ -1,24 +1,24 @@
import 'package:flutter/material.dart';
import 'package:get/get.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 { class PetugasPaketController extends GetxController {
final isLoading = false.obs; // Dependencies
final searchQuery = ''.obs; final AsetProvider _asetProvider = Get.find<AsetProvider>();
final selectedCategory = 'Semua'.obs;
final sortBy = 'Terbaru'.obs;
// Kategori untuk filter // State
final categories = <String>[ final RxBool isLoading = false.obs;
'Semua', final RxString searchQuery = ''.obs;
'Pesta', final RxString selectedCategory = 'Semua'.obs;
'Rapat', final RxString sortBy = 'Terbaru'.obs;
'Olahraga', final RxList<PaketModel> packages = <PaketModel>[].obs;
'Pernikahan', final RxList<PaketModel> filteredPackages = <PaketModel>[].obs;
'Lainnya',
];
// Opsi pengurutan // Sort options for the dropdown
final sortOptions = <String>[ final List<String> sortOptions = [
'Terbaru', 'Terbaru',
'Terlama', 'Terlama',
'Harga Tertinggi', 'Harga Tertinggi',
@ -27,172 +27,218 @@ class PetugasPaketController extends GetxController {
'Nama Z-A', 'Nama Z-A',
]; ];
// Data dummy paket // For backward compatibility
final paketList = <Map<String, dynamic>>[].obs; final RxList<Map<String, dynamic>> paketList = <Map<String, dynamic>>[].obs;
final filteredPaketList = <Map<String, dynamic>>[].obs; final RxList<Map<String, dynamic>> filteredPaketList = <Map<String, dynamic>>[].obs;
// Logger
late final Logger _logger;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
loadPaketData();
}
// Format harga ke Rupiah // Initialize logger
String formatPrice(int price) { _logger = Logger(
final formatter = NumberFormat.currency( printer: PrettyPrinter(
locale: 'id', methodCount: 0,
symbol: 'Rp ', errorMethodCount: 5,
decimalDigits: 0, colors: true,
printEmojis: true,
),
); );
return formatter.format(price);
// Load initial data
fetchPackages();
} }
// Load data paket dummy /// Fetch packages from the API
Future<void> loadPaketData() async { Future<void> fetchPackages() async {
try {
isLoading.value = true; isLoading.value = true;
await Future.delayed(const Duration(milliseconds: 800)); // Simulasi loading _logger.i('🔄 [fetchPackages] Fetching packages...');
paketList.value = [ final result = await _asetProvider.getAllPaket();
{
'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(); if (result.isEmpty) {
_logger.w(' [fetchPackages] No packages found');
packages.clear();
filteredPackages.clear();
return;
}
packages.assignAll(result);
filteredPackages.assignAll(result);
// Update legacy list for backward compatibility
_updateLegacyPaketList();
_logger.i('✅ [fetchPackages] Successfully loaded ${result.length} packages');
} catch (e, stackTrace) {
_logger.e('❌ [fetchPackages] Error fetching packages',
error: e,
stackTrace: stackTrace);
Get.snackbar(
'Error',
'Gagal memuat data paket. Silakan coba lagi.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoading.value = false; isLoading.value = false;
} }
// Filter paket berdasarkan search query dan kategori
void filterPaket() {
filteredPaketList.value =
paketList.where((paket) {
final matchesQuery =
paket['nama'].toString().toLowerCase().contains(
searchQuery.value.toLowerCase(),
) ||
paket['deskripsi'].toString().toLowerCase().contains(
searchQuery.value.toLowerCase(),
);
final matchesCategory =
selectedCategory.value == 'Semua' ||
paket['kategori'] == selectedCategory.value;
return matchesQuery && matchesCategory;
}).toList();
// Sort the filtered list
sortFilteredList();
} }
// Sort the filtered list /// 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 {
_logger.d(' [loadPaketData] Using fetchPackages() instead');
await fetchPackages();
}
/// Filter packages based on search query and category
void filterPaket() {
try {
_logger.d('🔄 [filterPaket] Filtering packages...');
if (searchQuery.value.isEmpty && selectedCategory.value == 'Semua') {
filteredPackages.value = List.from(packages);
filteredPaketList.value = List.from(paketList);
} else {
// Filter new packages
filteredPackages.value = packages.where((paket) {
final matchesSearch = searchQuery.value.isEmpty ||
paket.nama.toLowerCase().contains(searchQuery.value.toLowerCase());
// For now, we're not using categories in the new model
// You can add category filtering if needed
final matchesCategory = selectedCategory.value == 'Semua';
return matchesSearch && matchesCategory;
}).toList();
// Also update legacy list for backward compatibility
filteredPaketList.value = paketList.where((paket) {
final matchesSearch = searchQuery.value.isEmpty ||
(paket['nama']?.toString() ?? '').toLowerCase()
.contains(searchQuery.value.toLowerCase());
// For legacy support, check if category exists
final matchesCategory = selectedCategory.value == 'Semua' ||
(paket['kategori']?.toString() ?? '') == selectedCategory.value;
return matchesSearch && matchesCategory;
}).toList();
}
sortFilteredList();
_logger.d('✅ [filterPaket] Filtered to ${filteredPackages.length} packages');
} catch (e, stackTrace) {
_logger.e('❌ [filterPaket] Error filtering packages',
error: e,
stackTrace: stackTrace);
}
}
/// Sort the filtered list based on the selected sort option
void sortFilteredList() { void sortFilteredList() {
try {
_logger.d('🔄 [sortFilteredList] Sorting packages by ${sortBy.value}');
// Sort new packages
switch (sortBy.value) { switch (sortBy.value) {
case 'Terbaru': case 'Terbaru':
filteredPaketList.sort( filteredPackages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
(a, b) => b['created_at'].compareTo(a['created_at']),
);
break; break;
case 'Terlama': case 'Terlama':
filteredPaketList.sort( filteredPackages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
(a, b) => a['created_at'].compareTo(b['created_at']),
);
break; break;
case 'Harga Tertinggi': case 'Harga Tertinggi':
filteredPaketList.sort((a, b) => b['harga'].compareTo(a['harga'])); filteredPackages.sort((a, b) => b.harga.compareTo(a.harga));
break; break;
case 'Harga Terendah': case 'Harga Terendah':
filteredPaketList.sort((a, b) => a['harga'].compareTo(b['harga'])); filteredPackages.sort((a, b) => a.harga.compareTo(b.harga));
break; break;
case 'Nama A-Z': case 'Nama A-Z':
filteredPaketList.sort((a, b) => a['nama'].compareTo(b['nama'])); filteredPackages.sort((a, b) => a.nama.compareTo(b.nama));
break; break;
case 'Nama Z-A': case 'Nama Z-A':
filteredPaketList.sort((a, b) => b['nama'].compareTo(a['nama'])); filteredPackages.sort((a, b) => b.nama.compareTo(a.nama));
break; break;
} }
// Also sort legacy list for backward compatibility
switch (sortBy.value) {
case 'Terbaru':
filteredPaketList.sort((a, b) =>
((b['created_at'] ?? '') as String).compareTo((a['created_at'] ?? '') as String));
break;
case 'Terlama':
filteredPaketList.sort((a, b) =>
((a['created_at'] ?? '') as String).compareTo((b['created_at'] ?? '') as String));
break;
case 'Harga Tertinggi':
filteredPaketList.sort((a, b) =>
((b['harga'] ?? 0) as int).compareTo((a['harga'] ?? 0) as int));
break;
case 'Harga Terendah':
filteredPaketList.sort((a, b) =>
((a['harga'] ?? 0) as int).compareTo((b['harga'] ?? 0) as int));
break;
case 'Nama A-Z':
filteredPaketList.sort((a, b) =>
((a['nama'] ?? '') as String).compareTo((b['nama'] ?? '') as String));
break;
case 'Nama Z-A':
filteredPaketList.sort((a, b) =>
((b['nama'] ?? '') as String).compareTo((a['nama'] ?? '') as String));
break;
}
_logger.d('✅ [sortFilteredList] Sorted ${filteredPackages.length} packages');
} catch (e, stackTrace) {
_logger.e('❌ [sortFilteredList] Error sorting packages',
error: e,
stackTrace: stackTrace);
}
} }
// Set search query dan filter paket // Set search query dan filter paket
@ -214,40 +260,134 @@ class PetugasPaketController extends GetxController {
} }
// Tambah paket baru // Tambah paket baru
void addPaket(Map<String, dynamic> paket) { Future<void> addPaket(Map<String, dynamic> paketData) async {
paketList.add(paket); try {
isLoading.value = true;
// Convert to PaketModel
final newPaket = PaketModel.fromJson({
...paketData,
'id': DateTime.now().millisecondsSinceEpoch.toString(),
'created_at': DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
});
// Add to the list
packages.add(newPaket);
_updateLegacyPaketList();
filterPaket(); filterPaket();
Get.back(); Get.back();
Get.snackbar( Get.snackbar(
'Sukses', 'Sukses',
'Paket baru berhasil ditambahkan', 'Paket baru berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM, snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
); );
} catch (e, stackTrace) {
_logger.e('❌ [addPaket] Error adding package',
error: e,
stackTrace: stackTrace);
Get.snackbar(
'Error',
'Gagal menambahkan paket. Silakan coba lagi.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoading.value = false;
}
} }
// Edit paket // Edit paket
void editPaket(String id, Map<String, dynamic> updatedPaket) { Future<void> editPaket(String id, Map<String, dynamic> updatedData) async {
final index = paketList.indexWhere((element) => element['id'] == id); try {
isLoading.value = true;
final index = packages.indexWhere((pkg) => pkg.id == id);
if (index >= 0) { if (index >= 0) {
paketList[index] = updatedPaket; // 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(); filterPaket();
Get.back(); Get.back();
Get.snackbar( Get.snackbar(
'Sukses', 'Sukses',
'Paket berhasil diperbarui', 'Paket berhasil diperbarui',
snackPosition: SnackPosition.BOTTOM, snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
); );
} }
} catch (e, stackTrace) {
_logger.e('❌ [editPaket] Error updating package',
error: e,
stackTrace: stackTrace);
Get.snackbar(
'Error',
'Gagal memperbarui paket. Silakan coba lagi.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoading.value = false;
}
} }
// Hapus paket // Hapus paket
void deletePaket(String id) { Future<void> deletePaket(String id) async {
paketList.removeWhere((element) => element['id'] == id); try {
isLoading.value = true;
// Remove from the main list
packages.removeWhere((pkg) => pkg.id == id);
_updateLegacyPaketList();
filterPaket(); filterPaket();
Get.back();
Get.snackbar( Get.snackbar(
'Sukses', 'Sukses',
'Paket berhasil dihapus', 'Paket berhasil dihapus',
snackPosition: SnackPosition.BOTTOM, snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
); );
} catch (e, stackTrace) {
_logger.e('❌ [deletePaket] Error deleting package',
error: e,
stackTrace: stackTrace);
Get.snackbar(
'Error',
'Gagal menghapus paket. Silakan coba lagi.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoading.value = false;
}
}
/// Format price to Rupiah currency
String formatPrice(num price) {
return 'Rp ${NumberFormat('#,##0', 'id_ID').format(price)}';
} }
} }

View File

@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.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 { class PetugasSewaController extends GetxController {
// Reactive variables // Reactive variables
@ -7,7 +10,7 @@ class PetugasSewaController extends GetxController {
final searchQuery = ''.obs; final searchQuery = ''.obs;
final orderIdQuery = ''.obs; final orderIdQuery = ''.obs;
final selectedStatusFilter = 'Semua'.obs; final selectedStatusFilter = 'Semua'.obs;
final filteredSewaList = <Map<String, dynamic>>[].obs; final filteredSewaList = <SewaModel>[].obs;
// Filter options // Filter options
final List<String> statusFilters = [ final List<String> statusFilters = [
@ -15,13 +18,19 @@ class PetugasSewaController extends GetxController {
'Menunggu Pembayaran', 'Menunggu Pembayaran',
'Periksa Pembayaran', 'Periksa Pembayaran',
'Diterima', 'Diterima',
'Aktif',
'Dikembalikan', 'Dikembalikan',
'Selesai', 'Selesai',
'Dibatalkan', 'Dibatalkan',
]; ];
// Mock data for sewa list // 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 @override
void onInit() { void onInit() {
@ -41,25 +50,21 @@ class PetugasSewaController extends GetxController {
void _updateFilteredList() { void _updateFilteredList() {
filteredSewaList.value = filteredSewaList.value =
sewaList.where((sewa) { sewaList.where((sewa) {
// Apply search filter final query = searchQuery.value.toLowerCase();
final matchesSearch = sewa['nama_warga'] // Apply search filter: nama warga, id pesanan, atau asetId
.toString() final matchesSearch =
.toLowerCase() sewa.wargaNama.toLowerCase().contains(query) ||
.contains(searchQuery.value.toLowerCase()); sewa.id.toLowerCase().contains(query) ||
(sewa.asetId != null &&
// Apply order ID filter if provided sewa.asetId!.toLowerCase().contains(query));
final matchesOrderId =
orderIdQuery.value.isEmpty ||
sewa['order_id'].toString().toLowerCase().contains(
orderIdQuery.value.toLowerCase(),
);
// Apply status filter if not 'Semua' // Apply status filter if not 'Semua'
final matchesStatus = final matchesStatus =
selectedStatusFilter.value == 'Semua' || selectedStatusFilter.value == 'Semua' ||
sewa['status'] == selectedStatusFilter.value; sewa.status.toUpperCase() ==
selectedStatusFilter.value.toUpperCase();
return matchesSearch && matchesOrderId && matchesStatus; return matchesSearch && matchesStatus;
}).toList(); }).toList();
} }
@ -68,100 +73,8 @@ class PetugasSewaController extends GetxController {
isLoading.value = true; isLoading.value = true;
try { try {
// Simulate API call delay final data = await SewaService().fetchAllSewa();
await Future.delayed(const Duration(milliseconds: 800)); sewaList.assignAll(data);
// 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',
},
]);
} catch (e) { } catch (e) {
print('Error loading sewa data: $e'); print('Error loading sewa data: $e');
} finally { } finally {
@ -196,10 +109,11 @@ class PetugasSewaController extends GetxController {
sewaList.where((sewa) { sewaList.where((sewa) {
bool matchesStatus = bool matchesStatus =
selectedStatusFilter.value == 'Semua' || selectedStatusFilter.value == 'Semua' ||
sewa['status'] == selectedStatusFilter.value; sewa.status.toUpperCase() ==
selectedStatusFilter.value.toUpperCase();
bool matchesSearch = bool matchesSearch =
searchQuery.value.isEmpty || searchQuery.value.isEmpty ||
sewa['nama_warga'].toLowerCase().contains( sewa.wargaNama.toLowerCase().contains(
searchQuery.value.toLowerCase(), searchQuery.value.toLowerCase(),
); );
return matchesStatus && matchesSearch; return matchesStatus && matchesSearch;
@ -213,102 +127,367 @@ class PetugasSewaController extends GetxController {
// Get color based on status // Get color based on status
Color getStatusColor(String status) { Color getStatusColor(String status) {
switch (status) { switch (status.toUpperCase()) {
case 'Menunggu Pembayaran': case 'MENUNGGU PEMBAYARAN':
return Colors.orange; return Colors.orangeAccent;
case 'Periksa Pembayaran': case 'PERIKSA PEMBAYARAN':
return Colors.amber.shade700; return Colors.amber;
case 'Diterima': case 'DITERIMA':
return Colors.blue; return Colors.blueAccent;
case 'Pembayaran Denda': case 'AKTIF':
return Colors.deepOrange;
case 'Periksa Denda':
return Colors.red.shade600;
case 'Dikembalikan':
return Colors.teal;
case 'Sedang Disewa':
return Colors.green; 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; return Colors.purple;
case 'Dibatalkan': case 'DIBATALKAN':
return Colors.red; return Colors.red;
default: default:
return Colors.grey; return Colors.grey;
} }
} }
// Handle sewa approval (from "Periksa Pembayaran" to "Diterima") // Get icon based on status
void approveSewa(String id) { IconData getStatusIcon(String status) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id); switch (status) {
if (index != -1) { case 'MENUNGGU PEMBAYARAN':
final sewa = Map<String, dynamic>.from(sewaList[index]); return Icons.payments_outlined;
final currentStatus = sewa['status']; case 'PERIKSA PEMBAYARAN':
return Icons.fact_check_outlined;
if (currentStatus == 'Periksa Pembayaran') { case 'DITERIMA':
sewa['status'] = 'Diterima'; return Icons.check_circle_outlined;
} else if (currentStatus == 'Periksa Denda') { case 'AKTIF':
sewa['status'] = 'Selesai'; return Icons.play_circle_outline;
} else if (currentStatus == 'Menunggu Pembayaran') { case 'PEMBYARAN DENDA':
sewa['status'] = 'Periksa Pembayaran'; 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;
}
} }
sewaList[index] = sewa; // Handle sewa approval (from "Periksa Pembayaran" to "Diterima")
void approveSewa(String id) {
final index = sewaList.indexWhere((sewa) => sewa.id == id);
if (index != -1) {
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.refresh();
} }
} }
}
// Handle sewa rejection or cancellation // Handle sewa rejection or cancellation
void rejectSewa(String id) { void rejectSewa(String id) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id); final index = sewaList.indexWhere((sewa) => sewa.id == id);
if (index != -1) { if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]); final sewa = sewaList[index];
sewa['status'] = 'Dibatalkan'; sewaList[index] = SewaModel(
sewaList[index] = sewa; 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(); sewaList.refresh();
} }
} }
// Request payment for penalty // Request payment for penalty
void requestPenaltyPayment(String id) { void requestPenaltyPayment(String id) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id); final index = sewaList.indexWhere((sewa) => sewa.id == id);
if (index != -1) { if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]); final sewa = sewaList[index];
sewa['status'] = 'Pembayaran Denda'; sewaList[index] = SewaModel(
sewaList[index] = sewa; 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(); sewaList.refresh();
} }
} }
// Mark penalty payment as requiring inspection // Mark penalty payment as requiring inspection
void markPenaltyForInspection(String id) { void markPenaltyForInspection(String id) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id); final index = sewaList.indexWhere((sewa) => sewa.id == id);
if (index != -1) { if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]); final sewa = sewaList[index];
sewa['status'] = 'Periksa Denda'; sewaList[index] = SewaModel(
sewaList[index] = sewa; 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(); sewaList.refresh();
} }
} }
// Handle sewa completion // Handle sewa completion
void completeSewa(String id) { void completeSewa(String id) async {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id); final index = sewaList.indexWhere((sewa) => sewa.id == id);
if (index != -1) { if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]); final sewa = sewaList[index];
sewa['status'] = 'Selesai'; sewaList[index] = SewaModel(
sewaList[index] = sewa; 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(); sewaList.refresh();
// Update status in database
final asetProvider = Get.find<AsetProvider>();
await asetProvider.updateSewaAsetStatus(
sewaAsetId: id,
status: 'SELESAI',
);
} }
} }
// Mark rental as returned // Mark rental as returned
void markAsReturned(String id) { Future<void> markAsReturned(String id) async {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id); final index = sewaList.indexWhere((sewa) => sewa.id == id);
if (index != -1) { if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]); final sewa = sewaList[index];
sewa['status'] = 'Dikembalikan'; sewaList[index] = SewaModel(
sewaList[index] = sewa; 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(); 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,187 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:bumrent_app/app/data/models/aset_model.dart';
import 'package:bumrent_app/app/data/providers/aset_provider.dart';
class PetugasTambahAsetController extends GetxController { 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.BOTTOM,
);
// Optionally navigate back if in edit mode without an ID
Future.delayed(Duration.zero, () => Get.back());
}
} else {
// Set default values for new asset
debugPrint('[DEBUG] Add new asset mode');
quantityController.text = '1';
unitOfMeasureController.text = 'Unit';
}
} else {
// Default values for new asset when no arguments are passed
debugPrint('[DEBUG] No arguments passed, defaulting to add new asset mode');
quantityController.text = '1';
unitOfMeasureController.text = 'Unit';
}
} catch (e, stackTrace) {
debugPrint('[ERROR] Error in onInit: $e');
debugPrint('Stack trace: $stackTrace');
// Ensure loading is set to false even if there's an error
isLoading.value = false;
Get.snackbar(
'Error',
'Terjadi kesalahan saat memuat data',
snackPosition: SnackPosition.BOTTOM,
);
}
// Listen to field changes for validation
nameController.addListener(validateForm);
descriptionController.addListener(validateForm);
quantityController.addListener(validateForm);
pricePerHourController.addListener(validateForm);
pricePerDayController.addListener(validateForm);
}
final AsetProvider _asetProvider = Get.find<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.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
// Optionally navigate back if there's an error
Future.delayed(const Duration(seconds: 2), () => Get.back());
} finally {
isLoading.value = false;
}
}
// Form controllers // Form controllers
final nameController = TextEditingController(); final nameController = TextEditingController();
final descriptionController = TextEditingController(); final descriptionController = TextEditingController();
@ -23,27 +203,17 @@ class PetugasTambahAsetController extends GetxController {
final categoryOptions = ['Sewa', 'Langganan']; final categoryOptions = ['Sewa', 'Langganan'];
final statusOptions = ['Tersedia', 'Pemeliharaan']; final statusOptions = ['Tersedia', 'Pemeliharaan'];
// Images // List to store selected images
final selectedImages = <String>[].obs; final RxList<XFile> selectedImages = <XFile>[].obs;
// List to store network image URLs
final RxList<String> networkImageUrls = <String>[].obs;
final _picker = ImagePicker();
// Form validation // Form validation
final isFormValid = false.obs; final isFormValid = false.obs;
final isSubmitting = 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 @override
void onClose() { void onClose() {
@ -89,17 +259,140 @@ class PetugasTambahAsetController extends GetxController {
validateForm(); validateForm();
} }
// Add image to the list (in a real app, this would handle file upload)
void addImage(String imagePath) { // Create a new asset in Supabase
selectedImages.add(imagePath); Future<String?> _createAsset(
validateForm(); 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;
} }
// Remove image from the list 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;
}
}
// 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) { void removeImage(int index) {
if (index >= 0 && index < selectedImages.length) { 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); selectedImages.removeAt(index);
validateForm();
} }
} }
@ -133,62 +426,130 @@ class PetugasTambahAsetController extends GetxController {
basicValid && perHourValid && perDayValid && anyTimeOptionSelected; basicValid && perHourValid && perDayValid && anyTimeOptionSelected;
} }
// Submit form and save asset // Submit form and save or update asset
Future<void> saveAsset() async { Future<void> saveAsset() async {
if (!isFormValid.value) return; if (!isFormValid.value) return;
isSubmitting.value = true; isSubmitting.value = true;
try { try {
// In a real app, this would make an API call to save the asset // Prepare the basic asset data
await Future.delayed(const Duration(seconds: 1)); // Mock API call final Map<String, dynamic> assetData = {
// Prepare asset data
final assetData = {
'nama': nameController.text, 'nama': nameController.text,
'deskripsi': descriptionController.text, 'deskripsi': descriptionController.text,
'kategori': selectedCategory.value, 'kategori': 'sewa', // Default to 'sewa' category
'status': selectedStatus.value, 'status': selectedStatus.value,
'kuantitas': int.parse(quantityController.text), 'kuantitas': int.parse(quantityController.text),
'satuan_ukur': unitOfMeasureController.text, 'satuan_ukur': 'unit', // Default unit of measure
'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,
}; };
// Log the data (in a real app, this would be sent to an API) // Handle time options and pricing
print('Asset data: $assetData'); final List<Map<String, dynamic>> satuanWaktuSewa = [];
// Return to the asset list page if (timeOptions['Per Jam']?.value == true) {
Get.back(); final hargaPerJam = int.tryParse(pricePerHourController.text) ?? 0;
final maxJam = int.tryParse(maxHourController.text) ?? 24;
if (hargaPerJam <= 0) {
throw Exception('Harga per jam harus lebih dari 0');
}
satuanWaktuSewa.add({
'satuan_waktu': 'jam',
'harga': hargaPerJam,
'maksimal_waktu': maxJam,
});
}
if (timeOptions['Per Hari']?.value == true) {
final hargaPerHari = int.tryParse(pricePerDayController.text) ?? 0;
final maxHari = int.tryParse(maxDayController.text) ?? 30;
if (hargaPerHari <= 0) {
throw Exception('Harga per hari harus lebih dari 0');
}
satuanWaktuSewa.add({
'satuan_waktu': 'hari',
'harga': hargaPerHari,
'maksimal_waktu': maxHari,
});
}
// 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 // Show success message
Get.snackbar( Get.snackbar(
'Berhasil', 'Sukses',
'Aset berhasil ditambahkan', isEditing.value ? 'Aset berhasil diperbarui' : 'Aset berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green, backgroundColor: Colors.green,
colorText: Colors.white, colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM, duration: const Duration(seconds: 3),
); );
// Navigate back with success after a short delay
await Future.delayed(const Duration(seconds: 1));
Get.back(result: true);
} else {
throw Exception('Gagal menyimpan aset');
}
} catch (e) { } catch (e) {
// Show error message // Show error message
Get.snackbar( Get.snackbar(
@ -203,8 +564,68 @@ class PetugasTambahAsetController extends GetxController {
} }
} }
// 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.BOTTOM,
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.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
}
// For demonstration purposes: add sample image // For demonstration purposes: add sample image
void addSampleImage() { 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,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:bumrent_app/app/data/models/paket_model.dart';
import 'package:image_picker/image_picker.dart';
import 'package:bumrent_app/app/data/providers/aset_provider.dart';
import 'dart:io';
import 'package:uuid/uuid.dart';
class PetugasTambahPaketController extends GetxController { class PetugasTambahPaketController extends GetxController {
// Form controllers // Form controllers
@ -10,14 +16,14 @@ class PetugasTambahPaketController extends GetxController {
// Dropdown and toggle values // Dropdown and toggle values
final selectedCategory = 'Bulanan'.obs; final selectedCategory = 'Bulanan'.obs;
final selectedStatus = 'Aktif'.obs; final selectedStatus = 'Tersedia'.obs;
// Category options // Category options
final categoryOptions = ['Bulanan', 'Tahunan', 'Premium', 'Bisnis']; final categoryOptions = ['Bulanan', 'Tahunan', 'Premium', 'Bisnis'];
final statusOptions = ['Aktif', 'Nonaktif']; final statusOptions = ['Tersedia', 'Pemeliharaan'];
// Images // Images
final selectedImages = <String>[].obs; final selectedImages = <dynamic>[].obs;
// For package name and description // For package name and description
final packageNameController = TextEditingController(); final packageNameController = TextEditingController();
@ -31,21 +37,85 @@ class PetugasTambahPaketController extends GetxController {
// For asset selection // For asset selection
final RxList<Map<String, dynamic>> availableAssets = final RxList<Map<String, dynamic>> availableAssets =
<Map<String, dynamic>>[].obs; <Map<String, dynamic>>[].obs;
final Rx<int?> selectedAsset = Rx<int?>(null); final Rx<String?> selectedAsset = Rx<String?>(null);
final RxBool isLoadingAssets = false.obs; final RxBool isLoadingAssets = false.obs;
// Form validation // Form validation
final isFormValid = false.obs; final isFormValid = false.obs;
final isSubmitting = false.obs; final isSubmitting = false.obs;
// New RxBool for editing
final isEditing = false.obs;
final timeOptions = {'Per Jam': true.obs, 'Per Hari': false.obs};
final pricePerHourController = TextEditingController();
final maxHourController = TextEditingController();
final pricePerDayController = TextEditingController();
final maxDayController = TextEditingController();
final _picker = ImagePicker();
final isFormChanged = false.obs;
Map<String, dynamic> initialFormData = {};
final AsetProvider _asetProvider = Get.put(AsetProvider());
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
// Ambil flag isEditing dari arguments
isEditing.value =
Get.arguments != null && Get.arguments['isEditing'] == true;
if (isEditing.value) {
final paketArg = Get.arguments['paket'];
String? paketId;
if (paketArg != null) {
if (paketArg is Map && paketArg['id'] != null) {
paketId = paketArg['id'].toString();
} else if (paketArg is PaketModel && paketArg.id != null) {
paketId = paketArg.id.toString();
}
}
if (paketId != null) {
fetchPaketDetail(paketId);
}
}
// Listen to field changes for validation // Listen to field changes for validation
nameController.addListener(validateForm); nameController.addListener(() {
descriptionController.addListener(validateForm); validateForm();
priceController.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 // Load available assets when the controller initializes
fetchAvailableAssets(); fetchAvailableAssets();
@ -61,6 +131,10 @@ class PetugasTambahPaketController extends GetxController {
packageNameController.dispose(); packageNameController.dispose();
packageDescriptionController.dispose(); packageDescriptionController.dispose();
packagePriceController.dispose(); packagePriceController.dispose();
pricePerHourController.dispose();
maxHourController.dispose();
pricePerDayController.dispose();
maxDayController.dispose();
super.onClose(); super.onClose();
} }
@ -68,18 +142,21 @@ class PetugasTambahPaketController extends GetxController {
void setCategory(String category) { void setCategory(String category) {
selectedCategory.value = category; selectedCategory.value = category;
validateForm(); validateForm();
checkFormChanged();
} }
// Change selected status // Change selected status
void setStatus(String status) { void setStatus(String status) {
selectedStatus.value = status; selectedStatus.value = status;
validateForm(); validateForm();
checkFormChanged();
} }
// Add image to the list (in a real app, this would handle file upload) // Add image to the list (in a real app, this would handle file upload)
void addImage(String imagePath) { void addImage(String imagePath) {
selectedImages.add(imagePath); selectedImages.add(imagePath);
validateForm(); validateForm();
checkFormChanged();
} }
// Remove image from the list // Remove image from the list
@ -87,34 +164,43 @@ class PetugasTambahPaketController extends GetxController {
if (index >= 0 && index < selectedImages.length) { if (index >= 0 && index < selectedImages.length) {
selectedImages.removeAt(index); selectedImages.removeAt(index);
validateForm(); validateForm();
checkFormChanged();
} }
} }
// Fetch available assets from the API or local data // Fetch available assets from Supabase and filter out already selected ones
void fetchAvailableAssets() { Future<void> fetchAvailableAssets() async {
isLoadingAssets.value = true; isLoadingAssets.value = true;
try {
// This is a mock implementation - replace with actual API call final allAssets = await _asetProvider.getSewaAsets();
Future.delayed(const Duration(seconds: 1), () { final selectedAsetIds =
availableAssets.value = [ packageItems.map((item) => item['asetId'].toString()).toSet();
{'id': 1, 'nama': 'Laptop Dell XPS', 'stok': 5}, // Only show assets not yet selected
{'id': 2, 'nama': 'Proyektor Epson', 'stok': 3}, availableAssets.value =
{'id': 3, 'nama': 'Meja Kantor', 'stok': 10}, allAssets
{'id': 4, 'nama': 'Kursi Ergonomis', 'stok': 15}, .where((aset) => !selectedAsetIds.contains(aset.id))
{'id': 5, 'nama': 'Printer HP LaserJet', 'stok': 2}, .map(
{'id': 6, 'nama': 'AC Panasonic 1PK', 'stok': 8}, (aset) => {
]; 'id': aset.id,
'nama': aset.nama,
'stok': aset.kuantitas,
},
)
.toList();
} catch (e) {
availableAssets.value = [];
} finally {
isLoadingAssets.value = false; isLoadingAssets.value = false;
}); }
} }
// Set the selected asset // Set the selected asset
void setSelectedAsset(int? assetId) { void setSelectedAsset(String? assetId) {
selectedAsset.value = assetId; selectedAsset.value = assetId;
} }
// Get remaining stock for an asset (considering current selections) // Get remaining stock for an asset (considering current selections)
int getRemainingStock(int assetId) { int getRemainingStock(String assetId) {
// Find the asset in available assets // Find the asset in available assets
final asset = availableAssets.firstWhere( final asset = availableAssets.firstWhere(
(item) => item['id'] == assetId, (item) => item['id'] == assetId,
@ -129,7 +215,7 @@ class PetugasTambahPaketController extends GetxController {
// Calculate how many of this asset are already in the package // Calculate how many of this asset are already in the package
int alreadySelected = 0; int alreadySelected = 0;
for (var item in packageItems) { for (var item in packageItems) {
if (item['asetId'] == assetId) { if (item['asetId'].toString() == assetId) {
alreadySelected += item['jumlah'] as int; alreadySelected += item['jumlah'] as int;
} }
} }
@ -204,6 +290,8 @@ class PetugasTambahPaketController extends GetxController {
backgroundColor: Colors.green, backgroundColor: Colors.green,
colorText: Colors.white, colorText: Colors.white,
); );
checkFormChanged();
} }
// Update an existing package item // Update an existing package item
@ -301,11 +389,16 @@ class PetugasTambahPaketController extends GetxController {
backgroundColor: Colors.green, backgroundColor: Colors.green,
colorText: Colors.white, colorText: Colors.white,
); );
checkFormChanged();
} }
// Remove an item from the package // Remove an item from the package
void removeItem(int index) { void removeItem(int index) {
if (index >= 0 && index < packageItems.length) {
packageItems.removeAt(index); packageItems.removeAt(index);
checkFormChanged();
}
Get.snackbar( Get.snackbar(
'Dihapus', 'Dihapus',
'Item berhasil dihapus dari paket', 'Item berhasil dihapus dari paket',
@ -319,10 +412,7 @@ class PetugasTambahPaketController extends GetxController {
void validateForm() { void validateForm() {
// Basic validation // Basic validation
bool basicValid = bool basicValid =
nameController.text.isNotEmpty && nameController.text.isNotEmpty && descriptionController.text.isNotEmpty;
descriptionController.text.isNotEmpty &&
priceController.text.isNotEmpty &&
int.tryParse(priceController.text) != null;
// Package should have at least one item // Package should have at least one item
bool hasItems = packageItems.isNotEmpty; bool hasItems = packageItems.isNotEmpty;
@ -337,27 +427,191 @@ class PetugasTambahPaketController extends GetxController {
isSubmitting.value = true; isSubmitting.value = true;
try { try {
// In a real app, this would make an API call to save the package final supabase = Supabase.instance.client;
await Future.delayed(const Duration(seconds: 1)); // Mock API call 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 // 1. Update data utama paket
final paketData = { await supabase
.from('paket')
.update({
'nama': nameController.text, 'nama': nameController.text,
'deskripsi': descriptionController.text, 'deskripsi': descriptionController.text,
'kategori': selectedCategory.value, 'status': selectedStatus.value.toLowerCase(),
'status': selectedStatus.value == 'Aktif', })
'harga': int.parse(priceController.text), .eq('id', paketId);
'gambar': selectedImages,
'items': packageItems,
};
// Log the data (in a real app, this would be sent to an API) // 2. Update paket_item: hapus semua, insert ulang
print('Package data: $paketData'); 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 // 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,
});
}
// 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.back();
Get.snackbar(
'Berhasil',
'Paket berhasil diperbarui',
backgroundColor: Colors.green,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
} else {
// --- ADD LOGIC ---
final uuid = Uuid();
final String paketId = uuid.v4();
// 1. Insert ke tabel paket
await supabase.from('paket').insert({
'id': paketId,
'nama': nameController.text,
'deskripsi': descriptionController.text,
'status': selectedStatus.value.toLowerCase(),
});
// 2. Insert ke paket_item
for (var item in packageItems) {
await supabase.from('paket_item').insert({
'paket_id': paketId,
'aset_id': item['asetId'],
'kuantitas': item['jumlah'],
});
}
// 3. Insert ke satuan_waktu_sewa (ambil UUID satuan waktu)
final satuanWaktuList = await supabase
.from('satuan_waktu')
.select('id, nama_satuan_waktu');
String? jamId;
String? hariId;
for (var sw in satuanWaktuList) {
final nama = (sw['nama_satuan_waktu'] ?? '').toString().toLowerCase();
if (nama.contains('jam')) jamId = sw['id'];
if (nama.contains('hari')) hariId = sw['id'];
}
if (timeOptions['Per Jam']?.value == true && jamId != null) {
await supabase.from('satuan_waktu_sewa').insert({
'paket_id': paketId,
'satuan_waktu_id': jamId,
'harga': int.tryParse(pricePerHourController.text) ?? 0,
'maksimal_waktu': int.tryParse(maxHourController.text) ?? 0,
});
}
if (timeOptions['Per Hari']?.value == true && hariId != null) {
await supabase.from('satuan_waktu_sewa').insert({
'paket_id': paketId,
'satuan_waktu_id': hariId,
'harga': int.tryParse(pricePerDayController.text) ?? 0,
'maksimal_waktu': int.tryParse(maxDayController.text) ?? 0,
});
}
// 4. Insert ke foto_aset (upload jika perlu)
for (final img in selectedImages) {
String url = '';
if (img is String && img.startsWith('http')) {
url = img;
} else if (img is XFile) {
final uploaded = await _asetProvider.uploadFileToStorage(
File(img.path),
);
if (uploaded != null) url = uploaded;
}
if (url.isNotEmpty) {
await supabase.from('foto_aset').insert({
'id_paket': paketId,
'foto_aset': url,
});
}
}
// Sukses
Get.back(); Get.back();
// Show success message
Get.snackbar( Get.snackbar(
'Berhasil', 'Berhasil',
'Paket berhasil ditambahkan', 'Paket berhasil ditambahkan',
@ -365,11 +619,12 @@ class PetugasTambahPaketController extends GetxController {
colorText: Colors.white, colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM, snackPosition: SnackPosition.BOTTOM,
); );
}
} catch (e) { } catch (e) {
// Show error message // Show error message
Get.snackbar( Get.snackbar(
'Gagal', 'Gagal',
'Terjadi kesalahan: ${e.toString()}', 'Terjadi kesalahan: \\${e.toString()}',
backgroundColor: Colors.red, backgroundColor: Colors.red,
colorText: Colors.white, colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM, snackPosition: SnackPosition.BOTTOM,
@ -390,4 +645,215 @@ class PetugasTambahPaketController extends GetxController {
selectedImages.add('https://example.com/sample_image.jpg'); selectedImages.add('https://example.com/sample_image.jpg');
validateForm(); 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.BOTTOM,
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.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
}
void checkFormChanged() {
final current = {
'nama': nameController.text,
'deskripsi': descriptionController.text,
'status': selectedStatus.value,
'images': List.from(selectedImages),
'items': List.from(packageItems),
'perJam': timeOptions['Per Jam']?.value ?? false,
'perHari': timeOptions['Per Hari']?.value ?? false,
'hargaJam': pricePerHourController.text,
'maxJam': maxHourController.text,
'hargaHari': pricePerDayController.text,
'maxHari': maxDayController.text,
};
isFormChanged.value = current.toString() != initialFormData.toString();
}
} }

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../controllers/petugas_aset_controller.dart'; import '../controllers/petugas_aset_controller.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../../../theme/app_colors_petugas.dart'; import '../../../theme/app_colors_petugas.dart';
import '../widgets/petugas_bumdes_bottom_navbar.dart'; import '../widgets/petugas_bumdes_bottom_navbar.dart';
import '../widgets/petugas_side_navbar.dart'; import '../widgets/petugas_side_navbar.dart';
@ -23,26 +24,12 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
void initState() { void initState() {
super.initState(); super.initState();
controller = Get.find<PetugasAsetController>(); controller = Get.find<PetugasAsetController>();
_tabController = TabController(length: 2, vsync: this); // Initialize with default tab (sewa)
controller.changeTab(0);
// 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);
}
});
} }
@override @override
void dispose() { void dispose() {
_tabController.dispose();
super.dispose(); super.dispose();
} }
@ -82,7 +69,7 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
body: Column( body: Column(
children: [ children: [
_buildSearchBar(), _buildSearchBar(),
_buildTabBar(), const SizedBox(height: 16),
Expanded(child: _buildAssetList()), Expanded(child: _buildAssetList()),
], ],
), ),
@ -93,7 +80,13 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
), ),
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_ASET), onPressed: () {
// Navigate to PetugasTambahAsetView in add mode
Get.toNamed(
Routes.PETUGAS_TAMBAH_ASET,
arguments: {'isEditing': false, 'assetData': null},
);
},
backgroundColor: AppColorsPetugas.babyBlueBright, backgroundColor: AppColorsPetugas.babyBlueBright,
icon: Icon(Icons.add, color: AppColorsPetugas.blueGrotto), icon: Icon(Icons.add, color: AppColorsPetugas.blueGrotto),
label: Text( label: Text(
@ -144,60 +137,19 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
); );
} }
Widget _buildTabBar() { // Tab bar has been removed as per requirements
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),
),
],
),
),
),
],
),
);
}
Widget _buildAssetList() { Widget _buildAssetList() {
return Obx(() { 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) { if (controller.isLoading.value) {
return Center( return Center(
child: CircularProgressIndicator( child: CircularProgressIndicator(
@ -255,10 +207,15 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
color: AppColorsPetugas.blueGrotto, color: AppColorsPetugas.blueGrotto,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemCount: controller.filteredAsetList.length, itemCount: controller.filteredAsetList.length + 1,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index < controller.filteredAsetList.length) {
final aset = controller.filteredAsetList[index]; final aset = controller.filteredAsetList[index];
return _buildAssetCard(context, aset); return _buildAssetCard(context, aset);
} else {
// Blank space at the end
return const SizedBox(height: 80);
}
}, },
), ),
); );
@ -266,7 +223,31 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
} }
Widget _buildAssetCard(BuildContext context, Map<String, dynamic> aset) { 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( return Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
@ -290,24 +271,49 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
child: Row( child: Row(
children: [ children: [
// Asset image // Asset image
Container( SizedBox(
width: 80, width: 80,
height: 80, height: 80,
decoration: BoxDecoration( child: ClipRRect(
color: AppColorsPetugas.babyBlueLight,
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12), topLeft: Radius.circular(12),
bottomLeft: Radius.circular(12), bottomLeft: Radius.circular(12),
), ),
), child: CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
placeholder:
(context, url) => Container(
color: AppColorsPetugas.babyBlueLight,
child: Center( child: Center(
child: Icon( child: Icon(
_getAssetIcon(aset['kategori']), _getAssetIcon(
color: AppColorsPetugas.navyBlue, kategori,
), // Show category icon as placeholder
color: AppColorsPetugas.navyBlue.withOpacity(
0.5,
),
size: 32, 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,
),
),
),
),
),
),
// Asset info // Asset info
Expanded( Expanded(
@ -323,8 +329,8 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Text(
aset['nama'], nama,
style: TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 14, fontSize: 14,
color: AppColorsPetugas.navyBlue, color: AppColorsPetugas.navyBlue,
@ -333,12 +339,63 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( // Harga dan satuan waktu (multi-line, tampilkan semua dari satuanWaktuSewa)
'${controller.formatPrice(aset['harga'])} ${aset['satuan']}', 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( style: TextStyle(
fontSize: 12, fontSize: 12,
color: AppColorsPetugas.textSecondary, color: AppColorsPetugas.textSecondary,
), ),
maxLines: 2,
overflow: TextOverflow.ellipsis,
);
}
},
), ),
], ],
), ),
@ -383,11 +440,36 @@ class _PetugasAsetViewState extends State<PetugasAsetView>
children: [ children: [
// Edit icon // Edit icon
GestureDetector( GestureDetector(
onTap: onTap: () {
() => _showAddEditAssetDialog( // Navigate to PetugasTambahAsetView in edit mode with only the asset ID
context, final assetId =
aset: aset, aset['id']?.toString() ??
), ''; // Changed from 'id_aset' to 'id'
debugPrint(
'[DEBUG] Navigating to edit asset with ID: $assetId',
);
debugPrint(
'[DEBUG] Full asset data: $aset',
); // Log full asset data for debugging
if (assetId.isEmpty) {
debugPrint('[ERROR] Asset ID is empty!');
Get.snackbar(
'Error',
'ID Aset tidak valid',
snackPosition: SnackPosition.BOTTOM,
);
return;
}
Get.toNamed(
Routes.PETUGAS_TAMBAH_ASET,
arguments: {
'isEditing': true,
'assetId': assetId,
},
);
},
child: Container( child: Container(
padding: const EdgeInsets.all(5), padding: const EdgeInsets.all(5),
decoration: BoxDecoration( decoration: BoxDecoration(

View File

@ -5,6 +5,7 @@ import '../controllers/petugas_bumdes_dashboard_controller.dart';
import '../widgets/petugas_bumdes_bottom_navbar.dart'; import '../widgets/petugas_bumdes_bottom_navbar.dart';
import '../widgets/petugas_side_navbar.dart'; import '../widgets/petugas_side_navbar.dart';
import '../../../theme/app_colors_petugas.dart'; import '../../../theme/app_colors_petugas.dart';
import '../../../utils/format_utils.dart';
class PetugasBumdesDashboardView class PetugasBumdesDashboardView
extends GetView<PetugasBumdesDashboardController> { extends GetView<PetugasBumdesDashboardController> {
@ -23,12 +24,7 @@ class PetugasBumdesDashboardView
backgroundColor: AppColorsPetugas.navyBlue, backgroundColor: AppColorsPetugas.navyBlue,
foregroundColor: Colors.white, foregroundColor: Colors.white,
elevation: 0, elevation: 0,
actions: [ // actions: [],
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => _showLogoutConfirmation(context),
),
],
), ),
drawer: PetugasSideNavbar(controller: controller), drawer: PetugasSideNavbar(controller: controller),
drawerEdgeDragWidth: 60, drawerEdgeDragWidth: 60,
@ -118,8 +114,6 @@ class PetugasBumdesDashboardView
), ),
_buildRevenueStatistics(), _buildRevenueStatistics(),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildRevenueSources(),
const SizedBox(height: 16),
_buildRevenueTrend(), _buildRevenueTrend(),
// Add some padding at the bottom for better scrolling // Add some padding at the bottom for better scrolling
@ -156,7 +150,31 @@ class PetugasBumdesDashboardView
children: [ children: [
Row( Row(
children: [ children: [
Container( 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,
),
),
),
);
} else {
return Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2), color: Colors.white.withOpacity(0.2),
@ -174,7 +192,9 @@ class PetugasBumdesDashboardView
color: Colors.white, color: Colors.white,
size: 30, size: 30,
), ),
), );
}
}),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
@ -208,15 +228,17 @@ class PetugasBumdesDashboardView
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Obx( Obx(() {
() => Text( final name = controller.userName.value;
controller.userEmail.value, final email = controller.userEmail.value;
return Text(
name.isNotEmpty ? name : email,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
color: Colors.white70, color: Colors.white70,
), ),
), );
), }),
], ],
), ),
), ),
@ -642,19 +664,24 @@ class PetugasBumdesDashboardView
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Obx( Obx(() {
() => Text( final stats = controller.pembayaranStats;
controller.totalPendapatanBulanIni.value, final total = stats['totalThisMonth'] ?? 0.0;
return Text(
formatRupiah(total),
style: TextStyle( style: TextStyle(
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColorsPetugas.success, color: AppColorsPetugas.success,
), ),
), );
), }),
const SizedBox(height: 6), const SizedBox(height: 6),
Obx( Obx(() {
() => Row( final stats = controller.pembayaranStats;
final percent = stats['percentComparedLast'] ?? 0.0;
final isPositive = percent >= 0;
return Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -663,7 +690,7 @@ class PetugasBumdesDashboardView
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color:
controller.isKenaikanPositif.value isPositive
? AppColorsPetugas.success.withOpacity( ? AppColorsPetugas.success.withOpacity(
0.1, 0.1,
) )
@ -676,23 +703,23 @@ class PetugasBumdesDashboardView
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
controller.isKenaikanPositif.value isPositive
? Icons.arrow_upward ? Icons.arrow_upward
: Icons.arrow_downward, : Icons.arrow_downward,
size: 14, size: 14,
color: color:
controller.isKenaikanPositif.value isPositive
? AppColorsPetugas.success ? AppColorsPetugas.success
: AppColorsPetugas.error, : AppColorsPetugas.error,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
controller.persentaseKenaikan.value, '${percent.toStringAsFixed(1)}%',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: color:
controller.isKenaikanPositif.value isPositive
? AppColorsPetugas.success ? AppColorsPetugas.success
: AppColorsPetugas.error, : AppColorsPetugas.error,
), ),
@ -709,8 +736,8 @@ class PetugasBumdesDashboardView
), ),
), ),
], ],
), );
), }),
], ],
), ),
), ),
@ -747,12 +774,29 @@ class PetugasBumdesDashboardView
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: _buildRevenueQuickInfo( child: Obx(() {
'Pendapatan Sewa', final stats = controller.pembayaranStats;
controller.pendapatanSewa.value, final totalTunai = stats['totalTunai'] ?? 0.0;
return _buildRevenueQuickInfo(
'Tunai',
formatRupiah(totalTunai),
AppColorsPetugas.navyBlue, AppColorsPetugas.navyBlue,
Icons.shopping_cart_outlined, Icons.payments,
);
}),
), ),
const SizedBox(width: 12),
Expanded(
child: Obx(() {
final stats = controller.pembayaranStats;
final totalTransfer = stats['totalTransfer'] ?? 0.0;
return _buildRevenueQuickInfo(
'Transfer',
formatRupiah(totalTransfer),
AppColorsPetugas.blueGrotto,
Icons.account_balance,
);
}),
), ),
], ],
); );
@ -811,81 +855,6 @@ class PetugasBumdesDashboardView
); );
} }
Widget _buildRevenueSources() {
return Card(
elevation: 2,
shadowColor: AppColorsPetugas.shadowColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sumber Pendapatan',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 20),
Row(
children: [
// Revenue Donut Chart
Expanded(
flex: 2,
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColorsPetugas.navyBlue.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Text(
'Sewa Aset',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 8),
Obx(
() => Text(
controller.pendapatanSewa.value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
),
const SizedBox(height: 8),
Text(
'100% dari total pendapatan',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade700,
),
),
],
),
),
],
),
),
],
),
],
),
),
);
}
Widget _buildRevenueTrend() { Widget _buildRevenueTrend() {
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun']; final months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun'];
@ -912,6 +881,9 @@ class PetugasBumdesDashboardView
child: Obx(() { child: Obx(() {
// Get the trend data from controller // Get the trend data from controller
final List<double> trendData = controller.trendPendapatan; final List<double> trendData = controller.trendPendapatan;
if (trendData.isEmpty) {
return Center(child: Text('Tidak ada data'));
}
final double maxValue = trendData.reduce( final double maxValue = trendData.reduce(
(curr, next) => curr > next ? curr : next, (curr, next) => curr > next ? curr : next,
); );
@ -925,28 +897,28 @@ class PetugasBumdesDashboardView
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
'${maxValue.toStringAsFixed(1)}M', formatRupiah(maxValue),
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
color: AppColorsPetugas.textSecondary, color: AppColorsPetugas.textSecondary,
), ),
), ),
Text( Text(
'${(maxValue * 0.75).toStringAsFixed(1)}M', formatRupiah(maxValue * 0.75),
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
color: AppColorsPetugas.textSecondary, color: AppColorsPetugas.textSecondary,
), ),
), ),
Text( Text(
'${(maxValue * 0.5).toStringAsFixed(1)}M', formatRupiah(maxValue * 0.5),
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
color: AppColorsPetugas.textSecondary, color: AppColorsPetugas.textSecondary,
), ),
), ),
Text( Text(
'${(maxValue * 0.25).toStringAsFixed(1)}M', formatRupiah(maxValue * 0.25),
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
color: AppColorsPetugas.textSecondary, color: AppColorsPetugas.textSecondary,

View File

@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../controllers/petugas_paket_controller.dart'; import 'package:bumrent_app/app/modules/petugas_bumdes/controllers/petugas_paket_controller.dart';
import '../../../theme/app_colors_petugas.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_bumdes_bottom_navbar.dart';
import '../widgets/petugas_side_navbar.dart'; import '../widgets/petugas_side_navbar.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart'; import '../controllers/petugas_bumdes_dashboard_controller.dart';
import '../../../routes/app_routes.dart'; import '../../../routes/app_routes.dart';
import '../../../theme/app_colors_petugas.dart';
class PetugasPaketView extends GetView<PetugasPaketController> { class PetugasPaketView extends GetView<PetugasPaketController> {
const PetugasPaketView({Key? key}) : super(key: key); const PetugasPaketView({Key? key}) : super(key: key);
@ -53,7 +55,11 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
), ),
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_PAKET), onPressed:
() => Get.toNamed(
Routes.PETUGAS_TAMBAH_PAKET,
arguments: {'isEditing': false},
),
label: Text( label: Text(
'Tambah Paket', 'Tambah Paket',
style: TextStyle( style: TextStyle(
@ -115,7 +121,7 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
); );
} }
if (controller.filteredPaketList.isEmpty) { if (controller.filteredPackages.isEmpty) {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -136,7 +142,11 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_PAKET), onPressed:
() => Get.toNamed(
Routes.PETUGAS_TAMBAH_PAKET,
arguments: {'isEditing': false},
),
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('Tambah Paket'), label: const Text('Tambah Paket'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@ -161,18 +171,192 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
color: AppColorsPetugas.blueGrotto, color: AppColorsPetugas.blueGrotto,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemCount: controller.filteredPaketList.length, itemCount: controller.filteredPackages.length + 1,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final paket = controller.filteredPaketList[index]; if (index < controller.filteredPackages.length) {
final paket = controller.filteredPackages[index];
return _buildPaketCard(context, paket); return _buildPaketCard(context, paket);
} else {
// Blank space at the end
return const SizedBox(height: 80);
}
}, },
), ),
); );
}); });
} }
Widget _buildPaketCard(BuildContext context, Map<String, dynamic> paket) { // Format price helper method
final isAvailable = paket['tersedia'] == true; 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( return Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
@ -196,24 +380,85 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
child: Row( child: Row(
children: [ children: [
// Paket image or icon // Paket image or icon
Container( SizedBox(
width: 80, width: 80,
height: 80, height: 80,
decoration: BoxDecoration( child: ClipRRect(
color: AppColorsPetugas.babyBlueLight,
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12), topLeft: Radius.circular(12),
bottomLeft: Radius.circular(12), bottomLeft: Radius.circular(12),
), ),
), 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: Center(
child: Icon( child: Icon(
_getPaketIcon(paket['kategori']), _getPaketIcon(
color: AppColorsPetugas.navyBlue, _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, 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,
),
),
),
),
),
// Paket info // Paket info
Expanded( Expanded(
@ -228,9 +473,10 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Package name
Text( Text(
paket['nama'], nama,
style: TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 14, fontSize: 14,
color: AppColorsPetugas.navyBlue, color: AppColorsPetugas.navyBlue,
@ -239,6 +485,111 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
// 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( Text(
'Rp ${_formatPrice(paket['harga'])}', 'Rp ${_formatPrice(paket['harga'])}',
style: TextStyle( style: TextStyle(
@ -247,6 +598,7 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
), ),
), ),
], ],
],
), ),
), ),
@ -258,25 +610,31 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color:
isAvailable status.toLowerCase() == 'tersedia'
? AppColorsPetugas.successLight ? AppColorsPetugas.successLight
: status.toLowerCase() == 'pemeliharaan'
? AppColorsPetugas.warningLight
: AppColorsPetugas.errorLight, : AppColorsPetugas.errorLight,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: color:
isAvailable status.toLowerCase() == 'tersedia'
? AppColorsPetugas.success ? AppColorsPetugas.success
: status.toLowerCase() == 'pemeliharaan'
? AppColorsPetugas.warning
: AppColorsPetugas.error, : AppColorsPetugas.error,
width: 1, width: 1,
), ),
), ),
child: Text( child: Text(
isAvailable ? 'Aktif' : 'Nonaktif', status,
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
color: color:
isAvailable status.toLowerCase() == 'tersedia'
? AppColorsPetugas.success ? AppColorsPetugas.success
: status.toLowerCase() == 'pemeliharaan'
? AppColorsPetugas.warning
: AppColorsPetugas.error, : AppColorsPetugas.error,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@ -290,9 +648,12 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
// Edit icon // Edit icon
GestureDetector( GestureDetector(
onTap: onTap:
() => _showAddEditPaketDialog( () => Get.toNamed(
context, Routes.PETUGAS_TAMBAH_PAKET,
paket: paket, arguments: {
'isEditing': true,
'paket': paket,
},
), ),
child: Container( child: Container(
padding: const EdgeInsets.all(5), padding: const EdgeInsets.all(5),
@ -350,33 +711,42 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
); );
} }
String _formatPrice(dynamic price) { // Add this helper method to get color based on status
if (price == null) return '0'; Color _getStatusColor(String status) {
switch (status.toLowerCase()) {
// Convert the price to string and handle formatting case 'aktif':
String priceStr = price.toString(); return AppColorsPetugas.success;
case 'tidak aktif':
// Add thousand separators case 'nonaktif':
final RegExp reg = RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'); return AppColorsPetugas.error;
String formatted = priceStr.replaceAllMapped(reg, (Match m) => '${m[1]}.'); case 'dalam perbaikan':
case 'maintenance':
return formatted; return AppColorsPetugas.warning;
case 'tersedia':
return AppColorsPetugas.success;
case 'pemeliharaan':
return AppColorsPetugas.warning;
default:
return Colors.grey;
}
} }
IconData _getPaketIcon(String? category) { IconData _getPaketIcon(String? timeUnit) {
if (category == null) return Icons.category; if (timeUnit == null) return Icons.access_time;
switch (category.toLowerCase()) { switch (timeUnit.toLowerCase()) {
case 'bulanan': case 'jam':
return Icons.calendar_month; return Icons.access_time;
case 'tahunan': case 'hari':
return Icons.calendar_today; return Icons.calendar_today;
case 'premium': case 'minggu':
return Icons.star; return Icons.date_range;
case 'bisnis': case 'bulan':
return Icons.business; return Icons.calendar_month;
case 'tahun':
return Icons.calendar_view_month;
default: default:
return Icons.category; return Icons.access_time;
} }
} }
@ -426,7 +796,27 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
); );
} }
void _showPaketDetails(BuildContext context, Map<String, dynamic> paket) { void _showPaketDetails(BuildContext context, dynamic paket) {
// Handle both Map and PaketModel for backward compatibility
final isPaketModel = paket is PaketModel;
final String nama =
isPaketModel
? paket.nama
: (paket['nama']?.toString() ?? 'Paket Tanpa Nama');
final String? deskripsi =
isPaketModel ? paket.deskripsi : paket['deskripsi']?.toString();
final bool isAvailable =
isPaketModel
? (paket.kuantitas > 0)
: ((paket['kuantitas'] as int?) ?? 0) > 0;
final dynamic harga =
isPaketModel
? (paket.satuanWaktuSewa.isNotEmpty
? paket.satuanWaktuSewa.first['harga']
: paket.harga)
: (paket['harga'] ?? 0);
// Items are not part of the PaketModel, so we'll use an empty list
final List<Map<String, dynamic>> items = [];
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@ -448,7 +838,7 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
paket['nama'], nama,
style: TextStyle( style: TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -473,16 +863,15 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildDetailItem('Kategori', paket['kategori']),
_buildDetailItem( _buildDetailItem(
'Harga', 'Harga',
controller.formatPrice(paket['harga']), 'Rp ${_formatPrice(harga)}',
), ),
_buildDetailItem( _buildDetailItem(
'Status', 'Status',
paket['tersedia'] ? 'Tersedia' : 'Tidak Tersedia', isAvailable ? 'Tersedia' : 'Tidak Tersedia',
), ),
_buildDetailItem('Deskripsi', paket['deskripsi']), _buildDetailItem('Deskripsi', deskripsi ?? '-'),
], ],
), ),
), ),
@ -502,11 +891,11 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
child: ListView.separated( child: ListView.separated(
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true, shrinkWrap: true,
itemCount: paket['items'].length, itemCount: items.length,
separatorBuilder: separatorBuilder:
(context, index) => const Divider(height: 1), (context, index) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = paket['items'][index]; final item = items[index];
return ListTile( return ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: AppColorsPetugas.babyBlue, backgroundColor: AppColorsPetugas.babyBlue,
@ -601,10 +990,11 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
); );
} }
void _showAddEditPaketDialog( void _showAddEditPaketDialog(BuildContext context, {dynamic paket}) {
BuildContext context, { // Handle both Map and PaketModel for backward compatibility
Map<String, dynamic>? paket, final isPaketModel = paket is PaketModel;
}) { final String? id = isPaketModel ? paket.id : paket?['id'];
final String title = id == null ? 'Tambah Paket' : 'Edit Paket';
final isEditing = paket != null; final isEditing = paket != null;
// This would be implemented with proper form validation in a real app // This would be implemented with proper form validation in a real app
@ -613,7 +1003,7 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
builder: (context) { builder: (context) {
return AlertDialog( return AlertDialog(
title: Text( title: Text(
isEditing ? 'Edit Paket' : 'Tambah Paket Baru', title,
style: TextStyle(color: AppColorsPetugas.navyBlue), style: TextStyle(color: AppColorsPetugas.navyBlue),
), ),
content: const Text( content: const Text(
@ -652,10 +1042,13 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
); );
} }
void _showDeleteConfirmation( void _showDeleteConfirmation(BuildContext context, dynamic paket) {
BuildContext context, // Handle both Map and PaketModel for backward compatibility
Map<String, dynamic> paket, 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( showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
@ -664,9 +1057,7 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
'Konfirmasi Hapus', 'Konfirmasi Hapus',
style: TextStyle(color: AppColorsPetugas.navyBlue), style: TextStyle(color: AppColorsPetugas.navyBlue),
), ),
content: Text( content: Text('Apakah Anda yakin ingin menghapus paket "$nama"?'),
'Apakah Anda yakin ingin menghapus paket "${paket['nama']}"?',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@ -678,7 +1069,7 @@ class PetugasPaketView extends GetView<PetugasPaketController> {
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
controller.deletePaket(paket['id']); controller.deletePaket(id);
Get.snackbar( Get.snackbar(
'Paket Dihapus', 'Paket Dihapus',
'Paket berhasil dihapus dari sistem', 'Paket berhasil dihapus dari sistem',

View File

@ -6,6 +6,7 @@ import '../widgets/petugas_bumdes_bottom_navbar.dart';
import '../widgets/petugas_side_navbar.dart'; import '../widgets/petugas_side_navbar.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart'; import '../controllers/petugas_bumdes_dashboard_controller.dart';
import 'petugas_detail_sewa_view.dart'; import 'petugas_detail_sewa_view.dart';
import '../../../data/models/rental_booking_model.dart';
class PetugasSewaView extends StatefulWidget { class PetugasSewaView extends StatefulWidget {
const PetugasSewaView({Key? key}) : super(key: key); const PetugasSewaView({Key? key}) : super(key: key);
@ -160,6 +161,10 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
} }
Widget _buildSearchSection() { Widget _buildSearchSection() {
// Tambahkan controller untuk TextField agar bisa dikosongkan
final TextEditingController searchController = TextEditingController(
text: controller.searchQuery.value,
);
return Container( return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -173,9 +178,9 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
], ],
), ),
child: TextField( child: TextField(
controller: searchController,
onChanged: (value) { onChanged: (value) {
controller.setSearchQuery(value); controller.setSearchQuery(value);
controller.setOrderIdQuery(value);
}, },
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Cari nama warga atau ID pesanan...', hintText: 'Cari nama warga atau ID pesanan...',
@ -204,11 +209,22 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
), ),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
isDense: true, isDense: true,
suffixIcon: Icon( suffixIcon: Obx(
Icons.tune_rounded, () =>
controller.searchQuery.value.isNotEmpty
? IconButton(
icon: Icon(
Icons.close,
color: AppColorsPetugas.textSecondary, color: AppColorsPetugas.textSecondary,
size: 20, size: 20,
), ),
onPressed: () {
searchController.clear();
controller.setSearchQuery('');
},
)
: SizedBox.shrink(),
),
), ),
), ),
); );
@ -241,17 +257,44 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
final filteredList = final filteredList =
status == 'Semua' status == 'Semua'
? controller.filteredSewaList ? controller.filteredSewaList
: status == 'Menunggu Pembayaran'
? controller.sewaList
.where(
(sewa) =>
sewa.status.toUpperCase() == 'MENUNGGU PEMBAYARAN' ||
sewa.status.toUpperCase() == 'PEMBAYARAN DENDA',
)
.toList()
: status == 'Periksa Pembayaran' : status == 'Periksa Pembayaran'
? controller.sewaList ? controller.sewaList
.where( .where(
(sewa) => (sewa) =>
sewa['status'] == 'Periksa Pembayaran' || sewa.status.toUpperCase() == 'PERIKSA PEMBAYARAN' ||
sewa['status'] == 'Pembayaran Denda' || sewa.status.toUpperCase() == 'PERIKSA PEMBAYARAN DENDA',
sewa['status'] == 'Periksa Denda',
) )
.toList() .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 : controller.sewaList
.where((sewa) => sewa['status'] == status) .where((sewa) => sewa.status == status)
.toList(); .toList();
if (filteredList.isEmpty) { if (filteredList.isEmpty) {
@ -313,40 +356,25 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
}); });
} }
Widget _buildSewaCard(BuildContext context, Map<String, dynamic> sewa) { Widget _buildSewaCard(BuildContext context, SewaModel sewa) {
final statusColor = controller.getStatusColor(sewa['status']); final statusColor = controller.getStatusColor(sewa.status);
final status = sewa['status']; final status = sewa.status;
// Get appropriate icon for status // Get appropriate icon for status
IconData statusIcon; IconData statusIcon = controller.getStatusIcon(status);
switch (status) {
case 'Menunggu Pembayaran': // Flag untuk membedakan tipe pesanan
statusIcon = Icons.payments_outlined; final bool isAset = sewa.tipePesanan == 'tunggal';
break; final bool isPaket = sewa.tipePesanan == 'paket';
case 'Periksa Pembayaran':
statusIcon = Icons.fact_check_outlined; // Pilih nama aset/paket
break; final String namaAsetAtauPaket =
case 'Diterima': isAset
statusIcon = Icons.check_circle_outlined; ? (sewa.asetNama ?? '-')
break; : (isPaket ? (sewa.paketNama ?? '-') : '-');
case 'Pembayaran Denda': // Pilih foto aset/paket jika ingin digunakan
statusIcon = Icons.money_off_csred_outlined; final String? fotoAsetAtauPaket =
break; isAset ? sewa.asetFoto : (isPaket ? sewa.paketFoto : null);
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;
}
return Container( return Container(
margin: const EdgeInsets.only(bottom: 16), margin: const EdgeInsets.only(bottom: 16),
@ -370,6 +398,35 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
child: Row( child: Row(
@ -378,14 +435,22 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
CircleAvatar( CircleAvatar(
radius: 24, radius: 24,
backgroundColor: AppColorsPetugas.babyBlueLight, backgroundColor: AppColorsPetugas.babyBlueLight,
child: Text( backgroundImage:
sewa['nama_warga'].substring(0, 1).toUpperCase(), (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( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColorsPetugas.blueGrotto, color: AppColorsPetugas.blueGrotto,
), ),
), )
: null,
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@ -395,58 +460,25 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
sewa['nama_warga'], sewa.wargaNama,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColorsPetugas.textPrimary, 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( Text(
status, 'Tanggal Pesan: ' +
(sewa.tanggalPemesanan != null
? '${sewa.tanggalPemesanan.day.toString().padLeft(2, '0')}-${sewa.tanggalPemesanan.month.toString().padLeft(2, '0')}-${sewa.tanggalPemesanan.year}'
: '-'),
style: TextStyle( style: TextStyle(
fontSize: 12, 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, color: AppColorsPetugas.textSecondary,
), ),
), ),
], ],
), ),
],
),
), ),
// Price // Price
@ -460,7 +492,7 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Text(
controller.formatPrice(sewa['total_biaya']), controller.formatPrice(sewa.totalTagihan),
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -481,12 +513,30 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
child: Divider(height: 1, color: Colors.grey.shade200), child: Divider(height: 1, color: Colors.grey.shade200),
), ),
// Asset details // Asset/Paket details
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Row( child: Row(
children: [ children: [
// Asset icon // 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( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -501,13 +551,13 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
// Asset name and duration // Asset/Paket name and duration
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
sewa['nama_aset'], namaAsetAtauPaket,
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -524,7 +574,7 @@ class _PetugasSewaViewState extends State<PetugasSewaView>
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'${sewa['tanggal_mulai']} - ${sewa['tanggal_selesai']}', '${sewa.waktuMulai.toIso8601String().substring(0, 10)} - ${sewa.waktuSelesai.toIso8601String().substring(0, 10)}',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: AppColorsPetugas.textSecondary, color: AppColorsPetugas.textSecondary,

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:io';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../../theme/app_colors_petugas.dart'; import '../../../theme/app_colors_petugas.dart';
@ -9,32 +10,51 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Obx(() => Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: AppBar( appBar: AppBar(
title: const Text( title: Text(
'Tambah Aset', controller.isEditing.value ? 'Edit Aset' : 'Tambah Aset',
style: TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
backgroundColor: AppColorsPetugas.navyBlue, backgroundColor: AppColorsPetugas.navyBlue,
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
), ),
body: SafeArea( body: Stack(
children: [
SafeArea(
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [_buildHeaderSection(), _buildFormSection(context)], children: [
_buildHeaderSection(),
_buildFormSection(context),
],
), ),
), ),
), ),
if (controller.isLoading.value)
Container(
color: Colors.black54,
child: const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColorsPetugas.blueGrotto),
),
),
),
],
),
bottomNavigationBar: _buildBottomBar(), bottomNavigationBar: _buildBottomBar(),
)),
); );
} }
Widget _buildHeaderSection() { Widget _buildHeaderSection() {
return Container( return Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.only(top: 10, left: 20, right: 20, bottom: 5), // Reduced padding
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: [AppColorsPetugas.navyBlue, AppColorsPetugas.blueGrotto], colors: [AppColorsPetugas.navyBlue, AppColorsPetugas.blueGrotto],
@ -42,50 +62,8 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
), ),
), ),
child: Column( child: Container(
crossAxisAlignment: CrossAxisAlignment.start, height: 12, // Further reduced height
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),
),
),
],
),
),
],
),
],
), ),
); );
} }
@ -131,49 +109,29 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
_buildImageUploader(), _buildImageUploader(),
const SizedBox(height: 24), const SizedBox(height: 24),
// Category Section // Status Section
_buildSectionHeader(icon: Icons.category, title: 'Kategori & Status'), _buildSectionHeader(icon: Icons.check_circle, title: 'Status'),
const SizedBox(height: 16), const SizedBox(height: 16),
// Category and Status as cards // Status card
Row( _buildCategorySelect(
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', title: 'Status',
options: controller.statusOptions, options: controller.statusOptions,
selectedOption: controller.selectedStatus, selectedOption: controller.selectedStatus,
onChanged: controller.setStatus, onChanged: controller.setStatus,
icon: Icons.check_circle, icon: Icons.check_circle,
), ),
),
],
),
const SizedBox(height: 24), const SizedBox(height: 24),
// Quantity Section // Quantity Section
_buildSectionHeader( _buildSectionHeader(
icon: Icons.format_list_numbered, icon: Icons.format_list_numbered,
title: 'Kuantitas & Pengukuran', title: 'Kuantitas',
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Quantity fields // Quantity field
Row( _buildTextField(
children: [
Expanded(
flex: 2,
child: _buildTextField(
label: 'Kuantitas', label: 'Kuantitas',
hint: 'Jumlah aset', hint: 'Jumlah aset',
controller: controller.quantityController, controller: controller.quantityController,
@ -182,19 +140,6 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
inputFormatters: [FilteringTextInputFormatter.digitsOnly], inputFormatters: [FilteringTextInputFormatter.digitsOnly],
prefixIcon: Icons.numbers, 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,
),
),
],
),
const SizedBox(height: 24), const SizedBox(height: 24),
// Rental Options Section // Rental Options Section
@ -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() { Widget _buildImageUploader() {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -696,7 +749,7 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
children: [ children: [
// Add button // Add button
GestureDetector( GestureDetector(
onTap: () => controller.addSampleImage(), onTap: _showImageSourceOptions,
child: Container( child: Container(
width: 100, width: 100,
height: 100, height: 100,
@ -732,37 +785,76 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
), ),
// Image previews // Image previews
...controller.selectedImages.asMap().entries.map((entry) { ...List<Widget>.generate(
final index = entry.key; controller.selectedImages.length,
return Container( (index) => Stack(
children: [
Container(
width: 100, width: 100,
height: 100, height: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueLight,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
boxShadow: [ border: Border.all(color: Colors.grey[300]!),
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, 2),
), ),
], 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),
);
},
), ),
child: Stack( );
children: [ } else {
ClipRRect( // Display local file
borderRadius: BorderRadius.circular(10), return ClipRRect(
child: Container( borderRadius: BorderRadius.circular(8),
width: 100, child: FutureBuilder<File>(
height: 100, future: File(controller.selectedImages[index].path).exists().then((exists) {
color: AppColorsPetugas.babyBlueLight, if (exists) {
child: Center( return File(controller.selectedImages[index].path);
child: Icon( } else {
Icons.image, return File(controller.selectedImages[index].path);
color: AppColorsPetugas.blueGrotto, }
size: 40, }),
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( Positioned(
@ -771,30 +863,29 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
child: GestureDetector( child: GestureDetector(
onTap: () => controller.removeImage(index), onTap: () => controller.removeImage(index),
child: Container( child: Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(2),
decoration: BoxDecoration( decoration: const BoxDecoration(
color: Colors.white, color: Colors.white,
shape: BoxShape.circle, shape: BoxShape.circle,
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.1), color: Colors.black26,
blurRadius: 3, blurRadius: 4,
offset: const Offset(0, 1), offset: Offset(0, 1),
), ),
], ],
), ),
child: Icon( child: const Icon(
Icons.close, Icons.close,
color: AppColorsPetugas.error,
size: 16, size: 16,
color: Colors.red,
), ),
), ),
), ),
), ),
], ],
), ),
); ).toList(),
}),
], ],
), ),
), ),
@ -850,7 +941,9 @@ class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
), ),
) )
: const Icon(Icons.save), : 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( style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto, backgroundColor: AppColorsPetugas.blueGrotto,
foregroundColor: Colors.white, foregroundColor: Colors.white,

View File

@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../../theme/app_colors_petugas.dart'; import '../../../theme/app_colors_petugas.dart';
import '../controllers/petugas_tambah_paket_controller.dart'; import '../controllers/petugas_tambah_paket_controller.dart';
import 'dart:io';
class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> { class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
const PetugasTambahPaketView({Key? key}) : super(key: key); const PetugasTambahPaketView({Key? key}) : super(key: key);
@ -12,9 +13,11 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: AppBar( appBar: AppBar(
title: const Text( title: Obx(
'Tambah Paket', () => Text(
style: TextStyle(fontWeight: FontWeight.w600), controller.isEditing.value ? 'Edit Paket' : 'Tambah Paket',
style: const TextStyle(fontWeight: FontWeight.w600),
),
), ),
backgroundColor: AppColorsPetugas.navyBlue, backgroundColor: AppColorsPetugas.navyBlue,
elevation: 0, elevation: 0,
@ -24,7 +27,7 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [_buildHeaderSection(), _buildFormSection(context)], children: [_buildFormSection(context)],
), ),
), ),
), ),
@ -32,64 +35,6 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
); );
} }
Widget _buildHeaderSection() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppColorsPetugas.navyBlue, AppColorsPetugas.blueGrotto],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.category,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Informasi Paket Baru',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'Isi data dengan lengkap untuk menambahkan paket',
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.8),
),
),
],
),
),
],
),
],
),
);
}
Widget _buildFormSection(BuildContext context) { Widget _buildFormSection(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
@ -132,22 +77,22 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
const SizedBox(height: 24), const SizedBox(height: 24),
// Category Section // Category Section
_buildSectionHeader(icon: Icons.category, title: 'Kategori & Status'), _buildSectionHeader(icon: Icons.category, title: 'Status'),
const SizedBox(height: 16), const SizedBox(height: 16),
// Category and Status as cards // Category and Status as cards
Row( Row(
children: [ children: [
Expanded( // Expanded(
child: _buildCategorySelect( // child: _buildCategorySelect(
title: 'Kategori', // title: 'Kategori',
options: controller.categoryOptions, // options: controller.categoryOptions,
selectedOption: controller.selectedCategory, // selectedOption: controller.selectedCategory,
onChanged: controller.setCategory, // onChanged: controller.setCategory,
icon: Icons.category, // icon: Icons.category,
), // ),
), // ),
const SizedBox(width: 12), // const SizedBox(width: 12),
Expanded( Expanded(
child: _buildCategorySelect( child: _buildCategorySelect(
title: 'Status', title: 'Status',
@ -161,24 +106,6 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Price Section
_buildSectionHeader(
icon: Icons.monetization_on,
title: 'Harga Paket',
),
const SizedBox(height: 16),
_buildTextField(
label: 'Harga Paket',
hint: 'Masukkan harga paket',
controller: controller.priceController,
isRequired: true,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
prefixText: 'Rp ',
prefixIcon: Icons.payments,
),
const SizedBox(height: 24),
// Package Items Section // Package Items Section
_buildSectionHeader( _buildSectionHeader(
icon: Icons.inventory_2, icon: Icons.inventory_2,
@ -186,6 +113,40 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildPackageItems(), _buildPackageItems(),
const SizedBox(height: 24),
_buildSectionHeader(
icon: Icons.schedule,
title: 'Opsi Waktu & Harga Sewa',
),
const SizedBox(height: 16),
_buildTimeOptionsCards(),
const SizedBox(height: 16),
Obx(
() => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (controller.timeOptions['Per Jam']!.value)
_buildPriceCard(
title: 'Harga Per Jam',
icon: Icons.timer,
priceController: controller.pricePerHourController,
maxController: controller.maxHourController,
maxLabel: 'Maksimal Jam',
),
if (controller.timeOptions['Per Jam']!.value &&
controller.timeOptions['Per Hari']!.value)
const SizedBox(height: 16),
if (controller.timeOptions['Per Hari']!.value)
_buildPriceCard(
title: 'Harga Per Hari',
icon: Icons.calendar_today,
priceController: controller.pricePerDayController,
maxController: controller.maxDayController,
maxLabel: 'Maksimal Hari',
),
],
),
),
const SizedBox(height: 40), const SizedBox(height: 40),
], ],
), ),
@ -310,7 +271,7 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
const SizedBox(height: 16), const SizedBox(height: 16),
// Asset dropdown // Asset dropdown
DropdownButtonFormField<int>( DropdownButtonFormField<String>(
value: controller.selectedAsset.value, value: controller.selectedAsset.value,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Pilih Aset', labelText: 'Pilih Aset',
@ -319,8 +280,8 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
hint: const Text('Pilih Aset'), hint: const Text('Pilih Aset'),
items: items:
controller.availableAssets.map((asset) { controller.availableAssets.map((asset) {
return DropdownMenuItem<int>( return DropdownMenuItem<String>(
value: asset['id'] as int, value: asset['id'].toString(),
child: Text( child: Text(
'${asset['nama']} (Stok: ${asset['stok']})', '${asset['nama']} (Stok: ${asset['stok']})',
), ),
@ -422,7 +383,7 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
const SizedBox(height: 16), const SizedBox(height: 16),
// Asset dropdown // Asset dropdown
DropdownButtonFormField<int>( DropdownButtonFormField<String>(
value: controller.selectedAsset.value, value: controller.selectedAsset.value,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Pilih Aset', labelText: 'Pilih Aset',
@ -431,8 +392,8 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
hint: const Text('Pilih Aset'), hint: const Text('Pilih Aset'),
items: items:
controller.availableAssets.map((asset) { controller.availableAssets.map((asset) {
return DropdownMenuItem<int>( return DropdownMenuItem<String>(
value: asset['id'] as int, value: asset['id'].toString(),
child: Text( child: Text(
'${asset['nama']} (Stok: ${asset['stok']})', '${asset['nama']} (Stok: ${asset['stok']})',
), ),
@ -757,7 +718,7 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
children: [ children: [
// Add button // Add button
GestureDetector( GestureDetector(
onTap: () => controller.addSampleImage(), onTap: _showImageSourceOptions,
child: Container( child: Container(
width: 100, width: 100,
height: 100, height: 100,
@ -791,37 +752,58 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
), ),
), ),
), ),
// Image previews // Image previews
...controller.selectedImages.asMap().entries.map((entry) { ...List<Widget>.generate(controller.selectedImages.length, (
final index = entry.key; index,
return Container( ) {
final img = controller.selectedImages[index];
return Stack(
children: [
Container(
width: 100, width: 100,
height: 100, height: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueLight,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
boxShadow: [ border: Border.all(color: Colors.grey[300]!),
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, 2),
), ),
], child: ClipRRect(
), borderRadius: BorderRadius.circular(8),
child: Stack( child:
children: [ (img is String && img.startsWith('http'))
ClipRRect( ? Image.network(
borderRadius: BorderRadius.circular(10), img,
child: Container( fit: BoxFit.cover,
width: 100, width: double.infinity,
height: 100, height: double.infinity,
color: AppColorsPetugas.babyBlueLight, errorBuilder:
child: Center( (context, error, stackTrace) =>
const Center(
child: Icon( child: Icon(
Icons.image, Icons.broken_image,
color: AppColorsPetugas.blueGrotto, color: Colors.grey,
size: 40, ),
),
)
: (img is String)
? Container(
color: Colors.grey[200],
child: const Icon(
Icons.broken_image,
color: Colors.grey,
),
)
: Image.file(
File(img.path),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder:
(context, error, stackTrace) =>
const Center(
child: Icon(
Icons.broken_image,
color: Colors.grey,
),
), ),
), ),
), ),
@ -829,35 +811,125 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
Positioned( Positioned(
top: 4, top: 4,
right: 4, right: 4,
child: GestureDetector( child: InkWell(
onTap: () => controller.removeImage(index), onTap: () => controller.removeImage(index),
child: Container( child: Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: const BoxDecoration(
color: Colors.white, color: Colors.white,
shape: BoxShape.circle, shape: BoxShape.circle,
boxShadow: [ ),
BoxShadow( child: const Icon(
color: Colors.black.withOpacity(0.1), Icons.close,
blurRadius: 3, size: 18,
offset: const Offset(0, 1), color: Colors.red,
),
),
),
), ),
], ],
), );
child: Icon( }),
Icons.close, ],
color: AppColorsPetugas.error,
size: 16,
),
),
), ),
), ),
], ],
), ),
); );
}), }
void _showImageSourceOptions() {
Get.bottomSheet(
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, -2),
),
], ],
), ),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
Text(
'Pilih Sumber Gambar',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildImageSourceOption(
icon: Icons.camera_alt,
label: 'Kamera',
onTap: () {
Get.back();
controller.pickImageFromCamera();
},
),
_buildImageSourceOption(
icon: Icons.photo_library,
label: 'Galeri',
onTap: () {
Get.back();
controller.pickImageFromGallery();
},
),
],
),
const SizedBox(height: 10),
],
),
),
);
}
Widget _buildImageSourceOption({
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Column(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueBright,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: AppColorsPetugas.blueGrotto, size: 28),
),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(
fontSize: 14,
color: AppColorsPetugas.navyBlue,
fontWeight: FontWeight.w500,
),
), ),
], ],
), ),
@ -899,26 +971,37 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
final isSubmitting = controller.isSubmitting.value; final isSubmitting = controller.isSubmitting.value;
return ElevatedButton.icon( return ElevatedButton.icon(
onPressed: onPressed:
isValid && !isSubmitting ? controller.savePaket : null, controller.isFormChanged.value && !isSubmitting
? controller.savePaket
: null,
icon: icon:
isSubmitting isSubmitting
? SizedBox( ? const SizedBox(
height: 20, width: 24,
width: 20, height: 24,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
color: Colors.white, color: Colors.white,
), ),
) )
: const Icon(Icons.save), : const Icon(Icons.save),
label: Text(isSubmitting ? 'Menyimpan...' : 'Simpan Paket'), label: Text(
isSubmitting
? 'Menyimpan...'
: (controller.isEditing.value
? 'Simpan Paket'
: 'Tambah Paket'),
),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto, backgroundColor: AppColorsPetugas.blueGrotto,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 16),
elevation: 0, textStyle: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(12),
), ),
disabledBackgroundColor: AppColorsPetugas.textLight, disabledBackgroundColor: AppColorsPetugas.textLight,
), ),
@ -929,4 +1012,226 @@ class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
), ),
); );
} }
Widget _buildTimeOptionsCards() {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
children:
controller.timeOptions.entries.map((entry) {
final option = entry.key;
final isSelected = entry.value;
return Obx(
() => Material(
color: Colors.transparent,
child: InkWell(
onTap: () => controller.toggleTimeOption(option),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color:
isSelected.value
? AppColorsPetugas.blueGrotto.withOpacity(
0.1,
)
: Colors.grey.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
option == 'Per Jam'
? Icons.hourglass_bottom
: Icons.calendar_today,
color:
isSelected.value
? AppColorsPetugas.blueGrotto
: Colors.grey,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color:
isSelected.value
? AppColorsPetugas.navyBlue
: Colors.grey.shade700,
),
),
const SizedBox(height: 2),
Text(
option == 'Per Jam'
? 'Sewa paket dengan basis perhitungan per jam'
: 'Sewa paket dengan basis perhitungan per hari',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
),
Checkbox(
value: isSelected.value,
onChanged:
(_) => controller.toggleTimeOption(option),
activeColor: AppColorsPetugas.blueGrotto,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
),
),
);
}).toList(),
),
);
}
Widget _buildPriceCard({
required String title,
required IconData icon,
required TextEditingController priceController,
required TextEditingController maxController,
required String maxLabel,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColorsPetugas.babyBlue.withOpacity(0.5)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 20, color: AppColorsPetugas.blueGrotto),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColorsPetugas.navyBlue,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Harga Sewa',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.textSecondary,
),
),
const SizedBox(height: 8),
TextFormField(
controller: priceController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
hintText: 'Masukkan harga',
hintStyle: TextStyle(color: AppColorsPetugas.textLight),
prefixText: 'Rp ',
filled: true,
fillColor: AppColorsPetugas.babyBlueBright,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
maxLabel,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.textSecondary,
),
),
const SizedBox(height: 8),
TextFormField(
controller: maxController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
hintText: 'Opsional',
hintStyle: TextStyle(color: AppColorsPetugas.textLight),
filled: true,
fillColor: AppColorsPetugas.babyBlueBright,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
],
),
),
],
),
],
),
);
}
} }

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../../theme/app_colors.dart'; import '../../../theme/app_colors.dart';
import '../../../theme/app_colors_petugas.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart'; import '../controllers/petugas_bumdes_dashboard_controller.dart';
class PetugasSideNavbar extends StatelessWidget { class PetugasSideNavbar extends StatelessWidget {
@ -11,7 +12,7 @@ class PetugasSideNavbar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Drawer( return Drawer(
backgroundColor: Colors.white, backgroundColor: AppColorsPetugas.babyBlueLight,
elevation: 0, elevation: 0,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
@ -32,14 +33,17 @@ class PetugasSideNavbar extends StatelessWidget {
Widget _buildHeader() { Widget _buildHeader() {
return Container( return Container(
padding: const EdgeInsets.fromLTRB(20, 60, 20, 20), padding: const EdgeInsets.fromLTRB(20, 60, 20, 20),
color: AppColors.primary, color: AppColorsPetugas.navyBlue,
width: double.infinity, width: double.infinity,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
Container( Obx(() {
final avatar = controller.avatarUrl.value;
if (avatar.isNotEmpty) {
return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2), border: Border.all(color: Colors.white, width: 2),
@ -47,9 +51,28 @@ class PetugasSideNavbar extends StatelessWidget {
child: CircleAvatar( child: CircleAvatar(
radius: 30, radius: 30,
backgroundColor: Colors.white, backgroundColor: Colors.white,
child: Icon(Icons.person, color: AppColors.primary, size: 36), 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), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(

View File

@ -34,7 +34,7 @@ class SplashView extends GetView<SplashController> {
child: Container( child: Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(
image: DecorationImage( image: DecorationImage(
image: AssetImage('assets/images/pattern.png'), image: AssetImage('assets/images/logo.png'), // Using logo.png which exists
repeat: ImageRepeat.repeat, repeat: ImageRepeat.repeat,
scale: 4.0, scale: 4.0,
), ),

View File

@ -8,12 +8,6 @@ import '../../../data/providers/aset_provider.dart';
class WargaSewaBinding extends Bindings { class WargaSewaBinding extends Bindings {
@override @override
void dependencies() { 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 // Ensure AuthProvider is registered
if (!Get.isRegistered<AuthProvider>()) { if (!Get.isRegistered<AuthProvider>()) {
Get.put(AuthProvider(), permanent: true); Get.put(AuthProvider(), permanent: true);

View File

@ -47,17 +47,21 @@ class PembayaranSewaController extends GetxController
final isLoading = false.obs; final isLoading = false.obs;
final currentStep = 0.obs; final currentStep = 0.obs;
// Payment proof images - now a list to support multiple images (both File and WebImageFile) // Payment proof images for tagihan awal
final RxList<dynamic> paymentProofImages = <dynamic>[].obs; final RxList<dynamic> paymentProofImagesTagihanAwal = <dynamic>[].obs;
// Payment proof images for denda
final RxList<dynamic> paymentProofImagesDenda = <dynamic>[].obs;
// Track original images loaded from database // Track original images loaded from database
final RxList<WebImageFile> originalImages = <WebImageFile>[].obs; final RxList<WebImageFile> originalImages = <WebImageFile>[].obs;
// Track images marked for deletion // Track images marked for deletion
final RxList<WebImageFile> imagesToDelete = <WebImageFile>[].obs; final RxList<WebImageFile> imagesToDeleteTagihanAwal = <WebImageFile>[].obs;
final RxList<WebImageFile> imagesToDeleteDenda = <WebImageFile>[].obs;
// Flag to track if there are changes that need to be saved // Flag to track if there are changes that need to be saved
final RxBool hasUnsavedChanges = false.obs; final RxBool hasUnsavedChangesTagihanAwal = false.obs;
final RxBool hasUnsavedChangesDenda = false.obs;
// Get image widget for a specific image // Get image widget for a specific image
Widget getImageWidget(dynamic imageFile) { Widget getImageWidget(dynamic imageFile) {
@ -98,12 +102,7 @@ class PembayaranSewaController extends GetxController
} }
// For mobile with a File object // For mobile with a File object
else if (imageFile is File) { else if (imageFile is File) {
return Image.file( return Image.file(imageFile, height: 120, width: 120, fit: BoxFit.cover);
imageFile,
height: 120,
width: 120,
fit: BoxFit.cover,
);
} }
// Fallback for any other type // Fallback for any other type
else { else {
@ -118,18 +117,26 @@ class PembayaranSewaController extends GetxController
// Remove an image from the list // Remove an image from the list
void removeImage(dynamic image) { void removeImage(dynamic image) {
// If this is an existing image (WebImageFile), add it to imagesToDelete if (selectedPaymentType.value == 'denda') {
// Untuk denda
if (image is WebImageFile && image.id.isNotEmpty) { if (image is WebImageFile && image.id.isNotEmpty) {
imagesToDelete.add(image); imagesToDeleteDenda.add(image);
debugPrint('🗑️ Marked image for deletion: ${image.imageUrl} (ID: ${image.id})'); debugPrint(
'🗑️ Marked image for deletion (denda): \\${image.imageUrl} (ID: \\${image.id})',
);
}
paymentProofImagesDenda.remove(image);
} else {
// Default/tagihan awal
if (image is WebImageFile && image.id.isNotEmpty) {
imagesToDeleteTagihanAwal.add(image);
debugPrint(
'🗑️ Marked image for deletion: \\${image.imageUrl} (ID: \\${image.id})',
);
}
paymentProofImagesTagihanAwal.remove(image);
} }
// Remove from the current list
paymentProofImages.remove(image);
// Check if we have any changes (additions or deletions)
_checkForChanges(); _checkForChanges();
update(); update();
} }
@ -161,14 +168,17 @@ class PembayaranSewaController extends GetxController
panEnabled: true, panEnabled: true,
minScale: 0.5, minScale: 0.5,
maxScale: 4, maxScale: 4,
child: kIsWeb child:
kIsWeb
? Image.network( ? Image.network(
imageUrl, imageUrl,
fit: BoxFit.contain, fit: BoxFit.contain,
height: Get.height, height: Get.height,
width: Get.width, width: Get.width,
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
return const Center(child: Text('Error loading image')); return const Center(
child: Text('Error loading image'),
);
}, },
) )
: Image.file( : Image.file(
@ -196,35 +206,33 @@ class PembayaranSewaController extends GetxController
// Check if there are any changes to save (new images added or existing images removed) // Check if there are any changes to save (new images added or existing images removed)
void _checkForChanges() { void _checkForChanges() {
// We have changes if: bool hasChangesTagihanAwal = false;
// 1. We have images marked for deletion bool hasChangesDenda = false;
// 2. We have new images (files) added if (imagesToDeleteTagihanAwal.isNotEmpty) {
// 3. The current list differs from the original list hasChangesTagihanAwal = true;
bool hasChanges = false;
// Check if any images are marked for deletion
if (imagesToDelete.isNotEmpty) {
hasChanges = true;
} }
if (imagesToDeleteDenda.isNotEmpty) {
// Check if any new images have been added hasChangesDenda = true;
for (dynamic image in paymentProofImages) { }
for (dynamic image in paymentProofImagesTagihanAwal) {
if (image is File) { if (image is File) {
// This is a new image hasChangesTagihanAwal = true;
hasChanges = true;
break; break;
} }
} }
for (dynamic image in paymentProofImagesDenda) {
// Check if the number of images has changed if (image is File) {
if (paymentProofImages.length != originalImages.length) { hasChangesDenda = true;
hasChanges = true; break;
}
}
hasUnsavedChangesTagihanAwal.value = hasChangesTagihanAwal;
hasUnsavedChangesDenda.value = hasChangesDenda;
debugPrint(
'💾 Has unsaved changes (tagihan awal): $hasChangesTagihanAwal, (denda): $hasChangesDenda',
);
} }
hasUnsavedChanges.value = hasChanges;
debugPrint('💾 Has unsaved changes: $hasChanges');
}
final isUploading = false.obs; final isUploading = false.obs;
final uploadProgress = 0.0.obs; final uploadProgress = 0.0.obs;
@ -260,8 +268,16 @@ class PembayaranSewaController extends GetxController
'rental_period': rentalData['waktuSewa'] ?? '', 'rental_period': rentalData['waktuSewa'] ?? '',
'duration': rentalData['duration'] ?? '', 'duration': rentalData['duration'] ?? '',
'price_per_unit': 0, // This might not be available in rental data 'price_per_unit': 0, // This might not be available in rental data
'total_price': rentalData['totalPrice'] != null ? 'total_price':
int.tryParse(rentalData['totalPrice'].toString().replaceAll(RegExp(r'[^0-9]'), '')) ?? 0 : 0, rentalData['totalPrice'] != null
? int.tryParse(
rentalData['totalPrice'].toString().replaceAll(
RegExp(r'[^0-9]'),
'',
),
) ??
0
: 0,
'status': rentalData['status'] ?? 'MENUNGGU PEMBAYARAN', 'status': rentalData['status'] ?? 'MENUNGGU PEMBAYARAN',
'created_at': DateTime.now().toString(), 'created_at': DateTime.now().toString(),
'denda': 0, // Default value 'denda': 0, // Default value
@ -276,7 +292,7 @@ class PembayaranSewaController extends GetxController
checkSewaAsetTableStructure(); checkSewaAsetTableStructure();
loadTagihanSewaDetails().then((_) { loadTagihanSewaDetails().then((_) {
// Load existing payment proof images after tagihan_sewa details are loaded // Load existing payment proof images after tagihan_sewa details are loaded
loadExistingPaymentProofImages(); loadExistingPaymentProofImages(jenisPembayaran: 'tagihan awal');
}); });
loadSewaAsetDetails(); loadSewaAsetDetails();
loadBankAccounts(); // Load bank accounts data loadBankAccounts(); // Load bank accounts data
@ -286,7 +302,7 @@ class PembayaranSewaController extends GetxController
loadOrderDetails(); loadOrderDetails();
loadTagihanSewaDetails().then((_) { loadTagihanSewaDetails().then((_) {
// Load existing payment proof images after tagihan_sewa details are loaded // Load existing payment proof images after tagihan_sewa details are loaded
loadExistingPaymentProofImages(); loadExistingPaymentProofImages(jenisPembayaran: 'tagihan awal');
}); });
loadSewaAsetDetails(); loadSewaAsetDetails();
loadBankAccounts(); // Load bank accounts data loadBankAccounts(); // Load bank accounts data
@ -382,18 +398,19 @@ class PembayaranSewaController extends GetxController
} }
val?['quantity'] = data['kuantitas'] ?? 1; val?['quantity'] = data['kuantitas'] ?? 1;
val?['denda'] = val?['denda'] =
data['denda'] ?? data['denda'] ?? 0; // Use data from API or default to 0
0; // Use data from API or default to 0 val?['keterangan'] = data['keterangan'] ?? '';
val?['keterangan'] = if (data['status'] != null &&
data['keterangan'] ?? data['status'].toString().isNotEmpty) {
''; // Use data from API or default to empty string
// Update status if it exists in the data
if (data['status'] != null && data['status'].toString().isNotEmpty) {
val?['status'] = data['status']; val?['status'] = data['status'];
debugPrint('📊 Order status from sewa_aset: ${data['status']}'); debugPrint(
'📊 Order status from sewa_aset: \\${data['status']}',
);
}
// Tambahkan mapping updated_at
if (data['updated_at'] != null) {
val?['updated_at'] = data['updated_at'];
} }
// Format rental period // Format rental period
if (data['waktu_mulai'] != null && if (data['waktu_mulai'] != null &&
data['waktu_selesai'] != null) { data['waktu_selesai'] != null) {
@ -401,12 +418,12 @@ class PembayaranSewaController extends GetxController
final startTime = DateTime.parse(data['waktu_mulai']); final startTime = DateTime.parse(data['waktu_mulai']);
final endTime = DateTime.parse(data['waktu_selesai']); final endTime = DateTime.parse(data['waktu_selesai']);
val?['rental_period'] = val?['rental_period'] =
'${startTime.day}/${startTime.month}/${startTime.year}, ${startTime.hour}:${startTime.minute.toString().padLeft(2, '0')} - ${endTime.hour}:${endTime.minute.toString().padLeft(2, '0')}'; '\\${startTime.day}/\\${startTime.month}/\\${startTime.year}, \\${startTime.hour}:\\${startTime.minute.toString().padLeft(2, '0')} - \\${endTime.hour}:\\${endTime.minute.toString().padLeft(2, '0')}';
debugPrint( debugPrint(
'✅ Successfully formatted rental period: ${val?['rental_period']}', '✅ Successfully formatted rental period: \\${val?['rental_period']}',
); );
} catch (e) { } catch (e) {
debugPrint('❌ Error parsing date: $e'); debugPrint('❌ Error parsing date: \\${e}');
} }
} else { } else {
debugPrint( debugPrint(
@ -547,6 +564,11 @@ class PembayaranSewaController extends GetxController
// Select payment type (tagihan_awal or denda) // Select payment type (tagihan_awal or denda)
void selectPaymentType(String type) { void selectPaymentType(String type) {
selectedPaymentType.value = type; selectedPaymentType.value = type;
if (type == 'tagihan_awal') {
loadExistingPaymentProofImages(jenisPembayaran: 'tagihan awal');
} else if (type == 'denda') {
loadExistingPaymentProofImages(jenisPembayaran: 'denda');
}
update(); update();
} }
@ -558,21 +580,20 @@ class PembayaranSewaController extends GetxController
source: ImageSource.camera, source: ImageSource.camera,
imageQuality: 80, imageQuality: 80,
); );
if (image != null) { if (image != null) {
// Add to the list of images instead of replacing if (selectedPaymentType.value == 'denda') {
paymentProofImages.add(File(image.path)); paymentProofImagesDenda.add(File(image.path));
} else {
// Check for changes paymentProofImagesTagihanAwal.add(File(image.path));
}
_checkForChanges(); _checkForChanges();
update(); update();
} }
} catch (e) { } catch (e) {
debugPrint('❌ Error taking photo: $e'); debugPrint('❌ Error taking photo: $e');
Get.snackbar( Get.snackbar(
'Error', 'Error',
'Gagal mengambil foto: ${e.toString()}', 'Gagal mengambil foto: \\${e.toString()}',
snackPosition: SnackPosition.BOTTOM, snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red, backgroundColor: Colors.red,
colorText: Colors.white, colorText: Colors.white,
@ -588,17 +609,20 @@ class PembayaranSewaController extends GetxController
source: ImageSource.gallery, source: ImageSource.gallery,
imageQuality: 80, imageQuality: 80,
); );
if (image != null) { if (image != null) {
// Add to the list of images instead of replacing if (selectedPaymentType.value == 'denda') {
paymentProofImages.add(File(image.path)); paymentProofImagesDenda.add(File(image.path));
} else {
paymentProofImagesTagihanAwal.add(File(image.path));
}
_checkForChanges();
update(); update();
} }
} catch (e) { } catch (e) {
debugPrint('❌ Error selecting photo from gallery: $e'); debugPrint('❌ Error selecting photo from gallery: $e');
Get.snackbar( Get.snackbar(
'Error', 'Error',
'Gagal memilih foto dari galeri: ${e.toString()}', 'Gagal memilih foto dari galeri: \\${e.toString()}',
snackPosition: SnackPosition.BOTTOM, snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red, backgroundColor: Colors.red,
colorText: Colors.white, colorText: Colors.white,
@ -607,7 +631,19 @@ class PembayaranSewaController extends GetxController
} }
// Upload payment proof to Supabase storage and save to foto_pembayaran table // Upload payment proof to Supabase storage and save to foto_pembayaran table
Future<void> uploadPaymentProof() async { Future<void> uploadPaymentProof({required String jenisPembayaran}) async {
final paymentProofImages =
jenisPembayaran == 'tagihan awal'
? paymentProofImagesTagihanAwal
: paymentProofImagesDenda;
final imagesToDelete =
jenisPembayaran == 'tagihan awal'
? imagesToDeleteTagihanAwal
: imagesToDeleteDenda;
final hasUnsavedChanges =
jenisPembayaran == 'tagihan awal'
? hasUnsavedChangesTagihanAwal
: hasUnsavedChangesDenda;
// If there are no images and none marked for deletion, show error // If there are no images and none marked for deletion, show error
if (paymentProofImages.isEmpty && imagesToDelete.isEmpty) { if (paymentProofImages.isEmpty && imagesToDelete.isEmpty) {
Get.snackbar( Get.snackbar(
@ -644,7 +680,9 @@ class PembayaranSewaController extends GetxController
// First, delete any images marked for deletion // First, delete any images marked for deletion
if (imagesToDelete.isNotEmpty) { if (imagesToDelete.isNotEmpty) {
debugPrint('🗑️ Deleting ${imagesToDelete.length} images from database and storage'); debugPrint(
'🗑️ Deleting ${imagesToDelete.length} images from database and storage',
);
for (WebImageFile image in imagesToDelete) { for (WebImageFile image in imagesToDelete) {
// Delete the record from the foto_pembayaran table // Delete the record from the foto_pembayaran table
@ -673,14 +711,16 @@ class PembayaranSewaController extends GetxController
// The filename is the last part of the path after the last '/' // The filename is the last part of the path after the last '/'
final String fileName = path.substring(path.lastIndexOf('/') + 1); final String fileName = path.substring(path.lastIndexOf('/') + 1);
debugPrint('🗑️ Attempting to delete file from storage: $fileName'); debugPrint(
'🗑️ Attempting to delete file from storage: $fileName',
);
// Delete the file from storage // Delete the file from storage
await client.storage await client.storage.from('bukti.pembayaran').remove([fileName]);
.from('bukti.pembayaran')
.remove([fileName]);
debugPrint('🗑️ Successfully deleted file from storage: $fileName'); debugPrint(
'🗑️ Successfully deleted file from storage: $fileName',
);
} catch (e) { } catch (e) {
debugPrint('⚠️ Error deleting file from storage: $e'); debugPrint('⚠️ Error deleting file from storage: $e');
// Continue even if file deletion fails - we've at least deleted from the database // Continue even if file deletion fails - we've at least deleted from the database
@ -693,7 +733,9 @@ class PembayaranSewaController extends GetxController
} }
// Upload each new image to Supabase Storage and save to database // Upload each new image to Supabase Storage and save to database
debugPrint('🔄 Uploading new payment proof images to Supabase storage...'); debugPrint(
'🔄 Uploading new payment proof images to Supabase storage...',
);
List<String> uploadedUrls = []; List<String> uploadedUrls = [];
List<dynamic> newImagesToUpload = []; List<dynamic> newImagesToUpload = [];
@ -710,7 +752,9 @@ class PembayaranSewaController extends GetxController
} }
} }
debugPrint('🔄 Found ${existingImageUrls.length} existing images and ${newImagesToUpload.length} new images to upload'); debugPrint(
'🔄 Found ${existingImageUrls.length} existing images and ${newImagesToUpload.length} new images to upload',
);
// If there are new images to upload // If there are new images to upload
if (newImagesToUpload.isNotEmpty) { if (newImagesToUpload.isNotEmpty) {
@ -721,13 +765,16 @@ class PembayaranSewaController extends GetxController
// Upload each new image // Upload each new image
for (int i = 0; i < newImagesToUpload.length; i++) { for (int i = 0; i < newImagesToUpload.length; i++) {
final dynamic imageFile = newImagesToUpload[i]; final dynamic imageFile = newImagesToUpload[i];
final String fileName = '${DateTime.now().millisecondsSinceEpoch}_${orderId.value}_$i.jpg'; final String fileName =
'${DateTime.now().millisecondsSinceEpoch}_${orderId.value}_$i.jpg';
// Create a sub-progress tracker for this image // Create a sub-progress tracker for this image
final subProgressNotifier = StreamController<double>(); final subProgressNotifier = StreamController<double>();
subProgressNotifier.stream.listen((subProgress) { subProgressNotifier.stream.listen((subProgress) {
// Calculate overall progress // Calculate overall progress
progressNotifier.add(currentProgress + (subProgress * progressIncrement)); progressNotifier.add(
currentProgress + (subProgress * progressIncrement),
);
}); });
// Upload to Supabase Storage // Upload to Supabase Storage
@ -754,15 +801,20 @@ class PembayaranSewaController extends GetxController
// Save all new URLs to foto_pembayaran table // Save all new URLs to foto_pembayaran table
for (String imageUrl in uploadedUrls) { for (String imageUrl in uploadedUrls) {
await _saveToFotoPembayaranTable(imageUrl); await _saveToFotoPembayaranTable(imageUrl, jenisPembayaran);
} }
// Reload the existing images to get fresh data with new IDs // Reload the existing images to get fresh data with new IDs
await loadExistingPaymentProofImages(); await loadExistingPaymentProofImages(jenisPembayaran: jenisPembayaran);
// Update order status in orderDetails // Update order status in orderDetails
orderDetails.update((val) { orderDetails.update((val) {
if (jenisPembayaran == 'denda' &&
val?['status'] == 'PEMBAYARAN DENDA') {
val?['status'] = 'PERIKSA PEMBAYARAN DENDA';
} else {
val?['status'] = 'MEMERIKSA PEMBAYARAN'; val?['status'] = 'MEMERIKSA PEMBAYARAN';
}
}); });
// Also update the status in the sewa_aset table // Also update the status in the sewa_aset table
@ -771,17 +823,28 @@ class PembayaranSewaController extends GetxController
final dynamic sewaAsetId = tagihanSewa.value['sewa_aset_id']; final dynamic sewaAsetId = tagihanSewa.value['sewa_aset_id'];
if (sewaAsetId != null && sewaAsetId.toString().isNotEmpty) { if (sewaAsetId != null && sewaAsetId.toString().isNotEmpty) {
debugPrint('🔄 Updating status in sewa_aset table for ID: $sewaAsetId'); debugPrint(
'🔄 Updating status in sewa_aset table for ID: $sewaAsetId',
);
// Update the status in the sewa_aset table // Update the status in the sewa_aset table
final updateResult = await client final updateResult = await client
.from('sewa_aset') .from('sewa_aset')
.update({'status': 'PERIKSA PEMBAYARAN'}) .update({
'status':
(jenisPembayaran == 'denda' &&
orderDetails.value['status'] ==
'PERIKSA PEMBAYARAN DENDA')
? 'PERIKSA PEMBAYARAN DENDA'
: 'PERIKSA PEMBAYARAN',
})
.eq('id', sewaAsetId.toString()); .eq('id', sewaAsetId.toString());
debugPrint('✅ Status updated in sewa_aset table: $updateResult'); debugPrint('✅ Status updated in sewa_aset table: $updateResult');
} else { } else {
debugPrint('⚠️ Could not update sewa_aset status: No valid sewa_aset_id found'); debugPrint(
'⚠️ Could not update sewa_aset status: No valid sewa_aset_id found',
);
} }
} catch (e) { } catch (e) {
// Don't fail the entire operation if this update fails // Don't fail the entire operation if this update fails
@ -878,21 +941,23 @@ class PembayaranSewaController extends GetxController
case 'DITERIMA': case 'DITERIMA':
currentStep.value = 2; currentStep.value = 2;
break; break;
case 'PENGEMBALIAN': case 'AKTIF':
currentStep.value = 3; currentStep.value = 3;
break; break;
case 'PEMBAYARAN DENDA': case 'PENGEMBALIAN':
currentStep.value = 4; currentStep.value = 4;
break; break;
case 'MEMERIKSA PEMBAYARAN DENDA': case 'PEMBAYARAN DENDA':
currentStep.value = 5; currentStep.value = 5;
break; break;
case 'SELESAI': case 'PERIKSA PEMBAYARAN DENDA':
currentStep.value = 6; currentStep.value = 6;
break; break;
case 'SELESAI':
currentStep.value = 7;
break;
case 'DIBATALKAN': case 'DIBATALKAN':
// Special case for canceled orders currentStep.value = 8;
currentStep.value = 0;
break; break;
default: default:
currentStep.value = 0; currentStep.value = 0;
@ -950,7 +1015,9 @@ class PembayaranSewaController extends GetxController
debugPrint('Available fields in sewa_aset table:'); debugPrint('Available fields in sewa_aset table:');
record.forEach((key, value) { record.forEach((key, value) {
debugPrint(' $key: (${value != null ? value.runtimeType : 'null'})'); debugPrint(
' $key: (${value != null ? value.runtimeType : 'null'})',
);
}); });
// Specifically check for time fields // Specifically check for time fields
@ -987,12 +1054,16 @@ class PembayaranSewaController extends GetxController
final data = await asetProvider.getBankAccounts(); final data = await asetProvider.getBankAccounts();
if (data.isNotEmpty) { if (data.isNotEmpty) {
bankAccounts.assignAll(data); bankAccounts.assignAll(data);
debugPrint('✅ Bank accounts loaded: ${bankAccounts.length} accounts found'); debugPrint(
'✅ Bank accounts loaded: ${bankAccounts.length} accounts found',
);
// Debug the bank accounts data // Debug the bank accounts data
debugPrint('📋 BANK ACCOUNTS DETAILS:'); debugPrint('📋 BANK ACCOUNTS DETAILS:');
for (var account in bankAccounts) { for (var account in bankAccounts) {
debugPrint(' Bank: ${account['nama_bank']}, Account: ${account['nama_akun']}, Number: ${account['no_rekening']}'); debugPrint(
' Bank: ${account['nama_bank']}, Account: ${account['nama_akun']}, Number: ${account['no_rekening']}',
);
} }
} else { } else {
debugPrint('⚠️ No bank accounts found in akun_bank table'); debugPrint('⚠️ No bank accounts found in akun_bank table');
@ -1003,7 +1074,11 @@ class PembayaranSewaController extends GetxController
} }
// Helper method to upload image to Supabase storage // Helper method to upload image to Supabase storage
Future<String?> _uploadToSupabaseStorage(dynamic imageFile, String fileName, StreamController<double> progressNotifier) async { Future<String?> _uploadToSupabaseStorage(
dynamic imageFile,
String fileName,
StreamController<double> progressNotifier,
) async {
try { try {
debugPrint('🔄 Uploading image to Supabase storage: $fileName'); debugPrint('🔄 Uploading image to Supabase storage: $fileName');
@ -1031,7 +1106,9 @@ class PembayaranSewaController extends GetxController
); );
// Get public URL // Get public URL
final String publicUrl = client.storage.from('bukti.pembayaran').getPublicUrl(fileName); final String publicUrl = client.storage
.from('bukti.pembayaran')
.getPublicUrl(fileName);
debugPrint('✅ Upload successful: $publicUrl'); debugPrint('✅ Upload successful: $publicUrl');
progressNotifier.add(1.0); // Upload complete progressNotifier.add(1.0); // Upload complete
@ -1050,7 +1127,10 @@ class PembayaranSewaController extends GetxController
} }
// Helper method to save image URL to foto_pembayaran table // Helper method to save image URL to foto_pembayaran table
Future<void> _saveToFotoPembayaranTable(String imageUrl) async { Future<void> _saveToFotoPembayaranTable(
String imageUrl,
String jenisPembayaran,
) async {
try { try {
debugPrint('🔄 Saving image URL to foto_pembayaran table...'); debugPrint('🔄 Saving image URL to foto_pembayaran table...');
@ -1067,79 +1147,57 @@ class PembayaranSewaController extends GetxController
final Map<String, dynamic> data = { final Map<String, dynamic> data = {
'tagihan_sewa_id': tagihanSewaId, 'tagihan_sewa_id': tagihanSewaId,
'foto_pembayaran': imageUrl, 'foto_pembayaran': imageUrl,
'jenis_pembayaran': jenisPembayaran,
'created_at': DateTime.now().toIso8601String(), 'created_at': DateTime.now().toIso8601String(),
}; };
// Insert data into the foto_pembayaran table // Insert data into the foto_pembayaran table
final response = await client final response =
.from('foto_pembayaran') await client.from('foto_pembayaran').insert(data).select().single();
.insert(data)
.select()
.single();
debugPrint('✅ Image URL saved to foto_pembayaran table: ${response['id']}'); debugPrint(
'✅ Image URL saved to foto_pembayaran table: ${response['id']}',
);
} catch (e) { } catch (e) {
debugPrint('❌ Error in _saveToFotoPembayaranTable: $e'); debugPrint('❌ Error in _saveToFotoPembayaranTable: $e');
throw Exception('Failed to save image URL to database: $e'); throw Exception('Failed to save image URL to database: $e');
} }
} }
// Load existing payment proof images // Load existing payment proof images for a specific jenis_pembayaran
Future<void> loadExistingPaymentProofImages() async { Future<void> loadExistingPaymentProofImages({
required String jenisPembayaran,
}) async {
try { try {
debugPrint('🔄 Loading existing payment proof images for tagihan_sewa_id: ${tagihanSewa.value['id']}'); debugPrint(
'🔄 Loading existing payment proof images for tagihan_sewa_id: \\${tagihanSewa.value['id']} dan jenis_pembayaran: $jenisPembayaran',
// Check if we have a valid tagihan_sewa_id );
final dynamic tagihanSewaId = tagihanSewa.value['id']; final dynamic tagihanSewaId = tagihanSewa.value['id'];
if (tagihanSewaId == null || tagihanSewaId.toString().isEmpty) { if (tagihanSewaId == null || tagihanSewaId.toString().isEmpty) {
debugPrint('⚠️ No valid tagihan_sewa_id found, skipping image load'); debugPrint('⚠️ No valid tagihan_sewa_id found, skipping image load');
return; return;
} }
// First, make a test query to see the structure of the response
final testResponse = await client
.from('foto_pembayaran')
.select()
.limit(1);
// Log the test response structure
if (testResponse.isNotEmpty) {
debugPrint('💾 DEBUG: Test database response: ${testResponse[0]}');
testResponse[0].forEach((key, value) {
debugPrint('💾 DEBUG: Field $key = $value (${value?.runtimeType})');
});
}
// Now make the actual query for this tagihan_sewa_id
final List<dynamic> response = await client final List<dynamic> response = await client
.from('foto_pembayaran') .from('foto_pembayaran')
.select() .select()
.eq('tagihan_sewa_id', tagihanSewaId) .eq('tagihan_sewa_id', tagihanSewaId)
.eq('jenis_pembayaran', jenisPembayaran)
.order('created_at', ascending: false); .order('created_at', ascending: false);
debugPrint(
debugPrint('🔄 Found ${response.length} existing payment proof images'); '🔄 Found \\${response.length} existing payment proof images for $jenisPembayaran',
);
// Clear existing tracking lists final targetList =
paymentProofImages.clear(); jenisPembayaran == 'tagihan awal'
originalImages.clear(); ? paymentProofImagesTagihanAwal
imagesToDelete.clear(); : paymentProofImagesDenda;
hasUnsavedChanges.value = false; targetList.clear();
// Process each image in the response
for (final item in response) { for (final item in response) {
// Extract the image URL
final String imageUrl = item['foto_pembayaran']; final String imageUrl = item['foto_pembayaran'];
// Extract the ID - debug the item structure
debugPrint('💾 Image data: $item');
// Get the ID field - in Supabase, this is a UUID string
String imageId = ''; String imageId = '';
try { try {
if (item.containsKey('id')) { if (item.containsKey('id')) {
final dynamic rawId = item['id']; final dynamic rawId = item['id'];
if (rawId != null) { if (rawId != null) {
// Store ID as string since it's a UUID
imageId = rawId.toString(); imageId = rawId.toString();
} }
debugPrint('🔄 Image ID: $imageId'); debugPrint('🔄 Image ID: $imageId');
@ -1147,21 +1205,12 @@ class PembayaranSewaController extends GetxController
} catch (e) { } catch (e) {
debugPrint('❌ Error getting image ID: $e'); debugPrint('❌ Error getting image ID: $e');
} }
// Create the WebImageFile object
final webImageFile = WebImageFile(imageUrl); final webImageFile = WebImageFile(imageUrl);
webImageFile.id = imageId; webImageFile.id = imageId;
targetList.add(webImageFile);
// Add to tracking lists
paymentProofImages.add(webImageFile);
originalImages.add(webImageFile);
debugPrint('✅ Added image: $imageUrl with ID: $imageId'); debugPrint('✅ Added image: $imageUrl with ID: $imageId');
} }
// Update the UI
update(); update();
} catch (e) { } catch (e) {
debugPrint('❌ Error loading payment proof images: $e'); debugPrint('❌ Error loading payment proof images: $e');
} }
@ -1174,7 +1223,9 @@ class PembayaranSewaController extends GetxController
try { try {
// Reload all data // Reload all data
await Future.delayed(const Duration(milliseconds: 500)); // Small delay for better UX await Future.delayed(
const Duration(milliseconds: 500),
); // Small delay for better UX
loadOrderDetails(); loadOrderDetails();
loadTagihanSewaDetails(); loadTagihanSewaDetails();
loadSewaAsetDetails(); loadSewaAsetDetails();

View File

@ -88,6 +88,19 @@ class SewaAsetController extends GetxController
void onReady() { void onReady() {
super.onReady(); super.onReady();
debugPrint('🚀 SewaAsetController: onReady called'); 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 @override

View File

@ -2,11 +2,14 @@ import 'package:get/get.dart';
import '../../../data/providers/auth_provider.dart'; import '../../../data/providers/auth_provider.dart';
import '../../../routes/app_routes.dart'; import '../../../routes/app_routes.dart';
import '../../../services/navigation_service.dart'; import '../../../services/navigation_service.dart';
import '../../../data/providers/aset_provider.dart';
import 'package:intl/intl.dart';
class WargaDashboardController extends GetxController { class WargaDashboardController extends GetxController {
// Dependency injection // Dependency injection
final AuthProvider _authProvider = Get.find<AuthProvider>(); final AuthProvider _authProvider = Get.find<AuthProvider>();
final NavigationService navigationService = Get.find<NavigationService>(); final NavigationService navigationService = Get.find<NavigationService>();
final AsetProvider _asetProvider = Get.find<AsetProvider>();
// User data // User data
final userName = 'Pengguna Warga'.obs; final userName = 'Pengguna Warga'.obs;
@ -28,6 +31,11 @@ class WargaDashboardController extends GetxController {
// Active penalties // Active penalties
final activePenalties = <Map<String, dynamic>>[].obs; final activePenalties = <Map<String, dynamic>>[].obs;
// Summary counts
final diterimaCount = 0.obs;
final tagihanAktifCount = 0.obs;
final dendaAktifCount = 0.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
@ -36,6 +44,7 @@ class WargaDashboardController extends GetxController {
navigationService.setNavIndex(0); navigationService.setNavIndex(0);
// Load user data // Load user data
fetchProfileFromWargaDesa();
_loadUserData(); _loadUserData();
// Load sample data // Load sample data
@ -46,6 +55,12 @@ class WargaDashboardController extends GetxController {
// Load unpaid rentals // Load unpaid rentals
loadUnpaidRentals(); loadUnpaidRentals();
// Debug count sewa_aset by status
_debugCountSewaAset();
// Load sewa aktif
loadActiveRentals();
} }
Future<void> _loadUserData() async { Future<void> _loadUserData() async {
@ -112,7 +127,7 @@ class WargaDashboardController extends GetxController {
} }
void refreshData() { void refreshData() {
// Refresh data from repository fetchProfileFromWargaDesa();
_loadSampleData(); _loadSampleData();
loadDummyData(); loadDummyData();
} }
@ -129,12 +144,17 @@ class WargaDashboardController extends GetxController {
// Already on Home tab // Already on Home tab
break; break;
case 1: case 1:
// Navigate to Sewa page // Navigate to Sewa page, tab Aktif
navigationService.toWargaSewa(); toWargaSewaTabAktif();
break; break;
} }
} }
void toWargaSewaTabAktif() {
// Navigasi ke halaman warga sewa dan tab Aktif (index 3)
Get.toNamed(Routes.WARGA_SEWA, arguments: {'tab': 3});
}
void logout() async { void logout() async {
await _authProvider.signOut(); await _authProvider.signOut();
navigationService.toLogin(); navigationService.toLogin();
@ -177,4 +197,137 @@ class WargaDashboardController extends GetxController {
print('Error loading unpaid rentals: $e'); 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) return;
final userId = user.id;
final data =
await _authProvider.client
.from('warga_desa')
.select('nik, alamat, email, nama_lengkap, no_hp, avatar')
.eq('user_id', userId)
.maybeSingle();
if (data != null) {
userNik.value = data['nik']?.toString() ?? '';
userAddress.value = data['alamat']?.toString() ?? '';
userEmail.value = data['email']?.toString() ?? '';
userName.value = data['nama_lengkap']?.toString() ?? '';
userPhone.value = data['no_hp']?.toString() ?? '';
userAvatar.value = data['avatar']?.toString() ?? '';
}
} catch (e) {
print('Error fetching profile from warga_desa: $e');
}
}
} }

View File

@ -25,6 +25,8 @@ class WargaSewaController extends GetxController
final acceptedRentals = <Map<String, dynamic>>[].obs; final acceptedRentals = <Map<String, dynamic>>[].obs;
final completedRentals = <Map<String, dynamic>>[].obs; final completedRentals = <Map<String, dynamic>>[].obs;
final cancelledRentals = <Map<String, dynamic>>[].obs; final cancelledRentals = <Map<String, dynamic>>[].obs;
final returnedRentals = <Map<String, dynamic>>[].obs;
final activeRentals = <Map<String, dynamic>>[].obs;
// Loading states // Loading states
final isLoading = false.obs; final isLoading = false.obs;
@ -32,26 +34,26 @@ class WargaSewaController extends GetxController
final isLoadingAccepted = false.obs; final isLoadingAccepted = false.obs;
final isLoadingCompleted = false.obs; final isLoadingCompleted = false.obs;
final isLoadingCancelled = false.obs; final isLoadingCancelled = false.obs;
final isLoadingReturned = false.obs;
final isLoadingActive = false.obs;
bool _tabSetFromArgument = false;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
// Ensure tab index is set to Sewa (1) // Initialize tab controller with 7 tabs
navigationService.setNavIndex(1); tabController = TabController(length: 7, vsync: this);
// Initialize tab controller with 6 tabs
tabController = TabController(length: 6, vsync: this);
// Set initial tab and ensure tab view is updated
tabController.index = 0;
// Load real rental data for all tabs // Load real rental data for all tabs
loadRentalsData(); loadRentalsData();
loadPendingRentals(); loadPendingRentals();
loadAcceptedRentals(); loadAcceptedRentals();
loadActiveRentals();
loadCompletedRentals(); loadCompletedRentals();
loadCancelledRentals(); loadCancelledRentals();
loadReturnedRentals();
// Listen to tab changes to update state if needed // Listen to tab changes to update state if needed
tabController.addListener(() { tabController.addListener(() {
@ -77,7 +79,9 @@ class WargaSewaController extends GetxController
} }
break; break;
case 3: // Aktif case 3: // Aktif
// Add Aktif tab logic when needed if (activeRentals.isEmpty && !isLoadingActive.value) {
loadActiveRentals();
}
break; break;
case 4: // Selesai case 4: // Selesai
if (completedRentals.isEmpty && !isLoadingCompleted.value) { if (completedRentals.isEmpty && !isLoadingCompleted.value) {
@ -89,6 +93,11 @@ class WargaSewaController extends GetxController
loadCancelledRentals(); loadCancelledRentals();
} }
break; break;
case 6: // Dikembalikan
if (returnedRentals.isEmpty && !isLoadingReturned.value) {
loadReturnedRentals();
}
break;
} }
}); });
} }
@ -96,9 +105,26 @@ class WargaSewaController extends GetxController
@override @override
void onReady() { void onReady() {
super.onReady(); super.onReady();
// Ensure nav index is set to Sewa (1) when the controller is ready // Jalankan update nav index dan tab index setelah build selesai
// This helps maintain correct state during hot reload Future.delayed(Duration.zero, () {
navigationService.setNavIndex(1); 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 @override
@ -118,7 +144,7 @@ class WargaSewaController extends GetxController
// Get sewa_aset data with status "MENUNGGU PEMBAYARAN" or "PEMBAYARAN DENDA" // Get sewa_aset data with status "MENUNGGU PEMBAYARAN" or "PEMBAYARAN DENDA"
final sewaAsetList = await authProvider.getSewaAsetByStatus([ final sewaAsetList = await authProvider.getSewaAsetByStatus([
'MENUNGGU PEMBAYARAN', 'MENUNGGU PEMBAYARAN',
'PEMBAYARAN DENDA' 'PEMBAYARAN DENDA',
]); ]);
debugPrint('Fetched ${sewaAsetList.length} sewa_aset records'); debugPrint('Fetched ${sewaAsetList.length} sewa_aset records');
@ -147,7 +173,8 @@ class WargaSewaController extends GetxController
String jamSelesai = ''; String jamSelesai = '';
String rentangWaktu = ''; String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) { if (sewaAset['waktu_mulai'] != null &&
sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
@ -175,7 +202,8 @@ class WargaSewaController extends GetxController
} }
// Full time format for waktuSewa // Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' waktuSewa =
'${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
} }
@ -208,6 +236,7 @@ class WargaSewaController extends GetxController
'namaSatuanWaktu': namaSatuanWaktu, 'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'], 'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'], 'waktuSelesai': sewaAset['waktu_selesai'],
'updated_at': sewaAset['updated_at'],
}); });
} }
@ -245,12 +274,54 @@ class WargaSewaController extends GetxController
} }
// Actions // Actions
void cancelRental(String id) { void cancelRental(String id) async {
Get.snackbar( final confirmed = await Get.dialog<bool>(
'Info', AlertDialog(
'Pembatalan berhasil', title: const Text('Konfirmasi Pembatalan'),
snackPosition: SnackPosition.BOTTOM, content: const Text('Apakah Anda yakin ingin membatalkan pesanan ini?'),
actions: [
TextButton(
onPressed: () => Get.back(result: false),
child: const Text('Tidak'),
),
ElevatedButton(
onPressed: () => Get.back(result: true),
child: const Text('Ya, Batalkan'),
),
],
),
); );
if (confirmed == true) {
try {
await asetProvider.client
.from('sewa_aset')
.update({'status': 'DIBATALKAN'})
.eq('id', id);
Get.snackbar(
'Berhasil',
'Pesanan berhasil dibatalkan',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
);
// Refresh data
loadRentalsData();
loadPendingRentals();
loadAcceptedRentals();
loadActiveRentals();
loadCompletedRentals();
loadCancelledRentals();
loadReturnedRentals();
} catch (e) {
Get.snackbar(
'Gagal',
'Gagal membatalkan pesanan: $e',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
}
} }
// Navigate to payment page with the selected rental data // Navigate to payment page with the selected rental data
@ -260,10 +331,7 @@ class WargaSewaController extends GetxController
// Navigate to payment page with rental data // Navigate to payment page with rental data
Get.toNamed( Get.toNamed(
Routes.PEMBAYARAN_SEWA, Routes.PEMBAYARAN_SEWA,
arguments: { arguments: {'orderId': rental['id'], 'rentalData': rental},
'orderId': rental['id'],
'rentalData': rental,
},
); );
} }
@ -312,7 +380,8 @@ class WargaSewaController extends GetxController
String jamSelesai = ''; String jamSelesai = '';
String rentangWaktu = ''; String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) { if (sewaAset['waktu_mulai'] != null &&
sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
@ -340,7 +409,8 @@ class WargaSewaController extends GetxController
} }
// Full time format for waktuSewa // Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' waktuSewa =
'${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
} }
@ -375,7 +445,9 @@ class WargaSewaController extends GetxController
}); });
} }
debugPrint('Processed ${completedRentals.length} completed rental records'); debugPrint(
'Processed ${completedRentals.length} completed rental records',
);
} catch (e) { } catch (e) {
debugPrint('Error loading completed rentals data: $e'); debugPrint('Error loading completed rentals data: $e');
} finally { } finally {
@ -392,7 +464,9 @@ class WargaSewaController extends GetxController
cancelledRentals.clear(); cancelledRentals.clear();
// Get sewa_aset data with status "DIBATALKAN" // 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'); debugPrint('Fetched ${sewaAsetList.length} cancelled sewa_aset records');
@ -420,7 +494,8 @@ class WargaSewaController extends GetxController
String jamSelesai = ''; String jamSelesai = '';
String rentangWaktu = ''; String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) { if (sewaAset['waktu_mulai'] != null &&
sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
@ -448,7 +523,8 @@ class WargaSewaController extends GetxController
} }
// Full time format for waktuSewa // Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' waktuSewa =
'${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
} }
@ -484,7 +560,9 @@ class WargaSewaController extends GetxController
}); });
} }
debugPrint('Processed ${cancelledRentals.length} cancelled rental records'); debugPrint(
'Processed ${cancelledRentals.length} cancelled rental records',
);
} catch (e) { } catch (e) {
debugPrint('Error loading cancelled rentals data: $e'); debugPrint('Error loading cancelled rentals data: $e');
} finally { } finally {
@ -500,8 +578,11 @@ class WargaSewaController extends GetxController
// Clear existing data // Clear existing data
pendingRentals.clear(); pendingRentals.clear();
// Get sewa_aset data with status "PERIKSA PEMBAYARAN" // Get sewa_aset data with status 'PERIKSA PEMBAYARAN' dan 'PERIKSA PEMBAYARAN DENDA'
final sewaAsetList = await authProvider.getSewaAsetByStatus(['PERIKSA PEMBAYARAN']); final sewaAsetList = await authProvider.getSewaAsetByStatus([
'PERIKSA PEMBAYARAN',
'PERIKSA PEMBAYARAN DENDA',
]);
debugPrint('Fetched ${sewaAsetList.length} pending sewa_aset records'); debugPrint('Fetched ${sewaAsetList.length} pending sewa_aset records');
@ -529,7 +610,8 @@ class WargaSewaController extends GetxController
String jamSelesai = ''; String jamSelesai = '';
String rentangWaktu = ''; String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) { if (sewaAset['waktu_mulai'] != null &&
sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
@ -557,7 +639,8 @@ class WargaSewaController extends GetxController
} }
// Full time format for waktuSewa // Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' waktuSewa =
'${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
} }
@ -637,7 +720,8 @@ class WargaSewaController extends GetxController
String jamSelesai = ''; String jamSelesai = '';
String rentangWaktu = ''; String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) { if (sewaAset['waktu_mulai'] != null &&
sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
@ -665,7 +749,8 @@ class WargaSewaController extends GetxController
} }
// Full time format for waktuSewa // Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' waktuSewa =
'${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
} }
@ -707,4 +792,166 @@ class WargaSewaController extends GetxController
isLoadingAccepted.value = false; isLoadingAccepted.value = false;
} }
} }
Future<void> loadReturnedRentals() async {
try {
isLoadingReturned.value = true;
returnedRentals.clear();
final sewaAsetList = await authProvider.getSewaAsetByStatus([
'DIKEMBALIKAN',
]);
for (var sewaAset in sewaAsetList) {
String assetName = 'Aset';
String? imageUrl;
String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam';
if (sewaAset['aset_id'] != null) {
final asetData = await asetProvider.getAsetById(sewaAset['aset_id']);
if (asetData != null) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
DateTime? waktuMulai;
DateTime? waktuSelesai;
String waktuSewa = '';
String tanggalSewa = '';
String jamMulai = '';
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null &&
sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
final formatTanggal = DateFormat('dd-MM-yyyy');
final formatWaktu = DateFormat('HH:mm');
final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID');
tanggalSewa = formatTanggalLengkap.format(waktuMulai);
jamMulai = formatWaktu.format(waktuMulai);
jamSelesai = formatWaktu.format(waktuSelesai);
if (namaSatuanWaktu.toLowerCase() == 'jam') {
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
rentangWaktu = '$jamMulai - $jamSelesai';
}
waktuSewa =
'${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
returnedRentals.add({
'id': sewaAset['id'] ?? '',
'name': assetName,
'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg',
'jumlahUnit': sewaAset['kuantitas'] ?? 0,
'waktuSewa': waktuSewa,
'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}',
'status': sewaAset['status'] ?? 'DIKEMBALIKAN',
'totalPrice': totalPrice,
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
});
}
} catch (e) {
debugPrint('Error loading returned rentals data: $e');
} finally {
isLoadingReturned.value = false;
}
}
Future<void> loadActiveRentals() async {
try {
isLoadingActive.value = true;
activeRentals.clear();
final sewaAsetList = await authProvider.getSewaAsetByStatus(['AKTIF']);
for (var sewaAset in sewaAsetList) {
String assetName = 'Aset';
String? imageUrl;
String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam';
if (sewaAset['aset_id'] != null) {
final asetData = await asetProvider.getAsetById(sewaAset['aset_id']);
if (asetData != null) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
DateTime? waktuMulai;
DateTime? waktuSelesai;
String waktuSewa = '';
String tanggalSewa = '';
String jamMulai = '';
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null &&
sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
final formatTanggal = DateFormat('dd-MM-yyyy');
final formatWaktu = DateFormat('HH:mm');
final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID');
tanggalSewa = formatTanggalLengkap.format(waktuMulai);
jamMulai = formatWaktu.format(waktuMulai);
jamSelesai = formatWaktu.format(waktuSelesai);
if (namaSatuanWaktu.toLowerCase() == 'jam') {
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
rentangWaktu = '$jamMulai - $jamSelesai';
}
waktuSewa =
'${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
activeRentals.add({
'id': sewaAset['id'] ?? '',
'name': assetName,
'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg',
'jumlahUnit': sewaAset['kuantitas'] ?? 0,
'waktuSewa': waktuSewa,
'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}',
'status': sewaAset['status'] ?? 'AKTIF',
'totalPrice': totalPrice,
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
});
}
} catch (e) {
debugPrint('Error loading active rentals data: $e');
} finally {
isLoadingActive.value = false;
}
}
} }

View File

@ -4,6 +4,7 @@ import 'package:get/get.dart';
import '../controllers/pembayaran_sewa_controller.dart'; import '../controllers/pembayaran_sewa_controller.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../theme/app_colors.dart'; import '../../../theme/app_colors.dart';
import 'dart:async';
class PembayaranSewaView extends GetView<PembayaranSewaController> { class PembayaranSewaView extends GetView<PembayaranSewaController> {
const PembayaranSewaView({super.key}); const PembayaranSewaView({super.key});
@ -81,6 +82,44 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
], ],
), ),
), ),
if ((controller.orderDetails.value['status'] ?? '')
.toString()
.toUpperCase() ==
'MENUNGGU PEMBAYARAN' &&
controller.orderDetails.value['updated_at'] != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Obx(() {
final status =
(controller.orderDetails.value['status'] ?? '')
.toString()
.toUpperCase();
final updatedAtStr =
controller.orderDetails.value['updated_at'];
print('DEBUG status: ' + status);
print(
'DEBUG updated_at (raw): ' +
(updatedAtStr?.toString() ?? 'NULL'),
);
if (status == 'MENUNGGU PEMBAYARAN' && updatedAtStr != null) {
try {
final updatedAt = DateTime.parse(updatedAtStr);
print(
'DEBUG updated_at (parsed): ' +
updatedAt.toIso8601String(),
);
return CountdownTimerWidget(updatedAt: updatedAt);
} catch (e) {
print('ERROR parsing updated_at: ' + e.toString());
return Text(
'Format tanggal salah',
style: TextStyle(color: Colors.red),
);
}
}
return SizedBox.shrink();
}),
),
], ],
), ),
); );
@ -174,30 +213,27 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
_buildPaymentTypeSelection(), _buildPaymentTypeSelection(),
const SizedBox(height: 24), const SizedBox(height: 24),
Obx(() { Obx(() {
// Show payment method selection only after selecting a payment type
if (controller.selectedPaymentType.value.isNotEmpty) { if (controller.selectedPaymentType.value.isNotEmpty) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildPaymentMethodSelection(), _buildPaymentMethodSelection(),
const SizedBox(height: 24), const SizedBox(height: 24),
if (controller.paymentMethod.value == 'transfer') if (controller.paymentMethod.value == 'transfer') ...[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTransferInstructions(), _buildTransferInstructions(),
const SizedBox(height: 24), const SizedBox(height: 24),
_buildPaymentProofUpload(), if (controller.selectedPaymentType.value ==
], 'tagihan_awal')
) _buildPaymentProofUploadTagihanAwal(),
else if (controller.paymentMethod.value == 'cash') if (controller.selectedPaymentType.value == 'denda')
_buildPaymentProofUploadDenda(),
] else if (controller.paymentMethod.value == 'cash')
_buildCashInstructions() _buildCashInstructions()
else else
_buildSelectPaymentMethodPrompt(), _buildSelectPaymentMethodPrompt(),
], ],
); );
} else { } else {
// Prompt to select payment type first
return _buildSelectPaymentTypePrompt(); return _buildSelectPaymentTypePrompt();
} }
}), }),
@ -272,18 +308,36 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
'Batas waktu pembayaran: ', 'Batas waktu pembayaran: ',
style: TextStyle(fontSize: 14, color: Colors.grey), style: TextStyle(fontSize: 14, color: Colors.grey),
), ),
Obx( Obx(() {
() => Text( final status =
controller.orderDetails.value['status'] == 'DIBATALKAN' (controller.orderDetails.value['status'] ?? '')
? 'Dibatalkan' .toString()
: controller.remainingTime.value, .toUpperCase();
style: TextStyle( final updatedAtStr =
fontSize: 14, controller.orderDetails.value['updated_at'];
fontWeight: FontWeight.bold, print('DEBUG status: ' + status);
color: Colors.red[700], print(
), 'DEBUG updated_at (raw): ' +
), (updatedAtStr?.toString() ?? 'NULL'),
), );
if (status == 'MENUNGGU PEMBAYARAN' && updatedAtStr != null) {
try {
final updatedAt = DateTime.parse(updatedAtStr);
print(
'DEBUG updated_at (parsed): ' +
updatedAt.toIso8601String(),
);
return CountdownTimerWidget(updatedAt: updatedAt);
} catch (e) {
print('ERROR parsing updated_at: ' + e.toString());
return Text(
'Format tanggal salah',
style: TextStyle(color: Colors.red),
);
}
}
return SizedBox.shrink();
}),
], ],
), ),
], ],
@ -313,29 +367,41 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
'icon': Icons.check_circle, 'icon': Icons.check_circle,
'step': 2, 'step': 2,
}, },
{
'title': 'Aktif',
'description': 'Aset sewa sedang digunakan',
'icon': Icons.play_circle_fill,
'step': 3,
},
{ {
'title': 'Pengembalian', 'title': 'Pengembalian',
'description': 'Proses pengembalian aset sewa', 'description': 'Proses pengembalian aset sewa',
'icon': Icons.assignment_return, 'icon': Icons.assignment_return,
'step': 3, 'step': 4,
}, },
{ {
'title': 'Pembayaran Denda', 'title': 'Pembayaran Denda',
'description': 'Pembayaran denda jika ada kerusakan atau keterlambatan', 'description': 'Pembayaran denda jika ada kerusakan atau keterlambatan',
'icon': Icons.money, 'icon': Icons.money,
'step': 4, 'step': 5,
}, },
{ {
'title': 'Memeriksa Pembayaran Denda', 'title': 'Periksa Pembayaran Denda',
'description': 'Verifikasi pembayaran denda oleh petugas', 'description': 'Verifikasi pembayaran denda oleh petugas',
'icon': Icons.fact_check, 'icon': Icons.fact_check,
'step': 5, 'step': 6,
}, },
{ {
'title': 'Selesai', 'title': 'Selesai',
'description': 'Pesanan sewa telah selesai', 'description': 'Pesanan sewa telah selesai',
'icon': Icons.task_alt, 'icon': Icons.task_alt,
'step': 6, 'step': 7,
},
{
'title': 'Dibatalkan',
'description': 'Pesanan ini telah dibatalkan',
'icon': Icons.cancel,
'step': 8,
}, },
]; ];
@ -360,37 +426,69 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
const SizedBox(height: 20), const SizedBox(height: 20),
Obx(() { Obx(() {
final currentStep = controller.currentStep.value; final currentStep = controller.currentStep.value;
final isCancelled = currentStep == 8;
// Filter steps: tampilkan step Dibatalkan hanya jika status DIBATALKAN
final visibleSteps =
isCancelled
? steps
: steps.where((s) => s['step'] != 8).toList();
return ListView.builder( return ListView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
itemCount: steps.length, itemCount: visibleSteps.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final step = steps[index]; final step = visibleSteps[index];
final stepNumber = step['step'] as int; final stepNumber = step['step'] as int;
final isActive = currentStep >= stepNumber; final isActive =
final isCompleted = currentStep > stepNumber; currentStep >= stepNumber &&
final isLast = index == steps.length - 1; (!isCancelled || stepNumber == 8);
final isCompleted =
currentStep > stepNumber &&
(!isCancelled || stepNumber == 8);
final isLast = index == visibleSteps.length - 1;
// Determine the appropriate colors // Custom color for dibatalkan
final bool isCancelledStep = stepNumber == 8;
final Color iconColor = final Color iconColor =
isActive isCancelledStep
? Colors.red
: isCancelled
? Colors.grey[400]!
: isActive
? (isCompleted ? (isCompleted
? AppColors.success ? AppColors.success
: AppColors.primary) : AppColors.primary)
: Colors.grey[300]!; : Colors.grey[300]!;
final Color lineColor = final Color lineColor =
isCompleted ? AppColors.success : Colors.grey[300]!; isCancelledStep
? Colors.red
: isCancelled
? Colors.grey[400]!
: isCompleted
? AppColors.success
: Colors.grey[300]!;
final Color bgColor = final Color bgColor =
isActive isCancelledStep
? Colors.red.withOpacity(0.1)
: isCancelled
? Colors.grey[100]!
: isActive
? (isCompleted ? (isCompleted
? AppColors.successLight ? AppColors.successLight
: AppColors.primarySoft) : AppColors.primarySoft)
: Colors.grey[100]!; : Colors.grey[100]!;
// Icon logic: silang untuk step lain jika dibatalkan
final IconData displayIcon =
isCancelled
? (isCancelledStep ? Icons.cancel : Icons.cancel)
: (isCompleted
? Icons.check
: step['icon'] as IconData);
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Column( Column(
children: [ children: [
@ -403,9 +501,7 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
border: Border.all(color: iconColor, width: 2), border: Border.all(color: iconColor, width: 2),
), ),
child: Icon( child: Icon(
isCompleted displayIcon,
? Icons.check
: step['icon'] as IconData,
color: iconColor, color: iconColor,
size: 18, size: 18,
), ),
@ -425,7 +521,9 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 14, fontSize: 14,
color: color:
isActive isCancelledStep
? Colors.red
: isActive
? AppColors.textPrimary ? AppColors.textPrimary
: AppColors.textSecondary, : AppColors.textSecondary,
), ),
@ -434,7 +532,10 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
Text( Text(
step['description'] as String, step['description'] as String,
style: TextStyle( style: TextStyle(
color: AppColors.textSecondary, color:
isCancelledStep
? Colors.red
: AppColors.textSecondary,
fontSize: 12, fontSize: 12,
), ),
), ),
@ -442,13 +543,18 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
], ],
), ),
), ),
if (isCompleted) if (isCancelled && isCancelledStep)
Icon(Icons.cancel, color: Colors.red, size: 18)
else if (!isCancelled && isCompleted)
Icon( Icon(
Icons.check_circle, Icons.check_circle,
color: AppColors.success, color: AppColors.success,
size: 18, size: 18,
) )
else if (currentStep == stepNumber) else if (isActive &&
!isCancelledStep &&
!isCompleted &&
!isCancelled)
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, horizontal: 8,
@ -592,19 +698,20 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
'${controller.sewaAsetDetails.value['kuantitas'] ?? controller.orderDetails.value['quantity'] ?? 0} unit', '${controller.sewaAsetDetails.value['kuantitas'] ?? controller.orderDetails.value['quantity'] ?? 0} unit',
), ),
// Waktu Sewa with sub-points for Waktu Mulai and Waktu Selesai // Waktu Sewa with sub-points for Waktu Mulai and Waktu Selesai
_buildDetailItemWithSubpoints( _buildDetailItemWithSubpoints('Waktu Sewa', [
'Waktu Sewa',
[
{ {
'label': 'Waktu Mulai', 'label': 'Waktu Mulai',
'value': _formatDateTime(controller.sewaAsetDetails.value['waktu_mulai']), 'value': _formatDateTime(
controller.sewaAsetDetails.value['waktu_mulai'],
),
}, },
{ {
'label': 'Waktu Selesai', 'label': 'Waktu Selesai',
'value': _formatDateTime(controller.sewaAsetDetails.value['waktu_selesai']), 'value': _formatDateTime(
}, controller.sewaAsetDetails.value['waktu_selesai'],
],
), ),
},
]),
_buildDetailItem( _buildDetailItem(
'Durasi', 'Durasi',
controller.tagihanSewa.value['durasi'] != null controller.tagihanSewa.value['durasi'] != null
@ -665,7 +772,8 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
// Get values from tagihan_sewa table // Get values from tagihan_sewa table
final denda = controller.tagihanSewa.value['denda']; final denda = controller.tagihanSewa.value['denda'];
final keterangan = controller.tagihanSewa.value['keterangan']; final keterangan = controller.tagihanSewa.value['keterangan'];
final fotoKerusakan = controller.tagihanSewa.value['foto_kerusakan']; final fotoKerusakan =
controller.tagihanSewa.value['foto_kerusakan'];
debugPrint('Tagihan Denda: $denda'); debugPrint('Tagihan Denda: $denda');
debugPrint('Tagihan Keterangan: $keterangan'); debugPrint('Tagihan Keterangan: $keterangan');
@ -748,18 +856,35 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
onTap: () { onTap: () {
// Show fullscreen image when tapped // Show fullscreen image when tapped
// Use the BuildContext from the current widget tree // Use the BuildContext from the current widget tree
_showFullScreenImage(Get.context!, fotoKerusakan); _showFullScreenImage(
Get.context!,
fotoKerusakan,
);
}, },
child: Hero( child: Hero(
tag: 'damage-photo-${fotoKerusakan ?? 'default'}', tag:
child: fotoKerusakan != null && fotoKerusakan.toString().isNotEmpty && fotoKerusakan.toString().startsWith('http') 'damage-photo-${fotoKerusakan ?? 'default'}',
child:
fotoKerusakan != null &&
fotoKerusakan
.toString()
.isNotEmpty &&
fotoKerusakan.toString().startsWith(
'http',
)
? Image.network( ? Image.network(
fotoKerusakan.toString(), fotoKerusakan.toString(),
width: double.infinity, width: double.infinity,
height: 200, height: 200,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) { errorBuilder: (
debugPrint('Error loading image: $error'); context,
error,
stackTrace,
) {
debugPrint(
'Error loading image: $error',
);
return Image.asset( return Image.asset(
'assets/images/gambar_pendukung.jpg', 'assets/images/gambar_pendukung.jpg',
width: double.infinity, width: double.infinity,
@ -880,7 +1005,8 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
// Show fullscreen image dialog // Show fullscreen image dialog
void _showFullScreenImage(BuildContext context, dynamic imageUrl) { void _showFullScreenImage(BuildContext context, dynamic imageUrl) {
final String imageSource = (imageUrl != null && final String imageSource =
(imageUrl != null &&
imageUrl.toString().isNotEmpty && imageUrl.toString().isNotEmpty &&
imageUrl.toString().startsWith('http')) imageUrl.toString().startsWith('http'))
? imageUrl.toString() ? imageUrl.toString()
@ -906,12 +1032,15 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
height: MediaQuery.of(context).size.height, height: MediaQuery.of(context).size.height,
color: Colors.black.withOpacity(0.8), color: Colors.black.withOpacity(0.8),
child: Center( child: Center(
child: imageSource.isNotEmpty child:
imageSource.isNotEmpty
? Image.network( ? Image.network(
imageSource, imageSource,
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
debugPrint('Error loading fullscreen image: $error'); debugPrint(
'Error loading fullscreen image: $error',
);
return Image.asset( return Image.asset(
'assets/images/gambar_pendukung.jpg', 'assets/images/gambar_pendukung.jpg',
fit: BoxFit.contain, fit: BoxFit.contain,
@ -1022,7 +1151,7 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
0; 0;
// Get denda value // Get denda value
final denda = controller.sewaAsetDetails.value['denda'] ?? 0; final denda = controller.tagihanSewa.value['denda'] ?? 0;
return Column( return Column(
children: [ children: [
@ -1042,7 +1171,7 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
amount: 'Rp ${NumberFormat('#,###').format(denda)}', amount: 'Rp ${NumberFormat('#,###').format(denda)}',
type: 'denda', type: 'denda',
description: 'Pembayaran untuk denda yang diberikan', description: 'Pembayaran untuk denda yang diberikan',
isDisabled: denda == 0, isDisabled: denda == null || denda == 0,
isSelected: controller.selectedPaymentType.value == 'denda', isSelected: controller.selectedPaymentType.value == 'denda',
), ),
], ],
@ -1316,7 +1445,7 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Obx(() { Obx(() {
if (controller.bankAccounts.isEmpty) { if (controller.isLoading.value) {
return const Center( return const Center(
child: Padding( child: Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
@ -1324,9 +1453,21 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
), ),
); );
} }
if (controller.bankAccounts.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'Tidak ada rekening bank yang tersedia.\nSilakan hubungi admin.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
),
);
}
return Column( return Column(
children: controller.bankAccounts.map((account) { children:
controller.bankAccounts.map((account) {
return Column( return Column(
children: [ children: [
_buildBankAccount( _buildBankAccount(
@ -1365,13 +1506,6 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
title: 'Tunggu konfirmasi', title: 'Tunggu konfirmasi',
description: 'Pembayaran Anda akan dikonfirmasi oleh petugas', description: 'Pembayaran Anda akan dikonfirmasi oleh petugas',
), ),
_buildTransferStep(
icon: Icons.receipt_long,
title: 'Dapatkan struk pembayaran',
description:
'Setelah dikonfirmasi, akan dibuatkan struk pembayaran',
isLast: true,
),
], ],
), ),
), ),
@ -1508,7 +1642,9 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
final scaffoldMessenger = ScaffoldMessenger.of(Get.context!); final scaffoldMessenger = ScaffoldMessenger.of(Get.context!);
scaffoldMessenger.showSnackBar( scaffoldMessenger.showSnackBar(
SnackBar( SnackBar(
content: Text('Nomor rekening $accountNumber disalin ke clipboard'), content: Text(
'Nomor rekening $accountNumber disalin ke clipboard',
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
backgroundColor: Colors.green[700], backgroundColor: Colors.green[700],
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
@ -1541,7 +1677,8 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
GestureDetector( GestureDetector(
onTap: () { onTap: () {
// Get the total price // Get the total price
final totalPrice = controller.orderDetails.value['total_price'] ?? 0; final totalPrice =
controller.orderDetails.value['total_price'] ?? 0;
// Format the total price as a number without 'Rp' prefix // Format the total price as a number without 'Rp' prefix
final formattedPrice = totalPrice.toString(); final formattedPrice = totalPrice.toString();
@ -1552,7 +1689,9 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
final scaffoldMessenger = ScaffoldMessenger.of(Get.context!); final scaffoldMessenger = ScaffoldMessenger.of(Get.context!);
scaffoldMessenger.showSnackBar( scaffoldMessenger.showSnackBar(
SnackBar( SnackBar(
content: Text('Nominal Rp $formattedPrice disalin ke clipboard'), content: Text(
'Nominal Rp $formattedPrice disalin ke clipboard',
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
backgroundColor: Colors.green[700], backgroundColor: Colors.green[700],
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
@ -1570,11 +1709,7 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Icon( Icon(Icons.copy, size: 14, color: Colors.deepPurple[300]),
Icons.copy,
size: 14,
color: Colors.deepPurple[300],
),
], ],
), ),
), ),
@ -1635,8 +1770,8 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
); );
} }
// Payment Proof Upload // Payment Proof Upload for Tagihan Awal
Widget _buildPaymentProofUpload() { Widget _buildPaymentProofUploadTagihanAwal() {
return Card( return Card(
elevation: 2, elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
@ -1661,48 +1796,55 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
spacing: 12, spacing: 12,
runSpacing: 12, runSpacing: 12,
children: [ children: [
// Display all existing images
...List.generate( ...List.generate(
controller.paymentProofImages.length, controller.paymentProofImagesTagihanAwal.length,
(index) => _buildImageItem(index), (index) => _buildImageItemTagihanAwal(index),
), ),
// Add photo button _buildAddPhotoButtonTagihanAwal(),
_buildAddPhotoButton(),
], ],
); );
}), }),
const SizedBox(height: 16), const SizedBox(height: 16),
// Upload button
Obx(() { Obx(() {
// Disable button if there are no changes or if upload is in progress final bool isDisabled =
final bool isDisabled = controller.isUploading.value || !controller.hasUnsavedChanges.value; controller.isUploading.value ||
!controller.hasUnsavedChangesTagihanAwal.value;
return ElevatedButton.icon( return ElevatedButton.icon(
onPressed: isDisabled ? null : controller.uploadPaymentProof, onPressed:
icon: controller.isUploading.value isDisabled
? null
: () => controller.uploadPaymentProof(
jenisPembayaran: 'tagihan awal',
),
icon:
controller.isUploading.value
? const SizedBox( ? const SizedBox(
width: 20, width: 20,
height: 20, height: 20,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white), valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
), ),
) )
: const Icon(Icons.save), : const Icon(Icons.save),
label: Text(controller.isUploading.value label: Text(
controller.isUploading.value
? 'Menyimpan...' ? 'Menyimpan...'
: (controller.hasUnsavedChanges.value ? 'Simpan' : 'Tidak Ada Perubahan')), : (controller.hasUnsavedChangesTagihanAwal.value
? 'Simpan'
: 'Tidak Ada Perubahan'),
),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue, backgroundColor: Colors.blue,
foregroundColor: Colors.white, foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48), minimumSize: const Size(double.infinity, 48),
// Gray out button when disabled
disabledBackgroundColor: Colors.grey[300], disabledBackgroundColor: Colors.grey[300],
disabledForegroundColor: Colors.grey[600], disabledForegroundColor: Colors.grey[600],
), ),
); );
}), }),
// Upload progress indicator
Obx(() { Obx(() {
if (controller.isUploading.value) { if (controller.isUploading.value) {
return Column( return Column(
@ -1711,7 +1853,9 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
LinearProgressIndicator( LinearProgressIndicator(
value: controller.uploadProgress.value, value: controller.uploadProgress.value,
backgroundColor: Colors.grey[200], backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue[700]!), valueColor: AlwaysStoppedAnimation<Color>(
Colors.blue[700]!,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
@ -1730,12 +1874,14 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
); );
} }
// Build individual image item with remove button Widget _buildImageItemTagihanAwal(int index) {
Widget _buildImageItem(int index) { final image = controller.paymentProofImagesTagihanAwal[index];
final image = controller.paymentProofImages[index]; final status =
controller.orderDetails.value['status']?.toString().toUpperCase() ?? '';
final canDelete =
status == 'MENUNGGU PEMBAYARAN' || status == 'PERIKSA PEMBAYARAN';
return Stack( return Stack(
children: [ children: [
// Make the container tappable to show full-screen image
GestureDetector( GestureDetector(
onTap: () => controller.showFullScreenImage(image), onTap: () => controller.showFullScreenImage(image),
child: Container( child: Container(
@ -1751,7 +1897,7 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
), ),
), ),
), ),
// Close/remove button remains the same if (canDelete)
Positioned( Positioned(
top: 4, top: 4,
right: 4, right: 4,
@ -1771,8 +1917,7 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
); );
} }
// Build add photo button Widget _buildAddPhotoButtonTagihanAwal() {
Widget _buildAddPhotoButton() {
return InkWell( return InkWell(
onTap: () => _showImageSourceOptions(Get.context!), onTap: () => _showImageSourceOptions(Get.context!),
child: Container( child: Container(
@ -1786,11 +1931,183 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(Icons.add_a_photo, size: 40, color: Colors.blue[700]),
Icons.add_a_photo, const SizedBox(height: 8),
size: 40, Text(
'Tambah Foto',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.blue[700], color: Colors.blue[700],
), ),
),
],
),
),
);
}
// Payment Proof Upload for Denda
Widget _buildPaymentProofUploadDenda() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.photo_camera, size: 24),
SizedBox(width: 8),
Text(
'Unggah Bukti Pembayaran',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 16),
Obx(() {
return Wrap(
spacing: 12,
runSpacing: 12,
children: [
...List.generate(
controller.paymentProofImagesDenda.length,
(index) => _buildImageItemDenda(index),
),
_buildAddPhotoButtonDenda(),
],
);
}),
const SizedBox(height: 16),
Obx(() {
final bool isDisabled =
controller.isUploading.value ||
!controller.hasUnsavedChangesDenda.value;
return ElevatedButton.icon(
onPressed:
isDisabled
? null
: () => controller.uploadPaymentProof(
jenisPembayaran: 'denda',
),
icon:
controller.isUploading.value
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: const Icon(Icons.save),
label: Text(
controller.isUploading.value
? 'Menyimpan...'
: (controller.hasUnsavedChangesDenda.value
? 'Simpan'
: 'Tidak Ada Perubahan'),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48),
disabledBackgroundColor: Colors.grey[300],
disabledForegroundColor: Colors.grey[600],
),
);
}),
Obx(() {
if (controller.isUploading.value) {
return Column(
children: [
const SizedBox(height: 16),
LinearProgressIndicator(
value: controller.uploadProgress.value,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(
Colors.blue[700]!,
),
),
const SizedBox(height: 8),
Text(
'Mengunggah bukti pembayaran... ${(controller.uploadProgress.value * 100).toInt()}%',
style: const TextStyle(fontSize: 12),
),
],
);
} else {
return const SizedBox.shrink();
}
}),
],
),
),
);
}
Widget _buildImageItemDenda(int index) {
final image = controller.paymentProofImagesDenda[index];
final status =
controller.orderDetails.value['status']?.toString().toUpperCase() ?? '';
final canDelete = status != 'SELESAI';
return Stack(
children: [
GestureDetector(
onTap: () => controller.showFullScreenImage(image),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[300]!),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: controller.getImageWidget(image),
),
),
),
if (canDelete)
Positioned(
top: 4,
right: 4,
child: InkWell(
onTap: () => controller.removeImage(image),
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: const Icon(Icons.close, size: 18, color: Colors.red),
),
),
),
],
);
}
Widget _buildAddPhotoButtonDenda() {
return InkWell(
onTap: () => _showImageSourceOptions(Get.context!),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue[200]!),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_a_photo, size: 40, color: Colors.blue[700]),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Tambah Foto', 'Tambah Foto',
@ -1871,13 +2188,6 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
description: description:
'Total: Rp ${controller.orderDetails.value['total_price'] ?? 0}', 'Total: Rp ${controller.orderDetails.value['total_price'] ?? 0}',
), ),
_buildCashStep(
number: 4,
title: 'Dapatkan struk pembayaran',
description:
'Setelah dikonfirmasi, akan dibuatkan struk pembayaran',
isLast: true,
),
], ],
), ),
), ),
@ -2032,7 +2342,8 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
final denda = controller.tagihanSewa.value['denda'] ?? 0; final denda = controller.tagihanSewa.value['denda'] ?? 0;
// Get total dibayarkan from tagihan_dibayar // Get total dibayarkan from tagihan_dibayar
final dibayarkan = controller.tagihanSewa.value['tagihan_dibayar'] ?? 0; final dibayarkan =
controller.tagihanSewa.value['tagihan_dibayar'] ?? 0;
debugPrint('Tagihan Awal: $tagihanAwal'); debugPrint('Tagihan Awal: $tagihanAwal');
debugPrint('Denda: $denda'); debugPrint('Denda: $denda');
@ -2080,7 +2391,10 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
} }
// Helper method to build detail item with subpoints // Helper method to build detail item with subpoints
Widget _buildDetailItemWithSubpoints(String label, List<Map<String, String>> subpoints) { Widget _buildDetailItemWithSubpoints(
String label,
List<Map<String, String>> subpoints,
) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: Column( child: Column(
@ -2097,7 +2411,8 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Subpoints with indentation // Subpoints with indentation
...subpoints.map((subpoint) => Padding( ...subpoints.map(
(subpoint) => Padding(
padding: const EdgeInsets.only(left: 16, bottom: 6), padding: const EdgeInsets.only(left: 16, bottom: 6),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
@ -2107,25 +2422,21 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
flex: 2, flex: 2,
child: Text( child: Text(
subpoint['label'] ?? '', subpoint['label'] ?? '',
style: TextStyle( style: TextStyle(fontSize: 13, color: Colors.grey[600]),
fontSize: 13,
color: Colors.grey[600],
),
), ),
), ),
Expanded( Expanded(
flex: 3, flex: 3,
child: Text( child: Text(
subpoint['value'] ?? '-', subpoint['value'] ?? '-',
style: const TextStyle( style: const TextStyle(fontSize: 13),
fontSize: 13,
),
textAlign: TextAlign.right, textAlign: TextAlign.right,
), ),
), ),
], ],
), ),
)), ),
),
], ],
), ),
); );
@ -2152,3 +2463,85 @@ class PembayaranSewaView extends GetView<PembayaranSewaController> {
} }
} }
} }
class CountdownTimerWidget extends StatefulWidget {
final DateTime updatedAt;
final VoidCallback? onTimeout;
const CountdownTimerWidget({
required this.updatedAt,
this.onTimeout,
Key? key,
}) : super(key: key);
@override
State<CountdownTimerWidget> createState() => _CountdownTimerWidgetState();
}
class _CountdownTimerWidgetState extends State<CountdownTimerWidget> {
late Duration remaining;
Timer? timer;
@override
void initState() {
super.initState();
print(
'DEBUG [CountdownTimerWidget] updatedAt: ' +
widget.updatedAt.toIso8601String(),
);
updateRemaining();
timer = Timer.periodic(
const Duration(seconds: 1),
(_) => updateRemaining(),
);
}
void updateRemaining() {
final now = DateTime.now();
final end = widget.updatedAt.add(const Duration(hours: 1));
setState(() {
remaining = end.difference(now);
if (remaining.isNegative) {
remaining = Duration.zero;
timer?.cancel();
widget.onTimeout?.call();
}
});
}
@override
void dispose() {
timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (remaining.inSeconds <= 0) {
return Text('Waktu habis', style: TextStyle(color: Colors.red));
}
final h = remaining.inHours;
final m = remaining.inMinutes % 60;
final s = remaining.inSeconds % 60;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withOpacity(0.3), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.timer_outlined, size: 14, color: Colors.red),
const SizedBox(width: 4),
Text(
'Bayar dalam ${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
],
),
);
}
}

View File

@ -117,6 +117,7 @@ class SewaAsetView extends GetView<SewaAsetController> {
), ),
], ],
), ),
dividerColor: Colors.transparent,
labelColor: Colors.white, labelColor: Colors.white,
unselectedLabelColor: const Color( unselectedLabelColor: const Color(
0xFF718093, 0xFF718093,

View File

@ -154,10 +154,10 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
'route': () => controller.navigateToRentals(), 'route': () => controller.navigateToRentals(),
}, },
{ {
'title': 'Bayar', 'title': 'Paket',
'icon': Icons.payment_outlined, 'icon': Icons.widgets_outlined,
'color': const Color(0xFF2196F3), 'color': const Color(0xFF2196F3),
'route': () => Get.toNamed(Routes.PEMBAYARAN_SEWA), 'route': () => controller.toSewaAsetTabPaket(),
}, },
]; ];
@ -218,32 +218,44 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
child: Column( child: Column(
children: [ children: [
// Sewa Diterima // Sewa Diterima
_buildActivityCard( Obx(
() => _buildActivityCard(
title: 'Sewa Diterima', title: 'Sewa Diterima',
value: controller.activeRentals.length.toString(), value: controller.diterimaCount.value.toString(),
icon: Icons.check_circle_outline, icon: Icons.check_circle_outline,
color: AppColors.success, color: AppColors.success,
onTap: () => controller.navigateToRentals(), onTap:
() =>
Get.toNamed(Routes.WARGA_SEWA, arguments: {'tab': 2}),
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Tagihan Aktif // Tagihan Aktif
_buildActivityCard( Obx(
() => _buildActivityCard(
title: 'Tagihan Aktif', title: 'Tagihan Aktif',
value: controller.activeBills.length.toString(), value: controller.tagihanAktifCount.value.toString(),
icon: Icons.receipt_long_outlined, icon: Icons.receipt_long_outlined,
color: AppColors.warning, color: AppColors.warning,
onTap: () => Get.toNamed(Routes.PEMBAYARAN_SEWA), onTap:
() =>
Get.toNamed(Routes.WARGA_SEWA, arguments: {'tab': 0}),
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Denda Aktif // Denda Aktif
_buildActivityCard( Obx(
() => _buildActivityCard(
title: 'Denda Aktif', title: 'Denda Aktif',
value: controller.activePenalties.length.toString(), value: controller.dendaAktifCount.value.toString(),
icon: Icons.warning_amber_outlined, icon: Icons.warning_amber_outlined,
color: AppColors.error, color: AppColors.error,
onTap: () => Get.toNamed(Routes.PEMBAYARAN_SEWA), onTap:
() =>
Get.toNamed(Routes.WARGA_SEWA, arguments: {'tab': 0}),
),
), ),
], ],
), ),
@ -357,7 +369,7 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text( Text(
'Sewa Diterima', 'Sewa Aktif',
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -498,31 +510,34 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
), ),
child: Row( child: Row(
children: [ children: [
// Asset icon // Asset icon/gambar
Container( Container(
padding: const EdgeInsets.all(12), width: 48,
height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.primary.withOpacity(0.7),
AppColors.primary,
],
),
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
boxShadow: [ color: AppColors.primary.withOpacity(0.08),
BoxShadow(
color: AppColors.primary.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 3),
), ),
], child: ClipRRect(
), borderRadius: BorderRadius.circular(14),
child: const Icon( child:
rental['imageUrl'] != null &&
rental['imageUrl'].toString().isNotEmpty
? Image.network(
rental['imageUrl'],
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) => Icon(
Icons.local_shipping, Icons.local_shipping,
color: Colors.white, color: AppColors.primary,
size: 24, size: 28,
),
)
: Icon(
Icons.local_shipping,
color: AppColors.primary,
size: 28,
),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@ -533,7 +548,7 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
rental['name'], rental['name'] ?? '-',
style: TextStyle( style: TextStyle(
fontSize: 17, fontSize: 17,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -542,7 +557,7 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
rental['time'], rental['waktuSewa'] ?? '',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
color: AppColors.textSecondary, color: AppColors.textSecondary,
@ -567,7 +582,7 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
), ),
), ),
child: Text( child: Text(
rental['price'], rental['totalPrice'] ?? 'Rp 0',
style: TextStyle( style: TextStyle(
color: AppColors.primary, color: AppColors.primary,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -591,14 +606,14 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
child: _buildInfoItem( child: _buildInfoItem(
icon: Icons.timer_outlined, icon: Icons.timer_outlined,
title: 'Durasi', title: 'Durasi',
value: rental['duration'], value: rental['duration'] ?? '-',
), ),
), ),
Expanded( Expanded(
child: _buildInfoItem( child: _buildInfoItem(
icon: Icons.calendar_today_outlined, icon: Icons.calendar_today_outlined,
title: 'Status', title: 'Status',
value: 'Diterima', value: rental['status'] ?? '-',
valueColor: AppColors.success, valueColor: AppColors.success,
), ),
), ),
@ -608,7 +623,7 @@ class WargaDashboardView extends GetView<WargaDashboardController> {
const SizedBox(height: 16), const SizedBox(height: 16),
// Action buttons // Action buttons
if (rental['can_extend']) if ((rental['can_extend'] ?? false) == true)
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => controller.extendRental(rental['id']), onPressed: () => controller.extendRental(rental['id']),
icon: const Icon(Icons.update, size: 18), icon: const Icon(Icons.update, size: 18),

View File

@ -3,12 +3,16 @@ import 'package:get/get.dart';
import '../controllers/warga_dashboard_controller.dart'; import '../controllers/warga_dashboard_controller.dart';
import '../views/warga_layout.dart'; import '../views/warga_layout.dart';
import '../../../theme/app_colors.dart'; import '../../../theme/app_colors.dart';
import '../../../widgets/app_drawer.dart';
import '../../../services/navigation_service.dart';
class WargaProfileView extends GetView<WargaDashboardController> { class WargaProfileView extends GetView<WargaDashboardController> {
const WargaProfileView({super.key}); const WargaProfileView({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final navigationService = Get.find<NavigationService>();
navigationService.setNavIndex(2);
return WargaLayout( return WargaLayout(
appBar: AppBar( appBar: AppBar(
title: const Text('Profil Saya'), title: const Text('Profil Saya'),
@ -29,6 +33,14 @@ class WargaProfileView extends GetView<WargaDashboardController> {
), ),
], ],
), ),
drawer: AppDrawer(
onNavItemTapped: (index) {
// Handle navigation if needed
},
onLogout: () {
controller.logout();
},
),
backgroundColor: Colors.grey.shade100, backgroundColor: Colors.grey.shade100,
body: RefreshIndicator( body: RefreshIndicator(
color: AppColors.primary, color: AppColors.primary,

View File

@ -6,6 +6,7 @@ import '../views/warga_layout.dart';
import '../../../services/navigation_service.dart'; import '../../../services/navigation_service.dart';
import '../../../widgets/app_drawer.dart'; import '../../../widgets/app_drawer.dart';
import '../../../theme/app_colors.dart'; import '../../../theme/app_colors.dart';
import 'dart:async';
class WargaSewaView extends GetView<WargaSewaController> { class WargaSewaView extends GetView<WargaSewaController> {
const WargaSewaView({super.key}); const WargaSewaView({super.key});
@ -50,6 +51,7 @@ class WargaSewaView extends GetView<WargaSewaController> {
_buildPendingTab(), _buildPendingTab(),
_buildDiterimaTab(), _buildDiterimaTab(),
_buildAktifTab(), _buildAktifTab(),
_buildDikembalikanTab(),
_buildSelesaiTab(), _buildSelesaiTab(),
_buildDibatalkanTab(), _buildDibatalkanTab(),
], ],
@ -119,6 +121,7 @@ class WargaSewaView extends GetView<WargaSewaController> {
_buildTab(text: 'Pending', icon: Icons.pending_outlined), _buildTab(text: 'Pending', icon: Icons.pending_outlined),
_buildTab(text: 'Diterima', icon: Icons.check_circle_outline), _buildTab(text: 'Diterima', icon: Icons.check_circle_outline),
_buildTab(text: 'Aktif', icon: Icons.play_circle_outline), _buildTab(text: 'Aktif', icon: Icons.play_circle_outline),
_buildTab(text: 'Dikembalikan', icon: Icons.assignment_return),
_buildTab(text: 'Selesai', icon: Icons.task_alt_outlined), _buildTab(text: 'Selesai', icon: Icons.task_alt_outlined),
_buildTab(text: 'Dibatalkan', icon: Icons.cancel_outlined), _buildTab(text: 'Dibatalkan', icon: Icons.cancel_outlined),
], ],
@ -147,9 +150,7 @@ class WargaSewaView extends GetView<WargaSewaController> {
return Obx(() { return Obx(() {
// Show loading indicator while fetching data // Show loading indicator while fetching data
if (controller.isLoadingPending.value) { if (controller.isLoadingPending.value) {
return const Center( return const Center(child: CircularProgressIndicator());
child: CircularProgressIndicator(),
);
} }
// Check if there is any data to display // Check if there is any data to display
@ -158,11 +159,14 @@ class WargaSewaView extends GetView<WargaSewaController> {
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
children: controller.pendingRentals children:
.map((rental) => Padding( controller.pendingRentals
.map(
(rental) => Padding(
padding: const EdgeInsets.only(bottom: 16.0), padding: const EdgeInsets.only(bottom: 16.0),
child: _buildUnpaidRentalCard(rental), child: _buildUnpaidRentalCard(rental),
)) ),
)
.toList(), .toList(),
), ),
); );
@ -182,46 +186,189 @@ class WargaSewaView extends GetView<WargaSewaController> {
Widget _buildAktifTab() { Widget _buildAktifTab() {
return Obx(() { return Obx(() {
// Show loading indicator while fetching data if (controller.isLoadingActive.value) {
if (controller.isLoading.value) { return const Center(child: CircularProgressIndicator());
return const Center( }
child: CircularProgressIndicator(), if (controller.activeRentals.isEmpty) {
return _buildTabContent(
icon: Icons.play_circle_outline,
title: 'Tidak ada sewa aktif',
subtitle: 'Sewa yang sedang berlangsung akan muncul di sini',
buttonText: 'Sewa Sekarang',
onButtonPressed: () => controller.navigateToRentals(),
color: Colors.blue,
); );
} }
return SingleChildScrollView(
// Placeholder content for the Aktif tab physics: const BouncingScrollPhysics(),
return const Center( padding: const EdgeInsets.all(20),
child: Column(
children:
controller.activeRentals
.map(
(rental) => Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: _buildAktifRentalCard(rental),
),
)
.toList(),
),
);
});
}
Widget _buildAktifRentalCard(Map<String, dynamic> rental) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( // Header section with status
Icons.play_circle_filled, Container(
size: 80, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.info.withOpacity(0.1),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(Icons.play_circle_fill, size: 18, color: AppColors.info),
const SizedBox(width: 8),
Text(
rental['status'] ?? 'AKTIF',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: AppColors.info,
),
),
],
),
),
// Asset details
Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
rental['imageUrl'] ?? '',
width: 80,
height: 80,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) => Container(
width: 80,
height: 80,
color: Colors.grey[200],
child: const Icon(
Icons.broken_image,
color: Colors.grey, color: Colors.grey,
), ),
SizedBox(height: 16),
Text(
'Tab Aktif',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
), ),
SizedBox(height: 8), ),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text( Text(
'Konten tab Aktif akan ditampilkan di sini', rental['name'] ?? 'Aset',
style: TextStyle(color: Colors.grey), style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
_buildInfoRow(
icon: Icons.inventory_2_outlined,
text: '${rental['jumlahUnit'] ?? 0} Unit',
),
const SizedBox(height: 6),
_buildInfoRow(
icon: Icons.calendar_today_outlined,
text: rental['tanggalSewa'] ?? '',
),
const SizedBox(height: 6),
_buildInfoRow(
icon: Icons.schedule,
text: rental['rentangWaktu'] ?? '',
),
],
),
),
],
),
),
Divider(height: 1, thickness: 1, color: Colors.grey.shade100),
// Price section
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Total',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
Text(
rental['totalPrice'] ?? 'Rp 0',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildActionButton(
icon: Icons.info_outline,
label: 'Lihat Detail',
onPressed: () => controller.viewRentalDetail(rental),
iconColor: AppColors.info,
),
),
],
),
],
),
), ),
], ],
), ),
); );
});
} }
Widget _buildBelumBayarTab() { Widget _buildBelumBayarTab() {
return Obx(() { return Obx(() {
// Show loading indicator while fetching data // Show loading indicator while fetching data
if (controller.isLoading.value) { if (controller.isLoading.value) {
return const Center( return const Center(child: CircularProgressIndicator());
child: CircularProgressIndicator(),
);
} }
// Check if there is any data to display // Check if there is any data to display
@ -232,12 +379,16 @@ class WargaSewaView extends GetView<WargaSewaController> {
child: Column( child: Column(
children: [ children: [
// Build a card for each rental item // Build a card for each rental item
...controller.rentals.map((rental) => Column( ...controller.rentals
.map(
(rental) => Column(
children: [ children: [
_buildUnpaidRentalCard(rental), _buildUnpaidRentalCard(rental),
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
)).toList(), ),
)
.toList(),
_buildTipsSection(), _buildTipsSection(),
], ],
), ),
@ -259,7 +410,8 @@ class WargaSewaView extends GetView<WargaSewaController> {
Widget _buildUnpaidRentalCard(Map<String, dynamic> rental) { Widget _buildUnpaidRentalCard(Map<String, dynamic> rental) {
// Determine status color based on status // Determine status color based on status
final bool isPembayaranDenda = rental['status'] == 'PEMBAYARAN DENDA'; final bool isPembayaranDenda = rental['status'] == 'PEMBAYARAN DENDA';
final Color statusColor = isPembayaranDenda ? AppColors.error : AppColors.warning; final Color statusColor =
isPembayaranDenda ? AppColors.error : AppColors.warning;
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@ -289,7 +441,9 @@ class WargaSewaView extends GetView<WargaSewaController> {
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
Icon( Icon(
isPembayaranDenda ? Icons.warning_amber_rounded : Icons.access_time_rounded, isPembayaranDenda
? Icons.warning_amber_rounded
: Icons.access_time_rounded,
size: 18, size: 18,
color: statusColor, color: statusColor,
), ),
@ -315,7 +469,9 @@ class WargaSewaView extends GetView<WargaSewaController> {
// Asset image with rounded corners // Asset image with rounded corners
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') child:
rental['imageUrl'] != null &&
rental['imageUrl'].toString().startsWith('http')
? Image.network( ? Image.network(
rental['imageUrl'], rental['imageUrl'],
width: 90, width: 90,
@ -338,7 +494,8 @@ class WargaSewaView extends GetView<WargaSewaController> {
}, },
) )
: Image.asset( : Image.asset(
rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', rental['imageUrl'] ??
'assets/images/gambar_pendukung.jpg',
width: 90, width: 90,
height: 90, height: 90,
fit: BoxFit.cover, fit: BoxFit.cover,
@ -389,39 +546,11 @@ class WargaSewaView extends GetView<WargaSewaController> {
text: rental['rentangWaktu'] ?? '', text: rental['rentangWaktu'] ?? '',
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Countdown timer if ((rental['status'] ?? '').toString().toUpperCase() ==
Container( 'MENUNGGU PEMBAYARAN' &&
padding: const EdgeInsets.symmetric( rental['updated_at'] != null)
horizontal: 10, CountdownTimerWidget(
vertical: 6, updatedAt: DateTime.parse(rental['updated_at']),
),
decoration: BoxDecoration(
color: AppColors.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.error.withOpacity(0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.timer_outlined,
size: 14,
color: AppColors.error,
),
const SizedBox(width: 4),
Text(
'Bayar dalam ${rental['countdown'] ?? '00:59:59'}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppColors.error,
),
),
],
),
), ),
], ],
), ),
@ -465,7 +594,10 @@ class WargaSewaView extends GetView<WargaSewaController> {
ElevatedButton( ElevatedButton(
onPressed: () {}, onPressed: () {},
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: rental['status'] == 'PEMBAYARAN DENDA' ? AppColors.error : AppColors.warning, backgroundColor:
rental['status'] == 'PEMBAYARAN DENDA'
? AppColors.error
: AppColors.warning,
foregroundColor: Colors.white, foregroundColor: Colors.white,
elevation: 0, elevation: 0,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -477,8 +609,13 @@ class WargaSewaView extends GetView<WargaSewaController> {
), ),
), ),
child: Text( child: Text(
rental['status'] == 'PEMBAYARAN DENDA' ? 'Bayar Denda' : 'Bayar Sekarang', rental['status'] == 'PEMBAYARAN DENDA'
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), ? 'Bayar Denda'
: 'Bayar Sekarang',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
), ),
), ),
], ],
@ -502,6 +639,10 @@ class WargaSewaView extends GetView<WargaSewaController> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
if ((rental['status'] ?? '').toString().toUpperCase() !=
'PEMBAYARAN DENDA' &&
(rental['status'] ?? '').toString().toUpperCase() !=
'PERIKSA PEMBAYARAN DENDA')
Expanded( Expanded(
child: _buildActionButton( child: _buildActionButton(
icon: Icons.cancel_outlined, icon: Icons.cancel_outlined,
@ -560,9 +701,7 @@ class WargaSewaView extends GetView<WargaSewaController> {
return Obx(() { return Obx(() {
// Show loading indicator while fetching data // Show loading indicator while fetching data
if (controller.isLoadingAccepted.value) { if (controller.isLoadingAccepted.value) {
return const Center( return const Center(child: CircularProgressIndicator());
child: CircularProgressIndicator(),
);
} }
// Check if there is any data to display // Check if there is any data to display
@ -573,12 +712,16 @@ class WargaSewaView extends GetView<WargaSewaController> {
child: Column( child: Column(
children: [ children: [
// Build a card for each accepted rental item // Build a card for each accepted rental item
...controller.acceptedRentals.map((rental) => Column( ...controller.acceptedRentals
.map(
(rental) => Column(
children: [ children: [
_buildDiterimaRentalCard(rental), _buildDiterimaRentalCard(rental),
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
)).toList(), ),
)
.toList(),
_buildTipsSectionDiterima(), _buildTipsSectionDiterima(),
], ],
), ),
@ -652,7 +795,9 @@ class WargaSewaView extends GetView<WargaSewaController> {
// Asset image with rounded corners // Asset image with rounded corners
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') child:
rental['imageUrl'] != null &&
rental['imageUrl'].toString().startsWith('http')
? Image.network( ? Image.network(
rental['imageUrl'], rental['imageUrl'],
width: 90, width: 90,
@ -675,7 +820,8 @@ class WargaSewaView extends GetView<WargaSewaController> {
}, },
) )
: Image.asset( : Image.asset(
rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', rental['imageUrl'] ??
'assets/images/gambar_pendukung.jpg',
width: 90, width: 90,
height: 90, height: 90,
fit: BoxFit.cover, fit: BoxFit.cover,
@ -826,11 +972,14 @@ class WargaSewaView extends GetView<WargaSewaController> {
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
children: controller.completedRentals children:
.map((rental) => Padding( controller.completedRentals
.map(
(rental) => Padding(
padding: const EdgeInsets.only(bottom: 16.0), padding: const EdgeInsets.only(bottom: 16.0),
child: _buildSelesaiRentalCard(rental), child: _buildSelesaiRentalCard(rental),
)) ),
)
.toList(), .toList(),
), ),
); );
@ -888,7 +1037,9 @@ class WargaSewaView extends GetView<WargaSewaController> {
// Asset image with rounded corners // Asset image with rounded corners
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') child:
rental['imageUrl'] != null &&
rental['imageUrl'].toString().startsWith('http')
? Image.network( ? Image.network(
rental['imageUrl'], rental['imageUrl'],
width: 90, width: 90,
@ -911,7 +1062,8 @@ class WargaSewaView extends GetView<WargaSewaController> {
}, },
) )
: Image.asset( : Image.asset(
rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', rental['imageUrl'] ??
'assets/images/gambar_pendukung.jpg',
width: 90, width: 90,
height: 90, height: 90,
fit: BoxFit.cover, fit: BoxFit.cover,
@ -972,11 +1124,7 @@ class WargaSewaView extends GetView<WargaSewaController> {
), ),
// Divider // Divider
Divider( Divider(color: Colors.grey.shade200, thickness: 1, height: 1),
color: Colors.grey.shade200,
thickness: 1,
height: 1,
),
// Price section // Price section
Padding( Padding(
@ -1047,11 +1195,14 @@ class WargaSewaView extends GetView<WargaSewaController> {
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
children: controller.cancelledRentals children:
.map((rental) => Padding( controller.cancelledRentals
.map(
(rental) => Padding(
padding: const EdgeInsets.only(bottom: 16.0), padding: const EdgeInsets.only(bottom: 16.0),
child: _buildDibatalkanRentalCard(rental), child: _buildDibatalkanRentalCard(rental),
)) ),
)
.toList(), .toList(),
), ),
); );
@ -1109,7 +1260,9 @@ class WargaSewaView extends GetView<WargaSewaController> {
// Asset image with rounded corners // Asset image with rounded corners
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') child:
rental['imageUrl'] != null &&
rental['imageUrl'].toString().startsWith('http')
? Image.network( ? Image.network(
rental['imageUrl'], rental['imageUrl'],
width: 90, width: 90,
@ -1132,7 +1285,8 @@ class WargaSewaView extends GetView<WargaSewaController> {
}, },
) )
: Image.asset( : Image.asset(
rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', rental['imageUrl'] ??
'assets/images/gambar_pendukung.jpg',
width: 90, width: 90,
height: 90, height: 90,
fit: BoxFit.cover, fit: BoxFit.cover,
@ -1185,7 +1339,8 @@ class WargaSewaView extends GetView<WargaSewaController> {
icon: Icons.access_time, icon: Icons.access_time,
text: rental['duration'] ?? '-', text: rental['duration'] ?? '-',
), ),
if (rental['alasanPembatalan'] != null && rental['alasanPembatalan'] != '-') if (rental['alasanPembatalan'] != null &&
rental['alasanPembatalan'] != '-')
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: _buildInfoRow( child: _buildInfoRow(
@ -1201,11 +1356,7 @@ class WargaSewaView extends GetView<WargaSewaController> {
), ),
// Divider // Divider
Divider( Divider(color: Colors.grey.shade200, thickness: 1, height: 1),
color: Colors.grey.shade200,
thickness: 1,
height: 1,
),
// Price section // Price section
Padding( Padding(
@ -1243,15 +1394,6 @@ class WargaSewaView extends GetView<WargaSewaController> {
iconColor: AppColors.info, iconColor: AppColors.info,
), ),
), ),
const SizedBox(width: 8),
Expanded(
child: _buildActionButton(
icon: Icons.refresh,
label: 'Pesan Kembali',
onPressed: () {},
iconColor: AppColors.success,
),
),
], ],
), ),
], ],
@ -1490,4 +1632,261 @@ class WargaSewaView extends GetView<WargaSewaController> {
), ),
); );
} }
Widget _buildDikembalikanTab() {
return Obx(() {
if (controller.isLoadingReturned.value) {
return const Center(
child: Padding(
padding: EdgeInsets.all(20.0),
child: CircularProgressIndicator(),
),
);
}
if (controller.returnedRentals.isEmpty) {
return _buildTabContent(
icon: Icons.assignment_return,
title: 'Belum Ada Sewa Dikembalikan',
subtitle: 'Sewa yang sudah dikembalikan akan muncul di sini',
buttonText: 'Lihat Aset',
onButtonPressed: () => Get.toNamed('/warga-aset'),
color: Colors.deepPurple,
);
}
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(20),
child: Column(
children:
controller.returnedRentals
.map(
(rental) => Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: _buildDikembalikanRentalCard(rental),
),
)
.toList(),
),
);
});
}
Widget _buildDikembalikanRentalCard(Map<String, dynamic> rental) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
// Header section with status
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.info.withOpacity(0.1),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(Icons.assignment_return, size: 18, color: AppColors.info),
const SizedBox(width: 8),
Text(
rental['status'] ?? 'DIKEMBALIKAN',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: AppColors.info,
),
),
],
),
),
// Asset details
Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
rental['imageUrl'] ?? '',
width: 64,
height: 64,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) => Container(
width: 64,
height: 64,
color: Colors.grey[200],
child: const Icon(Icons.image, color: Colors.grey),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
rental['name'] ?? 'Aset',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
_buildInfoRow(
icon: Icons.inventory_2_outlined,
text: '${rental['jumlahUnit'] ?? 0} Unit',
),
const SizedBox(height: 6),
_buildInfoRow(
icon: Icons.calendar_today_outlined,
text: rental['tanggalSewa'] ?? '',
),
const SizedBox(height: 6),
_buildInfoRow(
icon: Icons.schedule,
text: rental['rentangWaktu'] ?? '',
),
],
),
),
],
),
),
Divider(height: 1, thickness: 1, color: Colors.grey.shade100),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Total',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
Text(
rental['totalPrice'] ?? 'Rp 0',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: _buildActionButton(
icon: Icons.info_outline,
label: 'Lihat Detail',
onPressed: () => controller.viewRentalDetail(rental),
iconColor: AppColors.info,
),
),
],
),
),
],
),
);
}
}
class CountdownTimerWidget extends StatefulWidget {
final DateTime updatedAt;
final VoidCallback? onTimeout;
const CountdownTimerWidget({
required this.updatedAt,
this.onTimeout,
Key? key,
}) : super(key: key);
@override
State<CountdownTimerWidget> createState() => _CountdownTimerWidgetState();
}
class _CountdownTimerWidgetState extends State<CountdownTimerWidget> {
late Duration remaining;
Timer? timer;
@override
void initState() {
super.initState();
updateRemaining();
timer = Timer.periodic(
const Duration(seconds: 1),
(_) => updateRemaining(),
);
print('DEBUG updated_at: ${widget.updatedAt}');
}
void updateRemaining() {
final now = DateTime.now();
final deadline = widget.updatedAt.add(const Duration(hours: 1));
setState(() {
remaining = deadline.difference(now);
if (remaining.isNegative) {
remaining = Duration.zero;
timer?.cancel();
widget.onTimeout?.call();
}
});
}
@override
void dispose() {
timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (remaining.inSeconds <= 0) {
return Text('Waktu habis', style: TextStyle(color: Colors.red));
}
final h = remaining.inHours;
final m = remaining.inMinutes % 60;
final s = remaining.inSeconds % 60;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withOpacity(0.3), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.timer_outlined, size: 14, color: Colors.red),
const SizedBox(width: 4),
Text(
'Bayar dalam ${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
],
),
);
}
} }

View File

@ -0,0 +1,88 @@
import 'package:supabase_flutter/supabase_flutter.dart';
import '../data/models/pembayaran_model.dart';
class PembayaranService {
final SupabaseClient _supabase = Supabase.instance.client;
/// Ambil data pembayaran antara [start] (inklusif) dan [end] (eksklusif).
Future<List<PembayaranModel>> _fetchBetween(
DateTime start,
DateTime end,
) async {
final data = await _supabase
.from('pembayaran')
.select('id, metode_pembayaran, total_pembayaran, waktu_pembayaran')
.gte('waktu_pembayaran', start.toIso8601String())
.lt('waktu_pembayaran', end.toIso8601String())
.order('waktu_pembayaran', ascending: true);
return (data as List<dynamic>)
.map((e) => PembayaranModel.fromJson(e as Map<String, dynamic>))
.toList();
}
/// Hitung statistik yang diminta.
Future<Map<String, dynamic>> fetchStats() async {
final now = DateTime.now();
// Rentang bulan ini: [awal bulan ini, awal bulan depan)
final thisMonthStart = DateTime(now.year, now.month, 1);
final nextMonthStart = DateTime(now.year, now.month + 1, 1);
// Bulan lalu: [awal bulan lalu, awal bulan ini)
final lastMonthStart = DateTime(now.year, now.month - 1, 1);
final thisMonthEnd = thisMonthStart;
// 6 bulan terakhir: [6 bulan lalu, sekarang]
final sixMonthsAgo = DateTime(now.year, now.month - 6, 1);
// 1) Data bulan ini & bulan lalu
final thisMonthData = await _fetchBetween(thisMonthStart, nextMonthStart);
final lastMonthData = await _fetchBetween(lastMonthStart, thisMonthEnd);
// 2) Data 6 bulan terakhir
final sixMonthsData = await _fetchBetween(sixMonthsAgo, nextMonthStart);
// 3) Hitung total pendapatan
double sum(List<PembayaranModel> list) =>
list.fold(0.0, (acc, e) => acc + e.totalPembayaran);
final totalThis = sum(thisMonthData);
final totalLast = sum(lastMonthData);
final totalSix = sum(sixMonthsData);
// 4) Persentase selisih (bulanan)
double percentDiff = 0.0;
if (totalLast != 0) {
percentDiff = ((totalThis - totalLast) / totalLast) * 100;
}
// 5) Total per metode (hanya dari bulan ini, misalnya)
double totTunai = 0.0, totTransfer = 0.0;
for (var p in thisMonthData) {
if (p.metodePembayaran.toLowerCase() == 'tunai') {
totTunai += p.totalPembayaran;
} else if (p.metodePembayaran.toLowerCase() == 'transfer') {
totTransfer += p.totalPembayaran;
}
}
// 6) Trend per month (6 months, oldest to newest)
List<double> trendPerMonth = [];
for (int i = 5; i >= 0; i--) {
final dt = DateTime(now.year, now.month - i, 1);
final dtNext = DateTime(now.year, now.month - i + 1, 1);
final monthData = await _fetchBetween(dt, dtNext);
trendPerMonth.add(sum(monthData));
}
return {
'totalThisMonth': totalThis,
'percentComparedLast': percentDiff,
'totalTunai': totTunai,
'totalTransfer': totTransfer,
'totalLastSixMonths': totalSix,
'trendPerMonth': trendPerMonth,
};
}
}

View File

@ -2,6 +2,8 @@ import 'package:get/get.dart';
import 'navigation_service.dart'; import 'navigation_service.dart';
import '../data/providers/auth_provider.dart'; import '../data/providers/auth_provider.dart';
import '../modules/warga/controllers/warga_dashboard_controller.dart'; import '../modules/warga/controllers/warga_dashboard_controller.dart';
import '../data/providers/aset_provider.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
/// Abstract class untuk mengelola lifecycle service dan dependency /// Abstract class untuk mengelola lifecycle service dan dependency
abstract class ServiceManager { abstract class ServiceManager {
@ -26,6 +28,11 @@ abstract class ServiceManager {
Get.put(AuthProvider(), permanent: true); Get.put(AuthProvider(), permanent: true);
} }
// Register AsetProvider if not already registered
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
// Register WargaDashboardController as a permanent controller // Register WargaDashboardController as a permanent controller
// This ensures it's always available for the drawer // This ensures it's always available for the drawer
registerWargaDashboardController(); registerWargaDashboardController();

View File

@ -0,0 +1,222 @@
import 'package:supabase_flutter/supabase_flutter.dart';
import '../data/models/rental_booking_model.dart';
import 'package:flutter/foundation.dart';
class SewaService {
final SupabaseClient _supabase = Supabase.instance.client;
Future<List<SewaModel>> fetchAllSewa() async {
// 1) Ambil semua sewa_aset
final sewaData =
await _supabase.from('sewa_aset').select('''
id,
user_id,
status,
waktu_mulai,
waktu_selesai,
tanggal_pemesanan,
tipe_pesanan,
kuantitas,
aset_id,
paket_id
''')
as List<dynamic>;
if (sewaData.isEmpty) return [];
// Konversi dasar ke map
final List<Map<String, dynamic>> rawSewa =
sewaData.map((e) => e as Map<String, dynamic>).toList();
// Kumpulkan semua ID
final sewaIds = rawSewa.map((e) => e['id'] as String).toList();
final userIds = rawSewa.map((e) => e['user_id'] as String).toSet().toList();
// Pisahkan aset dan paket IDs
final asetIds =
rawSewa
.where((e) => e['tipe_pesanan'] == 'tunggal')
.map((e) => e['aset_id'] as String?)
.whereType<String>()
.toSet()
.toList();
final paketIds =
rawSewa
.where((e) => e['tipe_pesanan'] == 'paket')
.map((e) => e['paket_id'] as String?)
.whereType<String>()
.toSet()
.toList();
// 2) Ambil tagihan_sewa
final tagihanData =
await _supabase
.from('tagihan_sewa')
.select('sewa_aset_id, total_tagihan, denda, tagihan_dibayar')
.filter('sewa_aset_id', 'in', '(${sewaIds.join(",")})')
as List<dynamic>;
final Map<String, Map<String, dynamic>> mapTagihan = {
for (var t in tagihanData)
t['sewa_aset_id'] as String: t as Map<String, dynamic>,
};
// 3) Ambil data warga_desa
final wargaData =
await _supabase
.from('warga_desa')
.select('user_id, nama_lengkap, no_hp, avatar')
.filter('user_id', 'in', '(${userIds.join(",")})')
as List<dynamic>;
debugPrint('DEBUG wargaData (raw): ' + wargaData.toString());
final Map<String, Map<String, String>> mapWarga = {
for (var w in wargaData)
(w['user_id'] as String): {
'nama': w['nama_lengkap'] as String? ?? '-',
'noHp': w['no_hp'] as String? ?? '-',
'avatar': w['avatar'] as String? ?? '-',
},
};
debugPrint('DEBUG mapWarga (mapped): ' + mapWarga.toString());
// 4) Ambil data aset (untuk tunggal)
Map<String, Map<String, String>> mapAset = {};
if (asetIds.isNotEmpty) {
final asetData =
await _supabase
.from('aset')
.select('id, nama')
.filter('id', 'in', '(${asetIds.join(",")})')
as List<dynamic>;
final fotoData =
await _supabase
.from('foto_aset')
.select('id_aset, foto_aset')
.filter('id_aset', 'in', '(${asetIds.join(",")})')
as List<dynamic>;
// Map aset id → nama
mapAset = {
for (var a in asetData)
(a['id'] as String): {'nama': a['nama'] as String},
};
// Ambil foto pertama per aset
for (var f in fotoData) {
final aid = f['id_aset'] as String;
if (mapAset.containsKey(aid) && mapAset[aid]!['foto'] == null) {
mapAset[aid]!['foto'] = f['foto_aset'] as String;
}
}
}
// 5) Ambil data paket (untuk paket)
Map<String, Map<String, String>> mapPaket = {};
if (paketIds.isNotEmpty) {
final paketData =
await _supabase
.from('paket')
.select('id, nama')
.filter('id', 'in', '(${paketIds.join(",")})')
as List<dynamic>;
final fotoData =
await _supabase
.from('foto_aset')
.select('id_paket, foto_aset')
.filter('id_paket', 'in', '(${paketIds.join(",")})')
as List<dynamic>;
mapPaket = {
for (var p in paketData)
(p['id'] as String): {'nama': p['nama'] as String},
};
for (var f in fotoData) {
final pid = f['id_paket'] as String;
if (mapPaket.containsKey(pid) && mapPaket[pid]!['foto'] == null) {
mapPaket[pid]!['foto'] = f['foto_aset'] as String;
}
}
}
// Debug print hasil query utama
debugPrint('sewaData: ' + sewaData.toString());
debugPrint('sewaData count: ' + sewaData.length.toString());
debugPrint('tagihanData: ' + tagihanData.toString());
debugPrint('wargaData: ' + wargaData.toString());
debugPrint('mapAset: ' + mapAset.toString());
debugPrint('mapPaket: ' + mapPaket.toString());
// 6) Gabungkan dan bangun SewaModel
final List<SewaModel> result = [];
for (var row in rawSewa) {
final id = row['id'] as String;
final tipe = row['tipe_pesanan'] as String;
final warga = mapWarga[row['user_id']];
final tagihan = mapTagihan[id];
if (warga == null || tagihan == null) {
// Skip data jika relasi tidak ditemukan
continue;
}
result.add(
SewaModel(
id: id,
userId: row['user_id'],
status: row['status'] as String,
waktuMulai: DateTime.parse(row['waktu_mulai'] as String),
waktuSelesai: DateTime.parse(row['waktu_selesai'] as String),
tanggalPemesanan: DateTime.parse(row['tanggal_pemesanan'] as String),
tipePesanan: tipe,
kuantitas: row['kuantitas'] as int,
asetId: tipe == 'tunggal' ? row['aset_id'] as String : null,
asetNama:
(row['aset_id'] != null &&
tipe == 'tunggal' &&
mapAset[row['aset_id']] != null)
? mapAset[row['aset_id']]!['nama']
: null,
asetFoto:
(row['aset_id'] != null &&
tipe == 'tunggal' &&
mapAset[row['aset_id']] != null)
? mapAset[row['aset_id']]!['foto']
: null,
paketId: tipe == 'paket' ? row['paket_id'] as String : null,
paketNama:
(row['paket_id'] != null &&
tipe == 'paket' &&
mapPaket[row['paket_id']] != null)
? mapPaket[row['paket_id']]!['nama']
: null,
paketFoto:
(row['paket_id'] != null &&
tipe == 'paket' &&
mapPaket[row['paket_id']] != null)
? mapPaket[row['paket_id']]!['foto']
: null,
totalTagihan:
tagihan['total_tagihan'] != null
? (tagihan['total_tagihan'] as num?)?.toDouble() ?? 0.0
: 0.0,
denda:
tagihan['denda'] != null
? (tagihan['denda'] as num?)?.toDouble()
: null,
dibayar:
tagihan['tagihan_dibayar'] != null
? (tagihan['tagihan_dibayar'] as num?)?.toDouble()
: null,
paidAmount:
tagihan['total_tagihan'] != null
? (tagihan['total_tagihan'] as num?)?.toDouble()
: null,
wargaNama: warga['nama'] ?? '-',
wargaNoHp: warga['noHp'] ?? '-',
wargaAvatar: warga['avatar'] ?? '-',
),
);
}
// Debug print hasil query result
debugPrint('SewaModel result: ' + result.toString());
debugPrint('SewaModel result count: ' + result.length.toString());
return result;
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'app_colors.dart'; import 'app_colors.dart';
/// App theme configuration /// App theme configuration
@ -19,13 +20,16 @@ class AppTheme {
), ),
scaffoldBackgroundColor: AppColors.background, scaffoldBackgroundColor: AppColors.background,
// Set Lato as the default font for the entire app
fontFamily: GoogleFonts.lato().fontFamily,
// App bar theme // App bar theme
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: AppColors.primary, backgroundColor: AppColors.primary,
foregroundColor: Colors.white, foregroundColor: Colors.white,
elevation: 0, elevation: 0,
iconTheme: const IconThemeData(color: Colors.white), iconTheme: const IconThemeData(color: Colors.white),
titleTextStyle: const TextStyle( titleTextStyle: GoogleFonts.lato(
color: Colors.white, color: Colors.white,
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -50,7 +54,10 @@ class AppTheme {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), textStyle: GoogleFonts.lato(
fontSize: 16,
fontWeight: FontWeight.w600,
),
), ),
), ),
@ -62,7 +69,10 @@ class AppTheme {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), textStyle: GoogleFonts.lato(
fontSize: 16,
fontWeight: FontWeight.w600,
),
), ),
), ),
@ -70,7 +80,10 @@ class AppTheme {
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: AppColors.primary, foregroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), textStyle: GoogleFonts.lato(
fontSize: 14,
fontWeight: FontWeight.w600,
),
), ),
), ),
@ -98,8 +111,8 @@ class AppTheme {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.error, width: 1.5), borderSide: BorderSide(color: AppColors.error, width: 1.5),
), ),
hintStyle: TextStyle(color: AppColors.textLight), hintStyle: GoogleFonts.lato(color: AppColors.textLight),
labelStyle: TextStyle(color: AppColors.textSecondary), labelStyle: GoogleFonts.lato(color: AppColors.textSecondary),
), ),
// Checkbox theme // Checkbox theme
@ -115,21 +128,21 @@ class AppTheme {
// Text themes // Text themes
textTheme: TextTheme( textTheme: TextTheme(
displayLarge: TextStyle(color: AppColors.textPrimary), displayLarge: GoogleFonts.lato(color: AppColors.textPrimary),
displayMedium: TextStyle(color: AppColors.textPrimary), displayMedium: GoogleFonts.lato(color: AppColors.textPrimary),
displaySmall: TextStyle(color: AppColors.textPrimary), displaySmall: GoogleFonts.lato(color: AppColors.textPrimary),
headlineLarge: TextStyle(color: AppColors.textPrimary), headlineLarge: GoogleFonts.lato(color: AppColors.textPrimary),
headlineMedium: TextStyle(color: AppColors.textPrimary), headlineMedium: GoogleFonts.lato(color: AppColors.textPrimary),
headlineSmall: TextStyle(color: AppColors.textPrimary), headlineSmall: GoogleFonts.lato(color: AppColors.textPrimary),
titleLarge: TextStyle(color: AppColors.textPrimary), titleLarge: GoogleFonts.lato(color: AppColors.textPrimary),
titleMedium: TextStyle(color: AppColors.textPrimary), titleMedium: GoogleFonts.lato(color: AppColors.textPrimary),
titleSmall: TextStyle(color: AppColors.textPrimary), titleSmall: GoogleFonts.lato(color: AppColors.textPrimary),
bodyLarge: TextStyle(color: AppColors.textPrimary), bodyLarge: GoogleFonts.lato(color: AppColors.textPrimary),
bodyMedium: TextStyle(color: AppColors.textPrimary), bodyMedium: GoogleFonts.lato(color: AppColors.textPrimary),
bodySmall: TextStyle(color: AppColors.textSecondary), bodySmall: GoogleFonts.lato(color: AppColors.textSecondary),
labelLarge: TextStyle(color: AppColors.textPrimary), labelLarge: GoogleFonts.lato(color: AppColors.textPrimary),
labelMedium: TextStyle(color: AppColors.textSecondary), labelMedium: GoogleFonts.lato(color: AppColors.textSecondary),
labelSmall: TextStyle(color: AppColors.textLight), labelSmall: GoogleFonts.lato(color: AppColors.textLight),
), ),
// Divider theme // Divider theme

View File

@ -0,0 +1,10 @@
import 'package:intl/intl.dart';
String formatRupiah(double value) {
final formatter = NumberFormat.currency(
locale: 'id_ID',
symbol: 'Rp',
decimalDigits: 0,
);
return formatter.format(value);
}

View File

@ -291,34 +291,6 @@ class AppDrawer extends StatelessWidget {
), ),
// Settings Items // Settings Items
_buildDrawerItem(
icon: Icons.info_outline_rounded,
title: 'Tentang Aplikasi',
subtitle: 'Informasi dan bantuan',
showTrailing: false,
onTap: () {
Navigator.pop(context);
// Show about dialog
showAboutDialog(
context: context,
applicationName: 'BumRent App',
applicationVersion: '1.0.0',
applicationIcon: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
'assets/images/logo.png',
width: 40,
height: 40,
),
),
children: [
const Text(
'Aplikasi penyewaan dan berlangganan aset milik BUMDes untuk warga desa.',
),
],
);
},
),
_buildDrawerItem( _buildDrawerItem(
icon: Icons.logout_rounded, icon: Icons.logout_rounded,
title: 'Keluar', title: 'Keluar',

View File

@ -525,6 +525,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.1" version: "5.1.1"
logger:
dependency: "direct main"
description:
name: logger
sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1
url: "https://pub.dev"
source: hosted
version: "2.5.0"
logging: logging:
dependency: transitive dependency: transitive
description: description:

View File

@ -48,6 +48,7 @@ dependencies:
flutter_dotenv: ^5.1.0 flutter_dotenv: ^5.1.0
image_picker: ^1.0.7 image_picker: ^1.0.7
intl: 0.19.0 intl: 0.19.0
logger: ^2.1.0
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
get_storage: ^2.1.1 get_storage: ^2.1.1

30
widget_test.dart Normal file
View File

@ -0,0 +1,30 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:bumrent_app/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}