Tambahkan fungsionalitas pendaftaran donatur baru tanpa konfirmasi email di AuthProvider. Perbarui model DonaturModel untuk menyertakan properti isManual. Modifikasi tampilan dan controller untuk mendukung registrasi donatur, termasuk validasi form dan navigasi ke halaman pendaftaran. Perbarui rute aplikasi untuk menambahkan halaman pendaftaran donatur. Selain itu, perbarui beberapa file konfigurasi dan dependensi untuk mendukung perubahan ini.

This commit is contained in:
Khafidh Fuadi
2025-03-26 14:39:12 +07:00
parent eede5ebd4d
commit f74c058c71
31 changed files with 2454 additions and 960 deletions

View File

@ -12,6 +12,7 @@ class DonaturModel {
final String? fotoProfil;
final DateTime? createdAt;
final DateTime? updatedAt;
final bool isManual;
DonaturModel({
required this.id,
@ -25,6 +26,7 @@ class DonaturModel {
this.fotoProfil,
this.createdAt,
this.updatedAt,
this.isManual = false,
});
factory DonaturModel.fromRawJson(String str) =>
@ -42,6 +44,7 @@ class DonaturModel {
deskripsi: json["deskripsi"],
status: json["status"] ?? 'AKTIF',
fotoProfil: json["foto_profil"],
isManual: json["is_manual"] ?? false,
createdAt: json["created_at"] != null
? DateTime.parse(json["created_at"])
: null,
@ -60,6 +63,7 @@ class DonaturModel {
"deskripsi": deskripsi,
"status": status ?? 'AKTIF',
"foto_profil": fotoProfil,
"is_manual": isManual,
"created_at": createdAt?.toIso8601String(),
"updated_at": updatedAt?.toIso8601String(),
};

View File

@ -0,0 +1,95 @@
import 'dart:convert';
class RiwayatStokModel {
final String? id;
final String? stokBantuanId;
final Map<String, dynamic>? stokBantuan;
final String? jenisPerubahan; // 'penambahan' atau 'pengurangan'
final double? jumlah;
final String? sumber; // 'penitipan', 'penyaluran', atau 'manual'
final String? idReferensi; // ID penitipan atau penyaluran jika bukan manual
final String? alasan;
final String? fotoBukti;
final String? createdById; // ID petugas yang membuat perubahan
final Map<String, dynamic>? createdBy;
final DateTime? createdAt;
RiwayatStokModel({
this.id,
this.stokBantuanId,
this.stokBantuan,
this.jenisPerubahan,
this.jumlah,
this.sumber,
this.idReferensi,
this.alasan,
this.fotoBukti,
this.createdById,
this.createdBy,
this.createdAt,
});
factory RiwayatStokModel.fromRawJson(String str) =>
RiwayatStokModel.fromJson(json.decode(str));
String toRawJson() => json.encode(toJson());
factory RiwayatStokModel.fromJson(Map<String, dynamic> json) =>
RiwayatStokModel(
id: json["id"],
stokBantuanId: json["stok_bantuan_id"],
stokBantuan: json["stok_bantuan"],
jenisPerubahan: json["jenis_perubahan"],
jumlah: json["jumlah"] != null
? (json["jumlah"] is int
? json["jumlah"].toDouble()
: json["jumlah"])
: 0.0,
sumber: json["sumber"],
idReferensi: json["id_referensi"],
alasan: json["alasan"],
fotoBukti: json["foto_bukti"],
createdById: json["created_by_id"],
createdBy: json["created_by"],
createdAt: json["created_at"] != null
? DateTime.parse(json["created_at"])
: null,
);
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {
"stok_bantuan_id": stokBantuanId,
"jenis_perubahan": jenisPerubahan,
"jumlah": jumlah,
"sumber": sumber,
"created_at": createdAt?.toIso8601String(),
};
// Tambahkan id hanya jika tidak null
if (id != null) {
data["id"] = id;
}
// Tambahkan id_referensi jika tidak null
if (idReferensi != null) {
data["id_referensi"] = idReferensi;
}
// Tambahkan alasan jika tidak null
if (alasan != null) {
data["alasan"] = alasan;
}
// Tambahkan foto_bukti jika tidak null
if (fotoBukti != null) {
data["foto_bukti"] = fotoBukti;
}
// Tambahkan created_by_id jika tidak null
if (createdById != null) {
data["created_by_id"] = createdById;
}
return data;
}
}

View File

@ -166,6 +166,90 @@ class AuthProvider {
}
}
// Metode untuk mendaftarkan donatur (role_id 3)
// Implementasi baru yang tidak memerlukan konfirmasi email
Future<void> signUpDonatur({
required String email,
required String password,
required String namaLengkap,
required String alamat,
required String noHp,
String? jenis,
}) async {
try {
// Step 1: Daftarkan user dengan role_id 3 (donatur)
final response = await _supabaseService.client.auth.signUp(
email: email,
password: password,
data: {
'role_id': 3, // Otomatis set sebagai donatur
},
// Tidak menggunakan email redirect karena kita akan auto-confirm
);
final user = response.user;
if (user == null) {
throw Exception('Gagal membuat akun donatur');
}
print('User berhasil terdaftar dengan ID: ${user.id}');
// Step 2: Buat data donatur di tabel donatur
await _supabaseService.client.from('donatur').insert({
'id': user.id,
'nama_lengkap': namaLengkap,
'alamat': alamat,
'no_hp': noHp,
'email': email,
'jenis': jenis ?? 'Individu',
'created_at': DateTime.now().toIso8601String(),
});
// Step 3: Pastikan di tabel profiles_backup juga ada data ini (jika digunakan)
try {
await _supabaseService.client.from('profiles_backup').insert({
'id': user.id,
'updated_at': DateTime.now().toIso8601String(),
'role_id': 3,
});
} catch (e) {
print('Error menyimpan di profiles_backup: $e');
// Lanjutkan proses meskipun ada error di step ini
}
print('Berhasil mendaftarkan donatur: $namaLengkap');
// Pesan untuk pengembang
print(
'CATATAN: User akan perlu login manual. Jika email konfirmasi masih diperlukan,');
print(
'nonaktifkan verifikasi email di dashboard Supabase: Authentication > Email Templates > Disable Email Confirmation');
} catch (e) {
print('Error pada signUpDonatur: $e');
if (e.toString().contains('User already registered')) {
throw Exception(
'Email sudah terdaftar. Silakan gunakan email lain atau login dengan email tersebut.');
} else {
// Untuk error konfirmasi email, berikan instruksi yang jelas
if (e.toString().contains('Error sending confirmation mail')) {
print('===== PERHATIAN PENGEMBANG =====');
print(
'Error ini terjadi karena Supabase gagal mengirim email konfirmasi.');
print(
'Untuk mengatasi ini, nonaktifkan konfirmasi email di dashboard Supabase:');
print(
'1. Buka project Supabase > Authentication > Email Templates > Confirmation');
print('2. Matikan toggle "Enable email confirmations"');
print('==============================');
throw Exception(
'Gagal mengirim email konfirmasi. Mohon hubungi administrator.');
}
rethrow;
}
}
}
// Metode untuk mendapatkan user saat ini
Future<UserData?> getCurrentUser({bool skipCache = false}) async {
// Jika ada cache dan user masih terautentikasi, gunakan cache kecuali skipCache = true

View File

@ -77,6 +77,12 @@ class AuthController extends GetxController {
void onClose() {
// Pastikan semua controller dibersihkan sebelum dilepaskan
clearAndDisposeControllers();
// Dispose controller registrasi donatur
confirmPasswordController.dispose();
namaController.dispose();
alamatController.dispose();
noHpController.dispose();
jenisController.dispose();
super.onClose();
}
@ -361,21 +367,13 @@ class AuthController extends GetxController {
// Validasi konfirmasi password
String? validateConfirmPassword(String? value) {
try {
if (value == null || value.isEmpty) {
return 'Konfirmasi password tidak boleh kosong';
}
// Ambil nilai password dari controller jika tersedia
final password = passwordController.text;
if (value != password) {
return 'Password dan konfirmasi password tidak sama';
}
return null;
} catch (e) {
print('Error validating confirm password: $e');
return 'Terjadi kesalahan saat validasi';
if (value == null || value.isEmpty) {
return 'Konfirmasi password tidak boleh kosong';
}
if (value != passwordController.text) {
return 'Password dan konfirmasi password tidak sama';
}
return null;
}
// Metode untuk refresh data user setelah update profil
@ -419,4 +417,130 @@ class AuthController extends GetxController {
return Routes.home;
}
}
// Metode untuk validasi form registrasi donatur
String? validateDonaturNama(String? value) {
if (value == null || value.isEmpty) {
return 'Nama lengkap tidak boleh kosong';
}
return null;
}
String? validateDonaturNoHp(String? value) {
if (value == null || value.isEmpty) {
return 'Nomor HP tidak boleh kosong';
}
if (!RegExp(r'^[0-9]+$').hasMatch(value)) {
return 'Nomor HP hanya boleh berisi angka';
}
return null;
}
String? validateDonaturAlamat(String? value) {
if (value == null || value.isEmpty) {
return 'Alamat tidak boleh kosong';
}
return null;
}
// Form controller untuk registrasi donatur
final TextEditingController namaController = TextEditingController();
final TextEditingController alamatController = TextEditingController();
final TextEditingController noHpController = TextEditingController();
final TextEditingController jenisController = TextEditingController();
// Form key untuk registrasi donatur
final GlobalKey<FormState> registerDonaturFormKey = GlobalKey<FormState>();
// Metode untuk registrasi donatur
Future<void> registerDonatur() async {
print('DEBUG: Memulai proses registrasi donatur');
if (registerDonaturFormKey.currentState == null) {
print('Error: registerDonaturFormKey.currentState adalah null');
Get.snackbar(
'Error',
'Terjadi kesalahan pada form registrasi. Silakan coba lagi.',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
if (!registerDonaturFormKey.currentState!.validate()) {
print('DEBUG: Validasi form gagal');
return;
}
isLoading.value = true;
try {
// Proses registrasi donatur dengan role_id 3
await _authProvider.signUpDonatur(
email: emailController.text,
password: passwordController.text,
namaLengkap: namaController.text,
alamat: alamatController.text,
noHp: noHpController.text,
jenis: jenisController.text.isEmpty ? 'Individu' : jenisController.text,
);
Get.snackbar(
'Sukses',
'Registrasi donatur berhasil! Silakan login dengan akun Anda.',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.green,
colorText: Colors.white,
duration: const Duration(seconds: 5),
);
// Bersihkan form
clearDonaturRegistrationForm();
// Arahkan ke halaman login
Get.offAllNamed(Routes.login);
} catch (e) {
print('Error registrasi donatur: $e');
String errorMessage = 'Gagal melakukan registrasi';
// Tangani error sesuai jenisnya
if (e.toString().contains('email konfirmasi')) {
errorMessage =
'Gagal mengirim email konfirmasi. Mohon periksa alamat email Anda dan coba lagi nanti.';
} else if (e.toString().contains('Email sudah terdaftar')) {
errorMessage =
'Email sudah terdaftar. Silakan gunakan email lain atau login dengan email tersebut.';
} else if (e.toString().contains('weak-password')) {
errorMessage =
'Password terlalu lemah. Gunakan kombinasi huruf, angka, dan simbol.';
} else if (e.toString().contains('invalid-email')) {
errorMessage = 'Format email tidak valid.';
} else {
errorMessage = 'Gagal melakukan registrasi: ${e.toString()}';
}
Get.snackbar(
'Error',
errorMessage,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
duration: const Duration(seconds: 5),
);
} finally {
isLoading.value = false;
}
}
// Metode untuk membersihkan form registrasi donatur
void clearDonaturRegistrationForm() {
emailController.clear();
passwordController.clear();
confirmPasswordController.clear();
namaController.clear();
alamatController.clear();
noHpController.clear();
jenisController.clear();
}
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:penyaluran_app/app/modules/auth/controllers/auth_controller.dart';
import 'package:penyaluran_app/app/routes/app_pages.dart';
class LoginView extends GetView<AuthController> {
const LoginView({super.key});
@ -109,6 +110,39 @@ class LoginView extends GetView<AuthController> {
),
)),
const SizedBox(height: 20),
// Divider
const Row(
children: [
Expanded(child: Divider()),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child:
Text('ATAU', style: TextStyle(color: Colors.grey)),
),
Expanded(child: Divider()),
],
),
const SizedBox(height: 20),
// Register Donatur Button
OutlinedButton(
onPressed: () => Get.toNamed(Routes.registerDonatur),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
side: const BorderSide(color: Colors.blue),
),
child: const Text(
'DAFTAR SEBAGAI DONATUR',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
],
),
),

View File

@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:penyaluran_app/app/modules/auth/controllers/auth_controller.dart';
import 'package:penyaluran_app/app/routes/app_pages.dart';
class RegisterDonaturView extends GetView<AuthController> {
const RegisterDonaturView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Daftar Sebagai Donatur'),
elevation: 0,
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: SingleChildScrollView(
child: Form(
key: controller.registerDonaturFormKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 20),
// Logo atau Judul
const Center(
child: Text(
'Daftar Donatur',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
const SizedBox(height: 10),
const Center(
child: Text(
'Isi data untuk mendaftar sebagai donatur',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
),
const SizedBox(height: 30),
// Nama Lengkap
TextFormField(
controller: controller.namaController,
keyboardType: TextInputType.name,
decoration: InputDecoration(
labelText: 'Nama Lengkap',
prefixIcon: const Icon(Icons.person),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
),
validator: controller.validateDonaturNama,
),
const SizedBox(height: 15),
// Email
TextFormField(
controller: controller.emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: 'Email',
prefixIcon: const Icon(Icons.email),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
),
validator: controller.validateEmail,
),
const SizedBox(height: 15),
// Password
TextFormField(
controller: controller.passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
),
validator: controller.validatePassword,
),
const SizedBox(height: 15),
// Confirm Password
TextFormField(
controller: controller.confirmPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Konfirmasi Password',
prefixIcon: const Icon(Icons.lock_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
),
validator: controller.validateConfirmPassword,
),
const SizedBox(height: 15),
// No HP
TextFormField(
controller: controller.noHpController,
keyboardType: TextInputType.phone,
decoration: InputDecoration(
labelText: 'Nomor HP',
prefixIcon: const Icon(Icons.phone),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
),
validator: controller.validateDonaturNoHp,
),
const SizedBox(height: 15),
// Alamat
TextFormField(
controller: controller.alamatController,
keyboardType: TextInputType.streetAddress,
maxLines: 2,
decoration: InputDecoration(
labelText: 'Alamat',
prefixIcon: const Icon(Icons.home),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
),
validator: controller.validateDonaturAlamat,
),
const SizedBox(height: 15),
// Jenis Donatur (Dropdown)
DropdownButtonFormField<String>(
value: controller.jenisController.text.isEmpty
? 'Individu'
: controller.jenisController.text,
decoration: InputDecoration(
labelText: 'Jenis Donatur',
prefixIcon: const Icon(Icons.category),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
),
items: const [
DropdownMenuItem(
value: 'Individu', child: Text('Individu')),
DropdownMenuItem(
value: 'Organisasi', child: Text('Organisasi')),
DropdownMenuItem(
value: 'Perusahaan', child: Text('Perusahaan')),
DropdownMenuItem(
value: 'Lainnya', child: Text('Lainnya')),
],
onChanged: (value) {
controller.jenisController.text = value ?? 'Individu';
},
),
const SizedBox(height: 15),
// Register Button
Obx(() => ElevatedButton(
onPressed: controller.isLoading.value
? null
: controller.registerDonatur,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 15),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: controller.isLoading.value
? const SpinKitThreeBounce(
color: Colors.white,
size: 24,
)
: const Text(
'DAFTAR',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
)),
const SizedBox(height: 20),
// Login Link
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Sudah punya akun?'),
TextButton(
onPressed: () => Get.offAllNamed(Routes.login),
child: const Text('Masuk'),
),
],
),
],
),
),
),
),
),
);
}
}

View File

@ -4,6 +4,7 @@ import 'package:penyaluran_app/app/modules/petugas_desa/controllers/petugas_desa
import 'package:penyaluran_app/app/modules/petugas_desa/controllers/petugas_desa_dashboard_controller.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/controllers/jadwal_penyaluran_controller.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/controllers/stok_bantuan_controller.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/controllers/riwayat_stok_controller.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/controllers/penitipan_bantuan_controller.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/controllers/pengaduan_controller.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/controllers/penerima_bantuan_controller.dart';
@ -43,6 +44,11 @@ class PetugasDesaBinding extends Bindings {
() => StokBantuanController(),
);
// Daftarkan controller riwayat stok
Get.lazyPut<RiwayatStokController>(
() => RiwayatStokController(),
);
// Daftarkan controller penitipan bantuan
Get.lazyPut<PenitipanBantuanController>(
() => PenitipanBantuanController(),

View File

@ -439,29 +439,6 @@ class JadwalPenyaluranController extends GetxController {
.insert(penerimaPenyaluran);
}
// Update stok bantuan (kurangi dengan total stok yang dibutuhkan)
try {
// Dapatkan stok saat ini
final stokData = await _supabaseService.client
.from('stok_bantuan')
.select('total_stok')
.eq('id', stokBantuanId)
.single();
if (stokData['total_stok'] != null) {
final currentStok = stokData['total_stok'].toDouble();
final newStok = currentStok - totalStokDibutuhkan;
// Update stok bantuan dengan nilai baru
await _supabaseService.client
.from('stok_bantuan')
.update({'total_stok': newStok}).eq('id', stokBantuanId);
}
} catch (e) {
print('Error updating stok bantuan: $e');
// Tidak throw exception di sini karena penyaluran sudah disimpan
}
// Setelah berhasil menambahkan, refresh data
await loadJadwalData();
await loadPermintaanPenjadwalanData();

View File

@ -75,15 +75,6 @@ class PenitipanBantuanController extends GetxController {
// Hapus delay dan muat data petugas desa langsung
loadAllPetugasDesaData();
// Listener untuk pencarian donatur
donaturSearchController.addListener(() {
if (donaturSearchController.text.length >= 3) {
searchDonatur(donaturSearchController.text);
} else {
hasilPencarianDonatur.clear();
}
});
}
@override
@ -441,16 +432,19 @@ class PenitipanBantuanController extends GetxController {
Future<DonaturModel?> getDonaturInfo(String donaturId) async {
try {
// Cek cache terlebih dahulu
// Periksa apakah donatur sudah ada di cache
if (donaturCache.containsKey(donaturId)) {
return donaturCache[donaturId];
}
final donaturData = await _supabaseService.getDonaturById(donaturId);
if (donaturData != null) {
final donatur = DonaturModel.fromJson(donaturData);
// Ambil data donatur dari server
final result = await _supabaseService.getDonaturById(donaturId);
if (result != null) {
final donatur = DonaturModel.fromJson(result);
// Simpan ke cache
donaturCache[donaturId] = donatur;
return donatur;
}
return null;
@ -595,16 +589,12 @@ class PenitipanBantuanController extends GetxController {
}
String getPetugasDesaNama(String? petugasDesaId) {
print('Petugas Desa ID: $petugasDesaId');
if (petugasDesaId == null) {
return 'Tidak diketahui';
}
// Cek apakah data ada di cache
if (!petugasDesaCache.containsKey(petugasDesaId)) {
print(
'Data petugas desa tidak ditemukan di cache untuk ID: $petugasDesaId');
// Muat data petugas dan perbarui UI
loadPetugasDesaData(petugasDesaId);
// Coba cek lagi setelah pemuatan
@ -620,24 +610,18 @@ class PenitipanBantuanController extends GetxController {
// Sekarang data seharusnya ada di cache
// Akses nama dari struktur data petugas_desa
final nama = petugasDesaCache[petugasDesaId]?['nama_lengkap'];
print('Nama petugas desa: $nama untuk ID: $petugasDesaId');
return nama ?? 'Tidak diketahui';
}
// Fungsi untuk memuat data petugas desa dan memperbarui UI
Future<void> loadPetugasDesaData(String petugasDesaId) async {
try {
print('Memuat data petugas desa untuk ID: $petugasDesaId');
final petugasData = await getPetugasDesaInfo(petugasDesaId);
if (petugasData != null) {
// Data sudah dimasukkan ke cache oleh getPetugasDesaInfo
print('Berhasil memuat data petugas: ${petugasData['nama_lengkap']}');
// Refresh UI segera
update(['petugas_data']);
} else {
print(
'Gagal mengambil data petugas desa dari server untuk ID: $petugasDesaId');
}
} catch (e) {
print('Error saat memuat data petugas desa: $e');
@ -647,11 +631,9 @@ class PenitipanBantuanController extends GetxController {
// Fungsi untuk memuat semua data petugas desa yang terkait dengan penitipan
void loadAllPetugasDesaData() async {
try {
print('Memuat ulang semua data petugas desa...');
for (var item in daftarPenitipan) {
if (item.status == 'TERVERIFIKASI' && item.petugasDesaId != null) {
if (!petugasDesaCache.containsKey(item.petugasDesaId)) {
print('Memuat data petugas desa untuk ID: ${item.petugasDesaId}');
await getPetugasDesaInfo(item.petugasDesaId);
}
}
@ -669,76 +651,6 @@ class PenitipanBantuanController extends GetxController {
}
}
Future<void> searchDonatur(String keyword) async {
if (keyword.length < 3) {
hasilPencarianDonatur.clear();
return;
}
isSearchingDonatur.value = true;
try {
final result = await _supabaseService.searchDonatur(keyword);
if (result != null) {
hasilPencarianDonatur.value =
result.map((data) => DonaturModel.fromJson(data)).toList();
} else {
hasilPencarianDonatur.clear();
}
} catch (e) {
print('Error searching donatur: $e');
hasilPencarianDonatur.clear();
} finally {
isSearchingDonatur.value = false;
}
}
// Metode untuk mendapatkan daftar donatur
Future<List<DonaturModel>> getDaftarDonatur() async {
try {
final result = await _supabaseService.getDaftarDonatur();
if (result != null) {
return result.map((data) => DonaturModel.fromJson(data)).toList();
}
return [];
} catch (e) {
print('Error getting daftar donatur: $e');
return [];
}
}
Future<String?> tambahDonatur({
required String nama,
required String noHp,
String? alamat,
String? email,
String? jenis,
}) async {
try {
final donaturData = {
'nama_lengkap': nama,
'no_hp': noHp,
'alamat': alamat,
'email': email,
'jenis': jenis,
'status': 'AKTIF',
'created_at': DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
};
return await _supabaseService.tambahDonatur(donaturData);
} catch (e) {
print('Error adding donatur: $e');
Get.snackbar(
'Error',
'Gagal menambahkan donatur: ${e.toString()}',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return null;
}
}
// Mendapatkan informasi apakah stok bantuan berupa uang
bool isStokBantuanUang(String stokBantuanId) {
if (!stokBantuanMap.containsKey(stokBantuanId)) {
@ -772,4 +684,10 @@ class PenitipanBantuanController extends GetxController {
print(
'Counter updated - Menunggu: $menunggu, Terverifikasi: $terverifikasi, Ditolak: $ditolak');
}
// Metode untuk membersihkan pencarian donatur
void resetDonaturSearch() {
hasilPencarianDonatur.clear();
donaturSearchController.clear();
}
}

View File

@ -0,0 +1,305 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:penyaluran_app/app/data/models/riwayat_stok_model.dart';
import 'package:penyaluran_app/app/data/models/stok_bantuan_model.dart';
import 'package:penyaluran_app/app/modules/auth/controllers/auth_controller.dart';
import 'package:penyaluran_app/app/services/supabase_service.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
class RiwayatStokController extends GetxController {
final AuthController _authController = Get.find<AuthController>();
final SupabaseService _supabaseService = SupabaseService.to;
final ImagePicker _imagePicker = ImagePicker();
final RxBool isLoading = false.obs;
final RxList<RiwayatStokModel> daftarRiwayatStok = <RiwayatStokModel>[].obs;
final RxList<StokBantuanModel> daftarStokBantuan = <StokBantuanModel>[].obs;
// Filter untuk riwayat stok
final RxString filterJenisPerubahan = 'semua'.obs;
final RxString filterStokBantuanId = 'semua'.obs;
// Controller untuk pencarian
final TextEditingController searchController = TextEditingController();
final RxString searchQuery = ''.obs;
// Data untuk form penambahan/pengurangan manual
final Rx<StokBantuanModel?> selectedStokBantuan = Rx<StokBantuanModel?>(null);
final RxDouble jumlah = 0.0.obs;
final RxString alasan = ''.obs;
final Rx<File?> fotoBukti = Rx<File?>(null);
final RxBool isSubmitting = false.obs;
@override
void onInit() {
super.onInit();
loadRiwayatStok();
loadStokBantuan();
// Listener untuk pencarian
searchController.addListener(() {
searchQuery.value = searchController.text;
});
}
@override
void onClose() {
searchController.dispose();
super.onClose();
}
// Metode untuk memperbarui data saat tab diaktifkan kembali
void onTabReactivated() {
refreshData();
}
Future<void> loadRiwayatStok() async {
isLoading.value = true;
try {
final String? stokBantuanId = filterStokBantuanId.value != 'semua'
? filterStokBantuanId.value
: null;
final String? jenisPerubahan = filterJenisPerubahan.value != 'semua'
? filterJenisPerubahan.value
: null;
final riwayatStokData = await _supabaseService.getRiwayatStok(
stokBantuanId: stokBantuanId,
jenisPerubahan: jenisPerubahan,
);
if (riwayatStokData != null) {
daftarRiwayatStok.value = riwayatStokData
.map((data) => RiwayatStokModel.fromJson(data))
.toList();
}
} catch (e) {
print('Error loading riwayat stok data: $e');
Get.snackbar(
'Error',
'Gagal memuat data riwayat stok: $e',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoading.value = false;
}
}
Future<void> loadStokBantuan() async {
try {
final stokBantuanData = await _supabaseService.getStokBantuan();
if (stokBantuanData != null) {
daftarStokBantuan.value = stokBantuanData
.map((data) => StokBantuanModel.fromJson(data))
.toList();
}
} catch (e) {
print('Error loading stok bantuan data: $e');
}
}
Future<void> refreshData() async {
await loadRiwayatStok();
await loadStokBantuan();
}
void filterByJenisPerubahan(String value) {
filterJenisPerubahan.value = value;
loadRiwayatStok();
}
void filterByStokBantuan(String value) {
filterStokBantuanId.value = value;
loadRiwayatStok();
}
List<RiwayatStokModel> getFilteredRiwayatStok() {
if (searchQuery.isEmpty) {
return daftarRiwayatStok;
}
return daftarRiwayatStok.where((item) {
// Cari berdasarkan nama stok bantuan
final stokBantuanMatch = item.stokBantuan != null &&
item.stokBantuan!['nama'] != null &&
item.stokBantuan!['nama']
.toString()
.toLowerCase()
.contains(searchQuery.value.toLowerCase());
// Cari berdasarkan alasan
final alasanMatch = item.alasan != null &&
item.alasan!.toLowerCase().contains(searchQuery.value.toLowerCase());
// Cari berdasarkan sumber
final sumberMatch = item.sumber != null &&
item.sumber!.toLowerCase().contains(searchQuery.value.toLowerCase());
return stokBantuanMatch || alasanMatch || sumberMatch;
}).toList();
}
Future<void> tambahStokManual() async {
isSubmitting.value = true;
try {
if (selectedStokBantuan.value == null) {
throw Exception('Pilih bantuan terlebih dahulu');
}
if (jumlah.value <= 0) {
throw Exception('Jumlah harus lebih dari 0');
}
if (alasan.value.isEmpty) {
throw Exception('Alasan harus diisi');
}
if (fotoBukti.value == null) {
throw Exception('Foto bukti harus diupload');
}
final petugasId = _authController.baseUser?.id;
if (petugasId == null) {
throw Exception('ID petugas tidak ditemukan');
}
await _supabaseService.tambahStokManual(
stokBantuanId: selectedStokBantuan.value!.id!,
jumlah: jumlah.value,
alasan: alasan.value,
fotoBuktiPath: fotoBukti.value!.path,
petugasId: petugasId,
);
// Reset form
resetForm();
// Refresh data
await refreshData();
Get.back(); // Tutup dialog
Get.snackbar(
'Sukses',
'Stok bantuan berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
);
} catch (e) {
print('Error menambahkan stok manual: $e');
Get.snackbar(
'Error',
'Gagal menambahkan stok: ${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isSubmitting.value = false;
}
}
Future<void> kurangiStokManual() async {
isSubmitting.value = true;
try {
if (selectedStokBantuan.value == null) {
throw Exception('Pilih bantuan terlebih dahulu');
}
if (jumlah.value <= 0) {
throw Exception('Jumlah harus lebih dari 0');
}
if (alasan.value.isEmpty) {
throw Exception('Alasan harus diisi');
}
if (fotoBukti.value == null) {
throw Exception('Foto bukti harus diupload');
}
final petugasId = _authController.baseUser?.id;
if (petugasId == null) {
throw Exception('ID petugas tidak ditemukan');
}
await _supabaseService.kurangiStokManual(
stokBantuanId: selectedStokBantuan.value!.id!,
jumlah: jumlah.value,
alasan: alasan.value,
fotoBuktiPath: fotoBukti.value!.path,
petugasId: petugasId,
);
// Reset form
resetForm();
// Refresh data
await refreshData();
Get.back(); // Tutup dialog
Get.snackbar(
'Sukses',
'Stok bantuan berhasil dikurangi',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
);
} catch (e) {
print('Error mengurangi stok manual: $e');
Get.snackbar(
'Error',
'Gagal mengurangi stok: ${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isSubmitting.value = false;
}
}
void setSelectedStokBantuan(StokBantuanModel? stokBantuan) {
selectedStokBantuan.value = stokBantuan;
}
void setJumlah(double value) {
jumlah.value = value;
}
void setAlasan(String value) {
alasan.value = value;
}
Future<void> pickImage() async {
try {
final pickedFile =
await _imagePicker.pickImage(source: ImageSource.gallery);
if (pickedFile != null) {
fotoBukti.value = File(pickedFile.path);
}
} catch (e) {
print('Error picking image: $e');
Get.snackbar(
'Error',
'Gagal memilih gambar: ${e.toString()}',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
}
void resetForm() {
selectedStokBantuan.value = null;
jumlah.value = 0.0;
alasan.value = '';
fotoBukti.value = null;
}
}

View File

@ -249,21 +249,24 @@ class StokBantuanController extends GetxController {
return filteredList;
}
// Metode untuk mendapatkan jumlah stok yang hampir habis (stok <= 10)
// Metode untuk mendapatkan jumlah stok yang hampir habis
int getStokHampirHabis() {
return daftarStokBantuan
.where((stok) => (stok.totalStok ?? 0) <= 10)
.where((item) => (item.totalStok ?? 0) <= 10)
.length;
}
// Metode untuk menghitung total dana bantuan
// Metode untuk menghitung total dana bantuan dari stok uang
void _hitungTotalDanaBantuan() {
double total = 0.0;
// Hitung dari stok yang isUang = true
for (var stok in daftarStokBantuan) {
if (stok.isUang == true) {
total += stok.totalStok ?? 0.0;
}
}
totalDanaBantuan.value = total;
}

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:penyaluran_app/app/data/models/donatur_model.dart';
import 'package:penyaluran_app/app/data/models/penitipan_bantuan_model.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/controllers/penitipan_bantuan_controller.dart';
import 'package:penyaluran_app/app/theme/app_theme.dart';
@ -44,11 +43,6 @@ class PenitipanView extends GetView<PenitipanBantuanController> {
),
),
)),
floatingActionButton: FloatingActionButton(
onPressed: () => _showTambahPenitipanDialog(context),
backgroundColor: AppTheme.primaryColor,
child: const Icon(Icons.add, color: Colors.white),
),
);
}
@ -293,6 +287,9 @@ class PenitipanView extends GetView<PenitipanBantuanController> {
// Gunakan data donatur dari relasi jika tersedia
final donaturNama = item.donatur?.nama ?? 'Donatur tidak ditemukan';
// Cek apakah donatur manual
final isDonaturManual = item.donatur?.isManual ?? false;
// Debug info
print('PenitipanItem - stokBantuanId: ${item.stokBantuanId}');
@ -331,12 +328,43 @@ class PenitipanView extends GetView<PenitipanBantuanController> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
donaturNama,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
child: Row(
children: [
Expanded(
child: Text(
donaturNama,
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
overflow: TextOverflow.ellipsis,
),
if (isDonaturManual)
Tooltip(
message: 'Donatur Manual (Diinput oleh petugas desa)',
child: Container(
margin: const EdgeInsets.only(left: 4),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.blue.shade300),
),
child: const Text(
'Manual',
style: TextStyle(
fontSize: 10,
color: Colors.blue,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
Container(
@ -753,771 +781,6 @@ class PenitipanView extends GetView<PenitipanBantuanController> {
);
}
void _showTambahPenitipanDialog(BuildContext context) {
final formKey = GlobalKey<FormState>();
final TextEditingController jumlahController = TextEditingController();
final TextEditingController deskripsiController = TextEditingController();
// Variabel untuk menyimpan nilai yang dipilih
final Rx<String?> selectedStokBantuanId = Rx<String?>(null);
final Rx<String?> selectedDonaturId = Rx<String?>(null);
final Rx<DonaturModel?> selectedDonatur = Rx<DonaturModel?>(null);
// Reset foto bantuan paths
controller.fotoBantuanPaths.clear();
controller.donaturSearchController.clear();
controller.hasilPencarianDonatur.clear();
Get.dialog(
Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 16),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Obx(() {
// Dapatkan informasi apakah stok bantuan berupa uang
bool isUang = false;
String satuan = '';
if (selectedStokBantuanId.value != null) {
isUang =
controller.isStokBantuanUang(selectedStokBantuanId.value!);
satuan =
controller.getKategoriSatuan(selectedStokBantuanId.value);
}
return Form(
key: formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tambah Manual Penitipan Bantuan',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// Pilih kategori bantuan
Text(
'Jenis Stok Bantuan',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
),
hint: const Text('Pilih jenis stok bantuan'),
value: selectedStokBantuanId.value,
items: controller.stokBantuanMap.entries.map((entry) {
return DropdownMenuItem<String>(
value: entry.key,
child: Text(entry.value.nama ?? 'Tidak ada nama'),
);
}).toList(),
onChanged: (value) {
selectedStokBantuanId.value = value;
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Kategori bantuan harus dipilih';
}
return null;
},
),
const SizedBox(height: 16),
// Jumlah bantuan
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isUang ? 'Jumlah Uang (Rp)' : 'Jumlah Bantuan',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
TextFormField(
controller: jumlahController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
hintText:
isUang ? 'Contoh: 100000' : 'Contoh: 10',
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Jumlah harus diisi';
}
if (double.tryParse(value) == null) {
return 'Jumlah harus berupa angka';
}
if (double.parse(value) <= 0) {
return 'Jumlah harus lebih dari 0';
}
return null;
},
),
],
),
),
if (satuan.isNotEmpty && !isUang) ...[
const SizedBox(width: 8),
Container(
margin: const EdgeInsets.only(top: 32),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade400),
),
child: Text(
satuan,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
],
),
const SizedBox(height: 16),
// Donatur (wajib)
Text(
'Donatur',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (selectedDonatur.value != null) ...[
// Tampilkan donatur yang dipilih
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
selectedDonatur.value!.nama ??
'Tidak ada nama',
style: const TextStyle(
fontWeight: FontWeight.bold),
),
if (selectedDonatur.value!.noHp != null)
Text(selectedDonatur.value!.noHp!),
],
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
selectedDonatur.value = null;
selectedDonaturId.value = null;
},
),
],
),
),
] else ...[
// Tampilkan pencarian donatur
TextFormField(
controller: controller.donaturSearchController,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
hintText: 'Cari donatur (min. 3 karakter)',
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
suffixIcon: controller.isSearchingDonatur.value
? const Padding(
padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.search),
),
onChanged: (value) {
controller.searchDonatur(value);
},
validator: (value) {
if (selectedDonaturId.value == null) {
return 'Donatur harus dipilih';
}
return null;
},
),
// Hasil pencarian donatur
if (controller.hasilPencarianDonatur.isNotEmpty)
Container(
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
constraints: const BoxConstraints(maxHeight: 150),
child: ListView.builder(
shrinkWrap: true,
itemCount:
controller.hasilPencarianDonatur.length,
itemBuilder: (context, index) {
final donatur =
controller.hasilPencarianDonatur[index];
return ListTile(
title:
Text(donatur.nama ?? 'Tidak ada nama'),
subtitle: donatur.noHp != null
? Text(donatur.noHp!)
: const Text('Tidak ada nomor telepon'),
dense: true,
onTap: () {
selectedDonatur.value = donatur;
selectedDonaturId.value = donatur.id;
controller.donaturSearchController
.clear();
controller.hasilPencarianDonatur.clear();
},
);
},
),
),
// Tombol tambah donatur baru
if (controller.donaturSearchController.text.length >=
3 &&
controller.hasilPencarianDonatur.isEmpty &&
!controller.isSearchingDonatur.value)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton.icon(
onPressed: () {
_showTambahDonaturDialog(context,
(donaturId) {
// Callback ketika donatur berhasil ditambahkan
controller
.getDonaturInfo(donaturId)
.then((donatur) {
if (donatur != null) {
selectedDonatur.value = donatur;
selectedDonaturId.value = donatur.id;
}
});
});
},
icon: const Icon(Icons.add),
label: const Text('Tambah Donatur Baru'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
foregroundColor: AppTheme.primaryColor,
),
),
),
],
],
),
const SizedBox(height: 16),
// Deskripsi
Text(
'Deskripsi',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
TextFormField(
controller: deskripsiController,
maxLines: 3,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
hintText: 'Deskripsi bantuan',
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Deskripsi harus diisi';
}
return null;
},
),
const SizedBox(height: 16),
// Upload foto bantuan
Text(
'Foto Bantuan',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
if (controller.fotoBantuanPaths.isEmpty)
InkWell(
onTap: () => _showPilihSumberFoto(context),
child: Container(
height: 150,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade400),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.camera_alt,
size: 48,
color: Colors.grey.shade600,
),
const SizedBox(height: 8),
Text(
'Tambah Foto',
style: TextStyle(
color: Colors.grey.shade600,
fontWeight: FontWeight.bold,
),
),
],
),
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 100,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: controller.fotoBantuanPaths.length +
1, // +1 untuk tombol tambah
itemBuilder: (context, index) {
if (index ==
controller.fotoBantuanPaths.length) {
// Tombol tambah foto
return InkWell(
onTap: () => _showPilihSumberFoto(context),
child: Container(
width: 100,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.grey.shade400),
),
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
Icons.add_photo_alternate,
size: 32,
color: Colors.grey.shade600,
),
const SizedBox(height: 4),
Text(
'Tambah',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12,
),
),
],
),
),
);
}
// Tampilkan foto yang sudah diambil
return Stack(
children: [
Container(
width: 100,
height: 100,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
image: DecorationImage(
image: FileImage(File(controller
.fotoBantuanPaths[index])),
fit: BoxFit.cover,
),
),
),
Positioned(
top: 4,
right: 12,
child: GestureDetector(
onTap: () =>
controller.removeFotoBantuan(index),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color:
Colors.black.withOpacity(0.5),
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
color: Colors.white,
size: 16,
),
),
),
),
],
);
},
),
),
],
),
const SizedBox(height: 24),
// Tombol aksi
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Batal'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: controller.isUploading.value
? null
: () {
if (formKey.currentState!.validate()) {
if (controller.fotoBantuanPaths.isEmpty) {
Get.snackbar(
'Error',
'Foto bantuan harus diupload',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
controller.tambahPenitipanBantuan(
stokBantuanId:
selectedStokBantuanId.value!,
jumlah:
double.parse(jumlahController.text),
deskripsi: deskripsiController.text,
donaturId: selectedDonaturId.value,
isUang: isUang,
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
),
child: controller.isUploading.value
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Simpan'),
),
],
),
],
),
),
);
}),
),
),
);
}
void _showPilihSumberFoto(BuildContext context) {
Get.bottomSheet(
Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Pilih Sumber Foto',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('Kamera'),
onTap: () {
Get.back();
controller.pickFotoBantuan(fromCamera: true);
},
),
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Galeri'),
onTap: () {
Get.back();
controller.pickFotoBantuan(fromCamera: false);
},
),
],
),
),
);
}
void _showTambahDonaturDialog(
BuildContext context, Function(String) onDonaturAdded) {
final formKey = GlobalKey<FormState>();
final TextEditingController namaController = TextEditingController();
final TextEditingController noHpController = TextEditingController();
final TextEditingController alamatController = TextEditingController();
final TextEditingController emailController = TextEditingController();
final TextEditingController jenisController = TextEditingController();
Get.dialog(
Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 16),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tambah Donatur Baru',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// Nama donatur
Text(
'Nama Donatur',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
TextFormField(
controller: namaController,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
hintText: 'Masukkan nama donatur',
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Nama donatur harus diisi';
}
return null;
},
),
const SizedBox(height: 16),
// Telepon
Text(
'Nomor HP',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
TextFormField(
controller: noHpController,
keyboardType: TextInputType.phone,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
hintText: 'Masukkan nomor HP',
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Nomor HP harus diisi';
}
return null;
},
),
const SizedBox(height: 16),
// Jenis (opsional)
Text(
'Jenis Donatur (Opsional)',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
),
hint: const Text('Pilih jenis donatur'),
value: jenisController.text.isEmpty
? null
: jenisController.text,
items: const [
DropdownMenuItem<String>(
value: 'Individu',
child: Text('Individu'),
),
DropdownMenuItem<String>(
value: 'Perusahaan',
child: Text('Perusahaan'),
),
DropdownMenuItem<String>(
value: 'Organisasi',
child: Text('Organisasi'),
),
],
onChanged: (value) {
if (value != null) {
jenisController.text = value;
}
},
),
const SizedBox(height: 16),
// Alamat (opsional)
Text(
'Alamat (Opsional)',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
TextFormField(
controller: alamatController,
maxLines: 2,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
hintText: 'Masukkan alamat',
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
),
),
const SizedBox(height: 16),
// Email (opsional)
Text(
'Email (Opsional)',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
TextFormField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
hintText: 'Masukkan email',
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
),
),
const SizedBox(height: 24),
// Tombol aksi
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Batal'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () async {
if (formKey.currentState!.validate()) {
final donaturId = await controller.tambahDonatur(
nama: namaController.text,
noHp: noHpController.text,
alamat: alamatController.text.isEmpty
? null
: alamatController.text,
email: emailController.text.isEmpty
? null
: emailController.text,
jenis: jenisController.text.isEmpty
? null
: jenisController.text,
);
if (donaturId != null) {
Get.back();
onDonaturAdded(donaturId);
Get.snackbar(
'Sukses',
'Donatur berhasil ditambahkan',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.green,
colorText: Colors.white,
);
}
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
),
child: const Text('Simpan'),
),
],
),
],
),
),
),
),
),
);
}
// Tambahkan widget untuk menampilkan waktu terakhir update
Widget _buildLastUpdateInfo(BuildContext context) {
return Obx(() {

View File

@ -5,9 +5,11 @@ import 'package:penyaluran_app/app/modules/petugas_desa/views/dashboard_view.dar
import 'package:penyaluran_app/app/modules/petugas_desa/views/penyaluran_view.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/views/notifikasi_view.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/views/stok_bantuan_view.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/views/riwayat_stok_view.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/views/penitipan_view.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/views/pengaduan_view.dart';
import 'package:penyaluran_app/app/theme/app_theme.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/controllers/riwayat_stok_controller.dart';
class PetugasDesaView extends GetView<PetugasDesaController> {
const PetugasDesaView({super.key});
@ -141,6 +143,27 @@ class PetugasDesaView extends GetView<PetugasDesaController> {
],
);
}
// Tampilkan tombol riwayat stok jika tab Stok Bantuan aktif
if (activeTab == 4) {
return Row(
children: [
IconButton(
onPressed: () {
// Navigasi ke halaman riwayat stok
if (!Get.isRegistered<RiwayatStokController>()) {
Get.put(RiwayatStokController());
}
Get.to(() => const RiwayatStokView());
},
icon: const Icon(Icons.history),
tooltip: 'Riwayat Stok',
),
notificationButton,
],
);
}
return notificationButton;
}),
],

View File

@ -0,0 +1,688 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:penyaluran_app/app/data/models/riwayat_stok_model.dart';
import 'package:penyaluran_app/app/data/models/stok_bantuan_model.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/controllers/riwayat_stok_controller.dart';
import 'package:penyaluran_app/app/theme/app_theme.dart';
import 'package:penyaluran_app/app/utils/date_time_helper.dart';
import 'package:cached_network_image/cached_network_image.dart';
class RiwayatStokView extends GetView<RiwayatStokController> {
const RiwayatStokView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Riwayat Stok Bantuan'),
//back button
leading: IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back),
),
),
body: RefreshIndicator(
onRefresh: controller.refreshData,
child: Obx(() => controller.isLoading.value
? const Center(child: CircularProgressIndicator())
: _buildContent(context)),
),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Tombol untuk mengurangi stok
FloatingActionButton.small(
onPressed: () => _showStokManualDialog(context, isAddition: false),
backgroundColor: Colors.red,
heroTag: 'kurangiStok',
child: const Icon(Icons.remove, color: Colors.white),
),
const SizedBox(height: 10),
// Tombol untuk menambah stok
FloatingActionButton(
onPressed: () => _showStokManualDialog(context, isAddition: true),
backgroundColor: AppTheme.primaryColor,
heroTag: 'tambahStok',
child: const Icon(Icons.add, color: Colors.white),
),
],
),
);
}
Widget _buildContent(BuildContext context) {
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Heading
Text(
'Riwayat Stok Bantuan',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
const SizedBox(height: 4),
Text(
'Catatan riwayat perubahan stok bantuan',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 16),
// Filter dan pencarian
_buildFilters(context),
const SizedBox(height: 16),
// Daftar riwayat stok
Obx(() {
final filteredRiwayat = controller.getFilteredRiwayatStok();
if (filteredRiwayat.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
children: [
const Icon(
Icons.history,
size: 64,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(
'Belum ada riwayat stok',
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
],
),
),
);
}
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: filteredRiwayat.length,
itemBuilder: (context, index) {
final riwayat = filteredRiwayat[index];
return _buildRiwayatItem(context, riwayat);
},
);
}),
],
),
),
);
}
Widget _buildFilters(BuildContext context) {
return Column(
children: [
// Pencarian
Container(
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: TextField(
controller: controller.searchController,
decoration: const InputDecoration(
hintText: 'Cari riwayat stok...',
prefixIcon: Icon(Icons.search),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(height: 16),
// Filter jenis perubahan
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Jenis Perubahan'),
const SizedBox(height: 4),
Container(
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Obx(() => DropdownButtonFormField<String>(
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding:
EdgeInsets.symmetric(horizontal: 12),
),
isExpanded: true,
value: controller.filterJenisPerubahan.value,
items: [
const DropdownMenuItem(
value: 'semua',
child: Text('Semua'),
),
const DropdownMenuItem(
value: 'penambahan',
child: Text('Penambahan'),
),
const DropdownMenuItem(
value: 'pengurangan',
child: Text('Pengurangan'),
),
],
onChanged: (value) {
if (value != null) {
controller.filterByJenisPerubahan(value);
}
},
)),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Jenis Bantuan'),
const SizedBox(height: 4),
Container(
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Obx(() => DropdownButtonFormField<String>(
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding:
EdgeInsets.symmetric(horizontal: 12),
),
isExpanded: true,
value: controller.filterStokBantuanId.value,
items: [
const DropdownMenuItem(
value: 'semua',
child: Text('Semua'),
),
...controller.daftarStokBantuan.map((stok) {
return DropdownMenuItem(
value: stok.id,
child: Text(stok.nama ?? '-'),
);
}).toList(),
],
onChanged: (value) {
if (value != null) {
controller.filterByStokBantuan(value);
}
},
)),
),
],
),
),
],
),
],
);
}
Widget _buildRiwayatItem(BuildContext context, RiwayatStokModel riwayat) {
final bool isPenambahan = riwayat.jenisPerubahan == 'penambahan';
final stokBantuanNama = riwayat.stokBantuan != null
? riwayat.stokBantuan!['nama'] ?? 'Tidak diketahui'
: 'Tidak diketahui';
final stokBantuanSatuan =
riwayat.stokBantuan != null ? riwayat.stokBantuan!['satuan'] ?? '' : '';
final sumberLabels = {
'penitipan': 'Penitipan',
'penyaluran': 'Penyaluran',
'manual': 'Manual',
};
final sumberLabel = sumberLabels[riwayat.sumber] ?? 'Tidak diketahui';
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: Jumlah dan waktu
Row(
children: [
// Icon & Jumlah
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: isPenambahan ? Colors.green[100] : Colors.red[100],
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isPenambahan ? Icons.add : Icons.remove,
color: isPenambahan ? Colors.green : Colors.red,
size: 14,
),
const SizedBox(width: 4),
Text(
'${riwayat.jumlah?.toStringAsFixed(0) ?? '0'} $stokBantuanSatuan',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isPenambahan ? Colors.green : Colors.red,
),
),
],
),
),
const Spacer(),
// Sumber
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(16),
),
child: Text(
sumberLabel,
style: TextStyle(
color: Colors.grey[700],
fontSize: 12,
),
),
),
const SizedBox(width: 8),
// Tanggal
Text(
riwayat.createdAt != null
? DateTimeHelper.formatDateTime(riwayat.createdAt!)
: '-',
style: TextStyle(
color: Colors.grey[600],
fontSize: 12,
),
),
],
),
const SizedBox(height: 12),
// Nama bantuan
Text(
stokBantuanNama,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
// Alasan jika ada
if (riwayat.alasan != null && riwayat.alasan!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Alasan: ${riwayat.alasan}',
style: Theme.of(context).textTheme.bodyMedium,
),
],
// Foto bukti jika ada
if (riwayat.fotoBukti != null && riwayat.fotoBukti!.isNotEmpty) ...[
const SizedBox(height: 8),
InkWell(
onTap: () => _showImageDialog(context, riwayat.fotoBukti!),
child: Row(
children: [
const Icon(
Icons.photo,
color: Colors.blue,
size: 20,
),
const SizedBox(width: 4),
Text(
'Lihat Bukti',
style: TextStyle(
color: Colors.blue,
),
),
],
),
),
],
// Petugas
if (riwayat.createdBy != null) ...[
const SizedBox(height: 12),
Row(
children: [
const Icon(
Icons.person,
size: 16,
color: Colors.grey,
),
const SizedBox(width: 4),
Text(
'Oleh: ${riwayat.createdBy!['nama_lengkap'] ?? 'Tidak diketahui'}',
style: TextStyle(
color: Colors.grey[600],
fontSize: 12,
),
),
],
),
],
],
),
),
);
}
void _showImageDialog(BuildContext context, String imageUrl) {
showDialog(
context: context,
builder: (BuildContext context) {
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
title: const Text('Bukti Foto'),
elevation: 0,
backgroundColor: AppTheme.primaryColor,
),
InteractiveViewer(
panEnabled: true,
boundaryMargin: const EdgeInsets.all(16),
minScale: 0.5,
maxScale: 4,
child: CachedNetworkImage(
imageUrl: imageUrl,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(),
),
errorWidget: (context, url, error) => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error),
const SizedBox(height: 8),
Text('Gagal memuat gambar: $error'),
],
),
fit: BoxFit.contain,
),
),
],
),
);
},
);
}
void _showStokManualDialog(BuildContext context, {required bool isAddition}) {
// Reset form
controller.resetForm();
showDialog(
context: context,
builder: (BuildContext context) {
return Dialog(
insetPadding: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Header
Row(
children: [
Icon(
isAddition ? Icons.add_circle : Icons.remove_circle,
color: isAddition ? Colors.green : Colors.red,
),
const SizedBox(width: 8),
Text(
isAddition
? 'Tambah Stok Manual'
: 'Kurangi Stok Manual',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 24),
// Form
// 1. Pilih Bantuan
Text(
'Pilih Bantuan',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Obx(() => DropdownButtonFormField<StokBantuanModel>(
isExpanded: true,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 12),
),
hint: const Text('Pilih bantuan'),
value: controller.selectedStokBantuan.value,
items: controller.daftarStokBantuan
.map((StokBantuanModel stok) {
return DropdownMenuItem<StokBantuanModel>(
value: stok,
child: Text(
'${stok.nama} (${stok.totalStok} ${stok.satuan})'),
);
}).toList(),
onChanged: (StokBantuanModel? value) {
controller.setSelectedStokBantuan(value);
},
)),
const SizedBox(height: 16),
// 2. Jumlah
Row(
children: [
Text(
'Jumlah',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(width: 8),
// Tampilkan satuan jika bantuan sudah dipilih
Obx(() => controller.selectedStokBantuan.value != null
? Text(
controller.selectedStokBantuan.value!.satuan ??
'',
style: TextStyle(
color: Colors.grey[600],
),
)
: const SizedBox.shrink()),
],
),
const SizedBox(height: 8),
TextFormField(
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 12),
),
keyboardType: TextInputType.number,
onChanged: (value) {
if (value.isNotEmpty) {
controller.setJumlah(double.parse(value));
} else {
controller.setJumlah(0);
}
},
),
const SizedBox(height: 16),
// 3. Alasan
Text(
'Alasan',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
TextFormField(
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 12),
hintText: 'Masukkan alasan perubahan stok',
),
maxLines: 2,
onChanged: (value) {
controller.setAlasan(value);
},
),
const SizedBox(height: 16),
// 4. Upload Bukti
Text(
'Foto Bukti',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
GestureDetector(
onTap: controller.pickImage,
child: Container(
height: 150,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[400]!),
),
child: Obx(() {
if (controller.fotoBukti.value != null) {
return Stack(
alignment: Alignment.center,
children: [
Image.file(
controller.fotoBukti.value!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
),
Positioned(
top: 8,
right: 8,
child: CircleAvatar(
radius: 16,
backgroundColor: Colors.red,
child: IconButton(
icon: const Icon(
Icons.delete,
size: 16,
color: Colors.white,
),
onPressed: () {
controller.fotoBukti.value = null;
},
),
),
),
],
);
} else {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.camera_alt,
size: 48,
color: Colors.grey,
),
const SizedBox(height: 8),
Text(
'Pilih Foto',
style: TextStyle(
color: Colors.grey[600],
),
),
],
);
}
}),
),
),
const SizedBox(height: 24),
// Button
SizedBox(
width: double.infinity,
child: Obx(() => ElevatedButton(
onPressed: controller.isSubmitting.value
? null
: () {
if (isAddition) {
controller.tambahStokManual();
} else {
controller.kurangiStokManual();
}
},
style: ElevatedButton.styleFrom(
backgroundColor:
isAddition ? Colors.green : Colors.red,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: controller.isSubmitting.value
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text(
isAddition ? 'Tambah Stok' : 'Kurangi Stok',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
)),
),
],
),
),
),
);
},
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:get/get.dart';
import 'package:penyaluran_app/app/modules/auth/views/login_view.dart';
import 'package:penyaluran_app/app/modules/auth/views/register_donatur_view.dart';
import 'package:penyaluran_app/app/modules/auth/bindings/auth_binding.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/views/petugas_desa_view.dart';
import 'package:penyaluran_app/app/modules/petugas_desa/bindings/petugas_desa_binding.dart';
@ -57,6 +58,11 @@ class AppPages {
page: () => const LoginView(),
binding: AuthBinding(),
),
GetPage(
name: _Paths.registerDonatur,
page: () => const RegisterDonaturView(),
binding: AuthBinding(),
),
GetPage(
name: Routes.wargaDashboard,
page: () => WargaView(),

View File

@ -5,6 +5,7 @@ abstract class Routes {
static const home = _Paths.home;
static const login = _Paths.login;
static const register = _Paths.register;
static const registerDonatur = _Paths.registerDonatur;
static const wargaDashboard = _Paths.wargaDashboard;
static const wargaPenerimaan = _Paths.wargaPenerimaan;
static const wargaPengaduan = _Paths.wargaPengaduan;
@ -49,6 +50,7 @@ abstract class _Paths {
static const home = '/home';
static const login = '/login';
static const register = '/register';
static const registerDonatur = '/register-donatur';
static const wargaDashboard = '/warga-dashboard';
static const wargaPenerimaan = '/warga-penerimaan';
static const wargaPengaduan = '/warga-pengaduan';

View File

@ -834,9 +834,30 @@ class SupabaseService extends GetxService {
}
final petugasDesaId = client.auth.currentUser?.id;
if (petugasDesaId == null) {
throw 'ID petugas desa tidak ditemukan';
}
print(
'Verifikasi penitipan dengan ID: $penitipanId oleh petugas desa ID: $petugasDesaId');
// 1. Dapatkan data penitipan untuk mendapatkan stok_bantuan_id dan jumlah
final response = await client
.from('penitipan_bantuan')
.select('stok_bantuan_id, jumlah')
.eq('id', penitipanId);
if (response == null || response.isEmpty) {
throw 'Data penitipan tidak ditemukan';
}
final penitipanData = response[0];
final String stokBantuanId = penitipanData['stok_bantuan_id'];
final double jumlah = penitipanData['jumlah'] is int
? penitipanData['jumlah'].toDouble()
: penitipanData['jumlah'];
// 2. Update status penitipan menjadi terverifikasi
final updateData = {
'status': 'TERVERIFIKASI',
'tanggal_verifikasi': DateTime.now().toIso8601String(),
@ -852,7 +873,11 @@ class SupabaseService extends GetxService {
.update(updateData)
.eq('id', penitipanId);
print('Penitipan berhasil diverifikasi dan data petugas desa disimpan');
// 3. Tambahkan ke stok dan catat di riwayat stok
await tambahStokDariPenitipan(
penitipanId, stokBantuanId, jumlah, petugasDesaId);
print('Penitipan berhasil diverifikasi dan stok bantuan ditambahkan');
} catch (e) {
print('Error verifying penitipan: $e');
throw e.toString();
@ -1591,6 +1616,12 @@ class SupabaseService extends GetxService {
String? buktiPenerimaan,
String? keterangan}) async {
try {
// Periksa petugas ID
final petugasId = client.auth.currentUser?.id;
if (petugasId == null) {
throw Exception('ID petugas tidak ditemukan');
}
final Map<String, dynamic> updateData = {
'status_penerimaan': status,
};
@ -1607,11 +1638,34 @@ class SupabaseService extends GetxService {
updateData['keterangan'] = keterangan;
}
// Update status penerimaan
await client
.from('penerima_penyaluran')
.update(updateData)
.eq('id', penerimaId);
// Jika status adalah DITERIMA, kurangi stok
if (status.toUpperCase() == 'DITERIMA') {
// Dapatkan data penerima penyaluran (stok_bantuan_id dan jumlah)
final penerimaData = await client
.from('penerima_penyaluran')
.select('penyaluran_bantuan_id, stok_bantuan_id, jumlah')
.eq('id', penerimaId)
.single();
if (penerimaData != null) {
final String penyaluranId = penerimaData['penyaluran_bantuan_id'];
final String stokBantuanId = penerimaData['stok_bantuan_id'];
final double jumlah = penerimaData['jumlah'] is int
? penerimaData['jumlah'].toDouble()
: penerimaData['jumlah'];
// Kurangi stok dan catat riwayat
await kurangiStokDariPenyaluran(
penyaluranId, stokBantuanId, jumlah, petugasId);
}
}
return true;
} catch (e) {
print('Error updating status penerimaan: $e');
@ -1840,4 +1894,286 @@ class SupabaseService extends GetxService {
throw e.toString();
}
}
// Riwayat Stok methods
Future<List<Map<String, dynamic>>?> getRiwayatStok(
{String? stokBantuanId, String? jenisPerubahan}) async {
try {
var filterString = '';
if (stokBantuanId != null) {
filterString += 'stok_bantuan_id.eq.$stokBantuanId';
}
if (jenisPerubahan != null) {
filterString += (filterString.isNotEmpty ? ',' : '') +
'jenis_perubahan.eq.$jenisPerubahan';
}
final response = await client.from('riwayat_stok').select('''
*,
stok_bantuan:stok_bantuan_id(*),
petugas_desa:created_by_id(*)
''').order('created_at', ascending: false);
var result = response;
if (filterString.isNotEmpty) {
// Menerapkan filter secara manual karena response sudah berupa List
result = result.where((item) {
if (stokBantuanId != null &&
item['stok_bantuan_id'] != stokBantuanId) {
return false;
}
if (jenisPerubahan != null &&
item['jenis_perubahan'] != jenisPerubahan) {
return false;
}
return true;
}).toList();
}
return result;
} catch (e) {
print('Error getting riwayat stok: $e');
return null;
}
}
// Metode untuk mencatat penambahan stok dari penitipan
Future<void> tambahStokDariPenitipan(String penitipanId, String stokBantuanId,
double jumlah, String petugasId) async {
try {
// 1. Update stok bantuan - tambahkan jumlah
final stokBantuanResponse = await client
.from('stok_bantuan')
.select('total_stok')
.eq('id', stokBantuanId)
.single();
// Konversi total_stok ke double terlepas dari apakah itu int atau double
double currentStok = 0.0;
if (stokBantuanResponse['total_stok'] != null) {
if (stokBantuanResponse['total_stok'] is int) {
currentStok = stokBantuanResponse['total_stok'].toDouble();
} else {
currentStok = stokBantuanResponse['total_stok'];
}
}
double newStok = currentStok + jumlah;
// Update stok bantuan
await client.from('stok_bantuan').update({
'total_stok': newStok,
'updated_at': DateTime.now().toIso8601String()
}).eq('id', stokBantuanId);
// 2. Catat riwayat penambahan
await client.from('riwayat_stok').insert({
'stok_bantuan_id': stokBantuanId,
'jenis_perubahan': 'penambahan',
'jumlah': jumlah,
'sumber': 'penitipan',
'id_referensi': penitipanId,
'created_by_id': petugasId,
'created_at': DateTime.now().toIso8601String()
});
print('Stok berhasil ditambahkan dari penitipan');
} catch (e) {
print('Error adding stok from penitipan: $e');
throw e; // Re-throw untuk penanganan di tingkat yang lebih tinggi
}
}
// Metode untuk mencatat pengurangan stok dari penyaluran
Future<void> kurangiStokDariPenyaluran(String penyaluranId,
String stokBantuanId, double jumlah, String petugasId) async {
try {
// 1. Update stok bantuan - kurangi jumlah
final stokBantuanResponse = await client
.from('stok_bantuan')
.select('total_stok')
.eq('id', stokBantuanId)
.single();
// Konversi total_stok ke double terlepas dari apakah itu int atau double
double currentStok = 0.0;
if (stokBantuanResponse['total_stok'] != null) {
if (stokBantuanResponse['total_stok'] is int) {
currentStok = stokBantuanResponse['total_stok'].toDouble();
} else {
currentStok = stokBantuanResponse['total_stok'];
}
}
// Validasi stok cukup
if (currentStok < jumlah) {
throw Exception('Stok tidak mencukupi untuk pengurangan');
}
double newStok = currentStok - jumlah;
// Update stok bantuan
await client.from('stok_bantuan').update({
'total_stok': newStok,
'updated_at': DateTime.now().toIso8601String()
}).eq('id', stokBantuanId);
// 2. Catat riwayat pengurangan
await client.from('riwayat_stok').insert({
'stok_bantuan_id': stokBantuanId,
'jenis_perubahan': 'pengurangan',
'jumlah': jumlah,
'sumber': 'penyaluran',
'id_referensi': penyaluranId,
'created_by_id': petugasId,
'created_at': DateTime.now().toIso8601String()
});
print('Stok berhasil dikurangi dari penyaluran');
} catch (e) {
print('Error reducing stok from penyaluran: $e');
throw e; // Re-throw untuk penanganan di tingkat yang lebih tinggi
}
}
// Metode untuk penambahan stok manual oleh petugas
Future<void> tambahStokManual({
required String stokBantuanId,
required double jumlah,
required String alasan,
required String fotoBuktiPath,
required String petugasId,
}) async {
try {
// 1. Upload foto bukti jika disediakan
String fotoBuktiUrl = '';
if (fotoBuktiPath.isNotEmpty) {
final String fileName =
'${DateTime.now().millisecondsSinceEpoch}_${stokBantuanId}.jpg';
final fileResponse = await client.storage.from('stok_bukti').upload(
fileName,
File(fotoBuktiPath),
fileOptions:
const FileOptions(cacheControl: '3600', upsert: false),
);
fotoBuktiUrl = client.storage.from('stok_bukti').getPublicUrl(fileName);
}
// 2. Update stok bantuan - tambahkan jumlah
final stokBantuanResponse = await client
.from('stok_bantuan')
.select('total_stok')
.eq('id', stokBantuanId)
.single();
// Konversi total_stok ke double terlepas dari apakah itu int atau double
double currentStok = 0.0;
if (stokBantuanResponse['total_stok'] != null) {
if (stokBantuanResponse['total_stok'] is int) {
currentStok = stokBantuanResponse['total_stok'].toDouble();
} else {
currentStok = stokBantuanResponse['total_stok'];
}
}
double newStok = currentStok + jumlah;
// Update stok bantuan
await client.from('stok_bantuan').update({
'total_stok': newStok,
'updated_at': DateTime.now().toIso8601String()
}).eq('id', stokBantuanId);
// 3. Catat riwayat penambahan
await client.from('riwayat_stok').insert({
'stok_bantuan_id': stokBantuanId,
'jenis_perubahan': 'penambahan',
'jumlah': jumlah,
'sumber': 'manual',
'alasan': alasan,
'foto_bukti': fotoBuktiUrl,
'created_by_id': petugasId,
'created_at': DateTime.now().toIso8601String()
});
print('Stok berhasil ditambahkan secara manual');
} catch (e) {
print('Error adding stok manually: $e');
throw e; // Re-throw untuk penanganan di tingkat yang lebih tinggi
}
}
// Metode untuk pengurangan stok manual oleh petugas
Future<void> kurangiStokManual({
required String stokBantuanId,
required double jumlah,
required String alasan,
required String fotoBuktiPath,
required String petugasId,
}) async {
try {
// 1. Validasi stok yang tersedia
final stokBantuanResponse = await client
.from('stok_bantuan')
.select('total_stok')
.eq('id', stokBantuanId)
.single();
// Konversi total_stok ke double terlepas dari apakah itu int atau double
double currentStok = 0.0;
if (stokBantuanResponse['total_stok'] != null) {
if (stokBantuanResponse['total_stok'] is int) {
currentStok = stokBantuanResponse['total_stok'].toDouble();
} else {
currentStok = stokBantuanResponse['total_stok'];
}
}
// Validasi stok cukup
if (currentStok < jumlah) {
throw Exception('Stok tidak mencukupi untuk pengurangan');
}
// 2. Upload foto bukti jika disediakan
String fotoBuktiUrl = '';
if (fotoBuktiPath.isNotEmpty) {
final String fileName =
'${DateTime.now().millisecondsSinceEpoch}_${stokBantuanId}.jpg';
final fileResponse = await client.storage.from('stok_bukti').upload(
fileName,
File(fotoBuktiPath),
fileOptions:
const FileOptions(cacheControl: '3600', upsert: false),
);
fotoBuktiUrl = client.storage.from('stok_bukti').getPublicUrl(fileName);
}
// 3. Update stok bantuan - kurangi jumlah
double newStok = currentStok - jumlah;
// Update stok bantuan
await client.from('stok_bantuan').update({
'total_stok': newStok,
'updated_at': DateTime.now().toIso8601String()
}).eq('id', stokBantuanId);
// 4. Catat riwayat pengurangan
await client.from('riwayat_stok').insert({
'stok_bantuan_id': stokBantuanId,
'jenis_perubahan': 'pengurangan',
'jumlah': jumlah,
'sumber': 'manual',
'alasan': alasan,
'foto_bukti': fotoBuktiUrl,
'created_by_id': petugasId,
'created_at': DateTime.now().toIso8601String()
});
print('Stok berhasil dikurangi secara manual');
} catch (e) {
print('Error reducing stok manually: $e');
throw e; // Re-throw untuk penanganan di tingkat yang lebih tinggi
}
}
}