From c9587758c63145db0d8c3e8421893d67c8b4a83a Mon Sep 17 00:00:00 2001 From: Khafidh Fuadi Date: Sun, 16 Mar 2025 22:15:45 +0700 Subject: [PATCH] Tambahkan dependensi baru timeline_tile versi 2.0.0 ke dalam pubspec.yaml dan pubspec.lock. Perbarui model PengaduanModel dan TindakanPengaduanModel untuk mendukung struktur data yang lebih kompleks, termasuk penambahan properti baru. Modifikasi PengaduanController untuk menggunakan metode baru dalam mengambil data pengaduan dengan detail penerima penyaluran. Perbarui tampilan di PengaduanView untuk meningkatkan pengalaman pengguna dengan menampilkan informasi penyaluran bantuan yang lebih lengkap. --- .../arm64-v8a/configure_fingerprint.bin | 24 +- .../armeabi-v7a/configure_fingerprint.bin | 24 +- .../626b5o2n/x86/configure_fingerprint.bin | 24 +- .../626b5o2n/x86_64/configure_fingerprint.bin | 24 +- lib/app/data/models/pengaduan_model.dart | 74 +- .../data/models/tindakan_pengaduan_model.dart | 119 +- .../bindings/pengaduan_binding.dart | 11 + .../controllers/pengaduan_controller.dart | 92 +- .../views/detail_pengaduan_view.dart | 2348 +++++++++++++++++ .../petugas_desa/views/pengaduan_view.dart | 534 ++-- .../warga_dashboard_controller.dart | 51 +- .../warga/views/detail_pengaduan_view.dart | 449 ++++ .../warga/views/warga_dashboard_view.dart | 4 +- .../warga/views/warga_pengaduan_view.dart | 71 +- lib/app/routes/app_pages.dart | 13 + lib/app/routes/app_routes.dart | 4 + lib/app/services/supabase_service.dart | 63 +- lib/app/widgets/section_header.dart | 2 +- pubspec.lock | 8 + pubspec.yaml | 1 + 20 files changed, 3572 insertions(+), 368 deletions(-) create mode 100644 lib/app/modules/petugas_desa/bindings/pengaduan_binding.dart create mode 100644 lib/app/modules/petugas_desa/views/detail_pengaduan_view.dart create mode 100644 lib/app/modules/warga/views/detail_pengaduan_view.dart diff --git a/android/app/.cxx/Debug/626b5o2n/arm64-v8a/configure_fingerprint.bin b/android/app/.cxx/Debug/626b5o2n/arm64-v8a/configure_fingerprint.bin index 6f10174..209c1bb 100644 --- a/android/app/.cxx/Debug/626b5o2n/arm64-v8a/configure_fingerprint.bin +++ b/android/app/.cxx/Debug/626b5o2n/arm64-v8a/configure_fingerprint.bin @@ -2,27 +2,27 @@ C/C++ Structured LogO M KC:\dev\flutter\packages\flutter_tools\gradle\src\main\groovy\CMakeLists.txtC A -?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  2 2 +?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  2 2  -}D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\additional_project_files.txt  2  2~ +}D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\additional_project_files.txt  2  2~ | -zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\android_gradle_build.json  2 2 +zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\android_gradle_build.json  2 2  -D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\android_gradle_build_mini.json  2 2p +D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\android_gradle_build_mini.json  2 2p n -lD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build.ninja  2 2t +lD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build.ninja  2 2t r -pD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build.ninja.txt  2y +pD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build.ninja.txt  2y w -uD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build_file_index.txt  2 K 2z +uD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\build_file_index.txt  2 K 2z x -vD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\compile_commands.json  2 ~ +vD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\compile_commands.json  2 ~ | -zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\compile_commands.json.bin  2 +zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\compile_commands.json.bin  2   -D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\metadata_generation_command.txt  2  2w +D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\metadata_generation_command.txt  2  2w u -sD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\prefab_config.json  2  ( 2| +sD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\prefab_config.json  2  ( 2| z -xD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\symbol_folder_index.txt  2  o 2 \ No newline at end of file +xD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\arm64-v8a\symbol_folder_index.txt  2  o 2 \ No newline at end of file diff --git a/android/app/.cxx/Debug/626b5o2n/armeabi-v7a/configure_fingerprint.bin b/android/app/.cxx/Debug/626b5o2n/armeabi-v7a/configure_fingerprint.bin index 4919f33..135389f 100644 --- a/android/app/.cxx/Debug/626b5o2n/armeabi-v7a/configure_fingerprint.bin +++ b/android/app/.cxx/Debug/626b5o2n/armeabi-v7a/configure_fingerprint.bin @@ -2,27 +2,27 @@ C/C++ Structured LogO M KC:\dev\flutter\packages\flutter_tools\gradle\src\main\groovy\CMakeLists.txtC A -?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  2 2 +?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  2 2  -D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\additional_project_files.txt  2  2 +D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\additional_project_files.txt  2  2 ~ -|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\android_gradle_build.json  2 2 +|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\android_gradle_build.json  2 2  -D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\android_gradle_build_mini.json  2 2r +D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\android_gradle_build_mini.json  2 2r p -nD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build.ninja  2 2v +nD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build.ninja  2 2v t -rD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build.ninja.txt  2{ +rD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build.ninja.txt  2{ y -wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build_file_index.txt  2 K 2| +wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\build_file_index.txt  2 K 2| z -xD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\compile_commands.json  2  +xD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\compile_commands.json  2  ~ -|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\compile_commands.json.bin  2 +|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\compile_commands.json.bin  2   -D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\metadata_generation_command.txt  2  2y +D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\metadata_generation_command.txt  2  2y w -uD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\prefab_config.json  2  ( 2~ +uD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\prefab_config.json  2  ( 2~ | -zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\symbol_folder_index.txt  2  q 2 \ No newline at end of file +zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\armeabi-v7a\symbol_folder_index.txt  2  q 2 \ No newline at end of file diff --git a/android/app/.cxx/Debug/626b5o2n/x86/configure_fingerprint.bin b/android/app/.cxx/Debug/626b5o2n/x86/configure_fingerprint.bin index 272cd52..37d37e5 100644 --- a/android/app/.cxx/Debug/626b5o2n/x86/configure_fingerprint.bin +++ b/android/app/.cxx/Debug/626b5o2n/x86/configure_fingerprint.bin @@ -2,27 +2,27 @@ C/C++ Structured LogO M KC:\dev\flutter\packages\flutter_tools\gradle\src\main\groovy\CMakeLists.txtC A -?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  2 2{ +?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  2 2{ y -wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\additional_project_files.txt  2  2x +wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\additional_project_files.txt  2  2x v -tD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\android_gradle_build.json  2 2} +tD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\android_gradle_build.json  2 2} { -yD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\android_gradle_build_mini.json  ů2 2j +yD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\android_gradle_build_mini.json  2 2j h -fD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build.ninja  ů2 2n +fD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build.ninja  2 2n l -jD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build.ninja.txt  ů2s +jD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build.ninja.txt  2s q -oD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build_file_index.txt  ů2 K 2t +oD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\build_file_index.txt  2 K 2t r -pD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\compile_commands.json  ϯ2 x +pD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\compile_commands.json  2 x v -tD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\compile_commands.json.bin  ϯ2 +tD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\compile_commands.json.bin  2 ~ | -zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\metadata_generation_command.txt  ϯ2  2q +zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\metadata_generation_command.txt  2  2q o -mD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\prefab_config.json  ϯ2  ( 2v +mD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\prefab_config.json  2  ( 2v t -rD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\symbol_folder_index.txt  կ2  i 2 \ No newline at end of file +rD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86\symbol_folder_index.txt  2  i 2 \ No newline at end of file diff --git a/android/app/.cxx/Debug/626b5o2n/x86_64/configure_fingerprint.bin b/android/app/.cxx/Debug/626b5o2n/x86_64/configure_fingerprint.bin index a4de62a..fd9b8b4 100644 --- a/android/app/.cxx/Debug/626b5o2n/x86_64/configure_fingerprint.bin +++ b/android/app/.cxx/Debug/626b5o2n/x86_64/configure_fingerprint.bin @@ -2,27 +2,27 @@ C/C++ Structured LogO M KC:\dev\flutter\packages\flutter_tools\gradle\src\main\groovy\CMakeLists.txtC A -?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  ̱2 2~ +?com.android.build.gradle.internal.cxx.io.EncodedFileFingerPrint  2 2~ | -zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\additional_project_files.txt  ̱2  2{ +zD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\additional_project_files.txt  2  2{ y -wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\android_gradle_build.json  ̱2 2 +wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\android_gradle_build.json  2 2 ~ -|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\android_gradle_build_mini.json  ̱2 2m +|D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\android_gradle_build_mini.json  2 2m k -iD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build.ninja  ̱2 2q +iD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build.ninja  2 2q o -mD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build.ninja.txt  ̱2v +mD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build.ninja.txt  2v t -rD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build_file_index.txt  ̱2 K 2w +rD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\build_file_index.txt  2 K 2w u -sD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\compile_commands.json  ̱2 { +sD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\compile_commands.json  2 { y -wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\compile_commands.json.bin  ̱2 +wD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\compile_commands.json.bin  2   -}D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\metadata_generation_command.txt  ̱2  2t +}D:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\metadata_generation_command.txt  2  2t r -pD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\prefab_config.json  ̱2  ( 2y +pD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\prefab_config.json  2  ( 2y w -uD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\symbol_folder_index.txt  ̱2  l 2 \ No newline at end of file +uD:\KULIAH\Matkul\SKRIPSI\penyaluran_app\penyaluran_app\android\app\.cxx\Debug\626b5o2n\x86_64\symbol_folder_index.txt  2  l 2 \ No newline at end of file diff --git a/lib/app/data/models/pengaduan_model.dart b/lib/app/data/models/pengaduan_model.dart index 924c58e..5a50127 100644 --- a/lib/app/data/models/pengaduan_model.dart +++ b/lib/app/data/models/pengaduan_model.dart @@ -5,26 +5,28 @@ class PengaduanModel { final String? judul; final String? deskripsi; final String? status; - final String? kategori; - final String? pelapor; - final String? kontakPelapor; - final List? gambarUrls; + final String? wargaId; + final List? fotoPengaduan; + final String? penerimaPenyaluranId; final DateTime? tanggalPengaduan; final DateTime? createdAt; final DateTime? updatedAt; + final Map? penerimaPenyaluran; + final Map? warga; PengaduanModel({ this.id, this.judul, this.deskripsi, this.status, - this.kategori, - this.pelapor, - this.kontakPelapor, - this.gambarUrls, + this.wargaId, + this.fotoPengaduan, + this.penerimaPenyaluranId, this.tanggalPengaduan, this.createdAt, this.updatedAt, + this.penerimaPenyaluran, + this.warga, }); factory PengaduanModel.fromRawJson(String str) => @@ -37,12 +39,9 @@ class PengaduanModel { judul: json["judul"], deskripsi: json["deskripsi"], status: json["status"], - kategori: json["kategori"], - pelapor: json["pelapor"], - kontakPelapor: json["kontak_pelapor"], - gambarUrls: json["gambar_urls"] == null - ? null - : List.from(json["gambar_urls"].map((x) => x)), + wargaId: json["warga_id"], + fotoPengaduan: json["foto_pengaduan"], + penerimaPenyaluranId: json["penerima_penyaluran_id"], tanggalPengaduan: json["tanggal_pengaduan"] != null ? DateTime.parse(json["tanggal_pengaduan"]) : null, @@ -52,6 +51,8 @@ class PengaduanModel { updatedAt: json["updated_at"] != null ? DateTime.parse(json["updated_at"]) : null, + penerimaPenyaluran: json["penerima_penyaluran"], + warga: json["warga"], ); Map toJson() => { @@ -59,14 +60,47 @@ class PengaduanModel { "judul": judul, "deskripsi": deskripsi, "status": status, - "kategori": kategori, - "pelapor": pelapor, - "kontak_pelapor": kontakPelapor, - "gambar_urls": gambarUrls == null - ? null - : List.from(gambarUrls!.map((x) => x)), + "warga_id": wargaId, + "foto_pengaduan": fotoPengaduan, + "penerima_penyaluran_id": penerimaPenyaluranId, "tanggal_pengaduan": tanggalPengaduan?.toIso8601String(), "created_at": createdAt?.toIso8601String(), "updated_at": updatedAt?.toIso8601String(), + "penerima_penyaluran": penerimaPenyaluran, + "warga": warga, }; + + // Getter untuk mendapatkan informasi penyaluran bantuan + Map? get penyaluranBantuan { + return penerimaPenyaluran?['penyaluran_bantuan']; + } + + // Getter untuk mendapatkan informasi stok bantuan + Map? get stokBantuan { + return penerimaPenyaluran?['stok_bantuan']; + } + + // Getter untuk mendapatkan nama penyaluran + String get namaPenyaluran { + return penyaluranBantuan?['nama'] ?? 'Tidak ada data'; + } + + // Getter untuk mendapatkan deskripsi penyaluran + String get deskripsiPenyaluran { + return penyaluranBantuan?['deskripsi'] ?? 'Tidak ada deskripsi'; + } + + // Getter untuk mendapatkan jenis bantuan + String get jenisBantuan { + return stokBantuan?['kategori_bantuan']?['nama'] ?? 'Tidak diketahui'; + } + + // Getter untuk mendapatkan jumlah bantuan yang diterima + String get jumlahBantuan { + final jumlah = penerimaPenyaluran?['jumlah_bantuan']; + final satuan = penerimaPenyaluran?['satuan'] ?? ''; + + if (jumlah == null) return 'Tidak diketahui'; + return '$jumlah $satuan'; + } } diff --git a/lib/app/data/models/tindakan_pengaduan_model.dart b/lib/app/data/models/tindakan_pengaduan_model.dart index db73099..b23d319 100644 --- a/lib/app/data/models/tindakan_pengaduan_model.dart +++ b/lib/app/data/models/tindakan_pengaduan_model.dart @@ -5,22 +5,46 @@ class TindakanPengaduanModel { final String? pengaduanId; final String? tindakan; final String? catatan; - final String? status; + final String? statusTindakan; // PROSES, SELESAI + final String? prioritas; // RENDAH, SEDANG, TINGGI + final String? kategoriTindakan; // Kategori tindakan enum final String? petugasId; + final String? verifikatorId; + final String? hasilTindakan; + final List? buktiTindakan; + final DateTime? estimasiSelesai; final DateTime? tanggalTindakan; + final DateTime? tanggalVerifikasi; final DateTime? createdAt; final DateTime? updatedAt; + final double? biayaTindakan; + final String? feedbackWarga; + final int? ratingWarga; + final Map? petugas; // Data petugas yang melakukan tindakan + final Map? verifikator; // Data petugas yang memverifikasi TindakanPengaduanModel({ this.id, this.pengaduanId, this.tindakan, this.catatan, - this.status, + this.statusTindakan, + this.prioritas, + this.kategoriTindakan, this.petugasId, + this.verifikatorId, + this.hasilTindakan, + this.buktiTindakan, + this.estimasiSelesai, this.tanggalTindakan, + this.tanggalVerifikasi, this.createdAt, this.updatedAt, + this.biayaTindakan, + this.feedbackWarga, + this.ratingWarga, + this.petugas, + this.verifikator, }); factory TindakanPengaduanModel.fromRawJson(String str) => @@ -34,17 +58,35 @@ class TindakanPengaduanModel { pengaduanId: json["pengaduan_id"], tindakan: json["tindakan"], catatan: json["catatan"], - status: json["status"], + statusTindakan: json["status_tindakan"], + prioritas: json["prioritas"], + kategoriTindakan: json["kategori_tindakan"], petugasId: json["petugas_id"], + verifikatorId: json["verifikator_id"], + hasilTindakan: json["hasil_tindakan"], + buktiTindakan: json["bukti_tindakan"], + estimasiSelesai: json["estimasi_selesai"] != null + ? DateTime.parse(json["estimasi_selesai"]) + : null, tanggalTindakan: json["tanggal_tindakan"] != null ? DateTime.parse(json["tanggal_tindakan"]) : null, + tanggalVerifikasi: json["tanggal_verifikasi"] != null + ? DateTime.parse(json["tanggal_verifikasi"]) + : null, createdAt: json["created_at"] != null ? DateTime.parse(json["created_at"]) : null, updatedAt: json["updated_at"] != null ? DateTime.parse(json["updated_at"]) : null, + biayaTindakan: json["biaya_tindakan"] != null + ? double.parse(json["biaya_tindakan"].toString()) + : null, + feedbackWarga: json["feedback_warga"], + ratingWarga: json["rating_warga"], + petugas: json["petugas"], + verifikator: json["verifikator"], ); Map toJson() => { @@ -52,10 +94,79 @@ class TindakanPengaduanModel { "pengaduan_id": pengaduanId, "tindakan": tindakan, "catatan": catatan, - "status": status, + "status_tindakan": statusTindakan, + "prioritas": prioritas, + "kategori_tindakan": kategoriTindakan, "petugas_id": petugasId, + "verifikator_id": verifikatorId, + "hasil_tindakan": hasilTindakan, + "bukti_tindakan": buktiTindakan, + "estimasi_selesai": estimasiSelesai?.toIso8601String(), "tanggal_tindakan": tanggalTindakan?.toIso8601String(), + "tanggal_verifikasi": tanggalVerifikasi?.toIso8601String(), "created_at": createdAt?.toIso8601String(), "updated_at": updatedAt?.toIso8601String(), + "biaya_tindakan": biayaTindakan, + "feedback_warga": feedbackWarga, + "rating_warga": ratingWarga, + "petugas": petugas, + "verifikator": verifikator, }; + + // Getter untuk mendapatkan nama petugas + String get namaPetugas { + if (petugas != null && petugas!['nama'] != null) { + return petugas!['nama']; + } else if (petugas != null && petugas!['name'] != null) { + return petugas!['name']; + } + return 'Petugas'; + } + + // Getter untuk mendapatkan nama verifikator + String get namaVerifikator { + if (verifikator != null && verifikator!['nama'] != null) { + return verifikator!['nama']; + } else if (verifikator != null && verifikator!['name'] != null) { + return verifikator!['name']; + } + return 'Belum diverifikasi'; + } + + // Getter untuk mendapatkan status tindakan yang lebih user-friendly + String get statusTindakanText { + switch (statusTindakan) { + case 'PROSES': + return 'Dalam Proses'; + case 'SELESAI': + return 'Selesai'; + default: + return statusTindakan ?? 'Tidak Diketahui'; + } + } + + // Getter untuk mendapatkan prioritas yang lebih user-friendly + String get prioritasText { + switch (prioritas) { + case 'RENDAH': + return 'Prioritas Rendah'; + case 'SEDANG': + return 'Prioritas Sedang'; + case 'TINGGI': + return 'Prioritas Tinggi'; + default: + return prioritas ?? 'Tidak Diketahui'; + } + } + + // Getter untuk mendapatkan kategori tindakan yang lebih user-friendly + String get kategoriTindakanText { + if (kategoriTindakan == null) return 'Tidak Diketahui'; + + // Mengubah format SNAKE_CASE menjadi Title Case + return kategoriTindakan! + .split('_') + .map((word) => word[0].toUpperCase() + word.substring(1).toLowerCase()) + .join(' '); + } } diff --git a/lib/app/modules/petugas_desa/bindings/pengaduan_binding.dart b/lib/app/modules/petugas_desa/bindings/pengaduan_binding.dart new file mode 100644 index 0000000..1752f2a --- /dev/null +++ b/lib/app/modules/petugas_desa/bindings/pengaduan_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import 'package:penyaluran_app/app/modules/petugas_desa/controllers/pengaduan_controller.dart'; + +class PengaduanBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut( + () => PengaduanController(), + ); + } +} diff --git a/lib/app/modules/petugas_desa/controllers/pengaduan_controller.dart b/lib/app/modules/petugas_desa/controllers/pengaduan_controller.dart index 2a1d545..87c84dd 100644 --- a/lib/app/modules/petugas_desa/controllers/pengaduan_controller.dart +++ b/lib/app/modules/petugas_desa/controllers/pengaduan_controller.dart @@ -48,14 +48,15 @@ class PengaduanController extends GetxController { Future loadPengaduanData() async { isLoading.value = true; try { - final pengaduanData = await _supabaseService.getPengaduan(); + final pengaduanData = + await _supabaseService.getPengaduanWithPenerimaPenyaluran(); if (pengaduanData != null) { daftarPengaduan.value = pengaduanData.map((data) => PengaduanModel.fromJson(data)).toList(); // Hitung jumlah berdasarkan status jumlahDiproses.value = - daftarPengaduan.where((item) => item.status == 'DIPROSES').length; + daftarPengaduan.where((item) => item.status == 'MENUNGGU').length; jumlahTindakan.value = daftarPengaduan.where((item) => item.status == 'TINDAKAN').length; jumlahSelesai.value = @@ -138,6 +139,36 @@ class PengaduanController extends GetxController { } } + Future updateTindakan( + String tindakanId, Map data) async { + isLoading.value = true; + try { + await _supabaseService.updateTindakanPengaduan(tindakanId, data); + + Get.snackbar( + 'Sukses', + 'Tindakan berhasil diperbarui', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + + // Refresh data + Get.forceAppUpdate(); + } catch (e) { + print('Error updating tindakan: $e'); + Get.snackbar( + 'Error', + 'Gagal memperbarui tindakan: ${e.toString()}', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + isLoading.value = false; + } + } + Future selesaikanPengaduan(String pengaduanId) async { isLoading.value = true; try { @@ -164,6 +195,25 @@ class PengaduanController extends GetxController { } } + Future updateStatusPengaduan(String pengaduanId, String status) async { + isLoading.value = true; + try { + await _supabaseService.updateStatusPengaduan(pengaduanId, status); + await loadPengaduanData(); + } catch (e) { + print('Error updating pengaduan status: $e'); + Get.snackbar( + 'Error', + 'Gagal mengubah status pengaduan: ${e.toString()}', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + isLoading.value = false; + } + } + Future> getTindakanPengaduan( String pengaduanId) async { try { @@ -200,7 +250,7 @@ class PengaduanController extends GetxController { return daftarPengaduan; case 1: return daftarPengaduan - .where((item) => item.status == 'DIPROSES') + .where((item) => item.status == 'MENUNGGU') .toList(); case 2: return daftarPengaduan @@ -222,4 +272,40 @@ class PengaduanController extends GetxController { } return null; } + + Future> getDetailPengaduan(String pengaduanId) async { + try { + // Ambil data pengaduan + final pengaduanData = + await _supabaseService.client.from('pengaduan').select(''' + *, + penerima_penyaluran:penerima_penyaluran_id( + *, + penyaluran_bantuan:penyaluran_bantuan_id(*), + stok_bantuan:stok_bantuan_id(*), + warga:warga_id(*) + ), + warga:warga_id(*) + ''').eq('id', pengaduanId).single(); + + // Ambil data tindakan pengaduan + final tindakanData = + await _supabaseService.getTindakanPengaduan(pengaduanId); + print(tindakanData); + + // Gabungkan data + final result = { + 'pengaduan': pengaduanData, + 'tindakan': tindakanData ?? [], + }; + + return result; + } catch (e) { + print('Error getting detail pengaduan: $e'); + return { + 'pengaduan': null, + 'tindakan': [], + }; + } + } } diff --git a/lib/app/modules/petugas_desa/views/detail_pengaduan_view.dart b/lib/app/modules/petugas_desa/views/detail_pengaduan_view.dart new file mode 100644 index 0000000..dbb9585 --- /dev/null +++ b/lib/app/modules/petugas_desa/views/detail_pengaduan_view.dart @@ -0,0 +1,2348 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:penyaluran_app/app/data/models/pengaduan_model.dart'; +import 'package:penyaluran_app/app/data/models/tindakan_pengaduan_model.dart'; +import 'package:penyaluran_app/app/modules/petugas_desa/controllers/pengaduan_controller.dart'; +import 'package:penyaluran_app/app/theme/app_theme.dart'; +import 'package:penyaluran_app/app/widgets/cards/info_card.dart'; +import 'package:penyaluran_app/app/widgets/indicators/status_pill.dart'; +import 'package:penyaluran_app/app/widgets/section_header.dart'; +import 'package:penyaluran_app/app/services/supabase_service.dart'; +import 'package:timeline_tile/timeline_tile.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'dart:io'; + +class DetailPengaduanView extends GetView { + const DetailPengaduanView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final Map args = Get.arguments ?? {}; + final String pengaduanId = args['id'] ?? ''; + + if (pengaduanId.isEmpty) { + return Scaffold( + appBar: AppBar( + title: const Text('Detail Pengaduan'), + ), + body: const Center( + child: Text('ID Pengaduan tidak valid'), + ), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Detail Pengaduan'), + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.help_outline), + onPressed: () { + showDialog( + context: context, + builder: (context) => Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 16), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Panduan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + _buildStatusGuide(context), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Tutup'), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ], + ), + body: FutureBuilder>( + future: controller.getDetailPengaduan(pengaduanId), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Text('Error: ${snapshot.error}'), + ); + } + + final data = snapshot.data; + if (data == null || data['pengaduan'] == null) { + return const Center( + child: Text('Data pengaduan tidak ditemukan'), + ); + } + + final pengaduan = PengaduanModel.fromJson(data['pengaduan']); + final List tindakanList = + (data['tindakan'] as List) + .map((item) => TindakanPengaduanModel.fromJson(item)) + .toList(); + + return _buildDetailContent(context, pengaduan, tindakanList); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + _showTambahTindakanDialog(context, pengaduanId); + }, + backgroundColor: AppTheme.primaryColor, + child: const Icon(Icons.add), + ), + ); + } + + Widget _buildDetailContent( + BuildContext context, + PengaduanModel pengaduan, + List tindakanList, + ) { + // Tentukan status dan warna + Color statusColor; + String statusText; + + switch (pengaduan.status?.toUpperCase()) { + case 'MENUNGGU': + statusColor = Colors.orange; + statusText = 'Menunggu'; + break; + case 'TINDAKAN': + statusColor = Colors.blue; + statusText = 'Tindakan'; + break; + case 'SELESAI': + statusColor = Colors.green; + statusText = 'Selesai'; + break; + default: + statusColor = Colors.grey; + statusText = pengaduan.status ?? 'Tidak Diketahui'; + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header dengan status + _buildHeaderWithStatus(context, pengaduan, statusColor, statusText), + + const SizedBox(height: 24), + + // Informasi pengaduan + _buildPengaduanInfo(context, pengaduan), + + const SizedBox(height: 24), + + // Informasi penyaluran yang diadukan + if (pengaduan.penerimaPenyaluran != null) + _buildPenyaluranInfo(context, pengaduan), + + const SizedBox(height: 24), + + // Timeline tindakan + _buildTindakanTimeline(context, tindakanList), + ], + ), + ); + } + + Widget _buildStatusGuide(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + color: Colors.blue, + size: 20, + ), + const SizedBox(width: 8), + const Text( + 'Alur Status Pengaduan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + _buildStatusGuideItem( + 'MENUNGGU', + 'Pengaduan baru yang belum ditindaklanjuti', + Colors.orange, + Icons.hourglass_empty, + ), + const SizedBox(height: 8), + _buildStatusGuideItem( + 'TINDAKAN', + 'Pengaduan sedang dalam proses penanganan', + Colors.blue, + Icons.engineering, + ), + const SizedBox(height: 8), + _buildStatusGuideItem( + 'SELESAI', + 'Pengaduan telah selesai ditangani', + Colors.green, + Icons.check_circle, + ), + ], + ), + ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.info_outline, + color: Colors.blue, + size: 20, + ), + const SizedBox(width: 8), + const Text( + 'Status Tindakan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 12), + Column( + children: [ + _buildTindakanStatusItem( + 'PROSES', + 'Dalam Proses', + 'Tindakan sedang dilakukan', + Colors.blue, + Icons.sync, + ), + const SizedBox(height: 8), + _buildTindakanStatusItem( + 'SELESAI', + 'Selesai', + 'Tindakan telah selesai', + Colors.green, + Icons.check_circle, + ), + ], + ), + ], + ); + } + + Widget _buildStatusGuideItem( + String status, + String description, + Color color, + IconData icon, + ) { + return Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: Center( + child: Icon( + icon, + color: color, + size: 18, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Menggunakan StatusPill untuk status + StatusPill( + status: status, + backgroundColor: color, + textColor: Colors.white, + ), + const SizedBox(height: 4), + Text( + description, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildTindakanStatusItem( + String status, + String label, + String description, + Color color, + IconData icon, + ) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + icon, + color: color, + size: 16, + ), + const SizedBox(width: 8), + // Menggunakan StatusPill untuk status tindakan + StatusPill( + status: label, + backgroundColor: color, + textColor: Colors.white, + ), + ], + ), + const SizedBox(height: 4), + Text( + description, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + ), + ), + ], + ), + ); + } + + Widget _buildHeaderWithStatus( + BuildContext context, + PengaduanModel pengaduan, + Color statusColor, + String statusText, + ) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + pengaduan.judul ?? 'Pengaduan', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + // Menggunakan StatusPill untuk menampilkan status + _getStatusPill(pengaduan.status), + ], + ), + const SizedBox(height: 12), + Text( + pengaduan.deskripsi ?? '', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.calendar_today, + size: 16, + color: Colors.grey.shade600, + ), + const SizedBox(width: 8), + Text( + pengaduan.tanggalPengaduan != null + ? DateFormat('dd MMMM yyyy', 'id_ID') + .format(pengaduan.tanggalPengaduan!) + : '-', + style: TextStyle( + color: Colors.grey.shade600, + ), + ), + ], + ), + const SizedBox(height: 16), + // Panel status pengaduan + _buildStatusPanel(context, pengaduan), + ], + ), + ), + ); + } + + // Helper method untuk mendapatkan StatusPill berdasarkan status + StatusPill _getStatusPill(String? status) { + switch (status?.toUpperCase()) { + case 'MENUNGGU': + return StatusPill( + status: 'Menunggu', + backgroundColor: Colors.orange, + textColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + ); + case 'TINDAKAN': + return StatusPill( + status: 'Tindakan', + backgroundColor: Colors.blue, + textColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + ); + case 'SELESAI': + return StatusPill.completed(status: 'Selesai'); + default: + return StatusPill( + status: status ?? 'Tidak Diketahui', + backgroundColor: Colors.grey, + textColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + ); + } + } + + Widget _buildStatusPanel(BuildContext context, PengaduanModel pengaduan) { + final status = pengaduan.status?.toUpperCase() ?? ''; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Status Pengaduan', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + // Tambahkan tombol untuk mengubah status + if (status != 'SELESAI') + TextButton.icon( + onPressed: () { + _showUbahStatusDialog(context, pengaduan); + }, + icon: const Icon(Icons.edit, size: 16), + label: const Text('Ubah Status'), + style: TextButton.styleFrom( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildStatusStep( + 'MENUNGGU', + 'Menunggu', + status == 'MENUNGGU', + status == 'MENUNGGU' || + status == 'TINDAKAN' || + status == 'SELESAI', + Colors.orange, + ), + ), + Expanded( + child: _buildStatusStep( + 'TINDAKAN', + 'Tindakan', + status == 'TINDAKAN', + status == 'TINDAKAN' || status == 'SELESAI', + Colors.blue, + ), + ), + Expanded( + child: _buildStatusStep( + 'SELESAI', + 'Selesai', + status == 'SELESAI', + status == 'SELESAI', + Colors.green, + ), + ), + ], + ), + const SizedBox(height: 16), + // Tombol aksi berdasarkan status + if (status == 'MENUNGGU') + ElevatedButton.icon( + onPressed: () { + _showTambahTindakanDialog(context, pengaduan.id!); + }, + icon: const Icon(Icons.engineering), + label: const Text('Tambah Tindakan'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 40), + ), + ) + else if (status == 'TINDAKAN') + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () { + _showTambahTindakanDialog(context, pengaduan.id!); + }, + icon: const Icon(Icons.add), + label: const Text('Tambah Tindakan'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + _showKonfirmasiSelesai(context, pengaduan.id!); + }, + icon: const Icon(Icons.check_circle), + label: const Text('Selesaikan'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + ), + ), + ], + ) + else if (status == 'SELESAI') + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green.shade200), + ), + child: Row( + children: [ + Icon( + Icons.check_circle, + color: Colors.green, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Pengaduan telah selesai ditangani', + style: TextStyle( + color: Colors.green.shade800, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildStatusStep( + String statusValue, + String label, + bool isActive, + bool isCompleted, + Color color, + ) { + return Column( + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: isActive + ? color + : (isCompleted ? color.withOpacity(0.3) : Colors.grey.shade300), + shape: BoxShape.circle, + ), + child: Center( + child: isCompleted + ? Icon( + Icons.check, + color: Colors.white, + size: 16, + ) + : Text( + (statusValue == 'MENUNGGU' + ? '1' + : (statusValue == 'TINDAKAN' ? '2' : '3')), + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isActive ? FontWeight.bold : FontWeight.normal, + color: isActive ? color : Colors.grey.shade600, + ), + ), + ], + ); + } + + void _showKonfirmasiSelesai(BuildContext context, String pengaduanId) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Konfirmasi'), + content: const Text( + 'Apakah Anda yakin ingin menyelesaikan pengaduan ini? Status pengaduan akan berubah menjadi SELESAI.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () async { + await controller.selesaikanPengaduan(pengaduanId); + Navigator.pop(context); + Get.forceAppUpdate(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + ), + child: const Text('Ya, Selesaikan'), + ), + ], + ), + ); + } + + Widget _buildPengaduanInfo(BuildContext context, PengaduanModel pengaduan) { + final warga = pengaduan.warga; + final String namaWarga = warga != null + ? warga['nama_lengkap'] ?? 'Tidak diketahui' + : 'Tidak diketahui'; + final String nikWarga = warga != null ? warga['nik'] ?? '-' : '-'; + final String alamatWarga = warga != null ? warga['alamat'] ?? '-' : '-'; + final String noHpWarga = warga != null ? warga['no_hp'] ?? '-' : '-'; + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header dengan judul + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Informasi Pelapor', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + Icon( + Icons.person, + color: AppTheme.primaryColor, + ), + ], + ), + const Divider(height: 24), + + // Informasi pelapor + _buildInfoRow('Nama', namaWarga), + _buildInfoRow('NIK', nikWarga), + _buildInfoRow('Alamat', alamatWarga), + _buildInfoRow('No. HP', noHpWarga), + ], + ), + ), + ); + } + + Widget _buildPenyaluranInfo(BuildContext context, PengaduanModel pengaduan) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header dengan judul + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Informasi Penyaluran', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.primaryColor, + ), + ), + Icon( + Icons.inventory, + color: AppTheme.primaryColor, + ), + ], + ), + const Divider(height: 24), + + // Informasi penyaluran + _buildInfoRow('Nama Penyaluran', pengaduan.namaPenyaluran ?? '-'), + _buildInfoRow('Jenis Bantuan', pengaduan.jenisBantuan ?? '-'), + _buildInfoRow('Jumlah Bantuan', pengaduan.jumlahBantuan ?? '-'), + _buildInfoRow('Deskripsi', pengaduan.deskripsiPenyaluran ?? '-'), + ], + ), + ), + ); + } + + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + value, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } + + Widget _buildTindakanTimeline( + BuildContext context, + List tindakanList, + ) { + if (tindakanList.isEmpty) { + return InfoCard( + title: 'Belum Ada Tindakan', + description: 'Belum ada tindakan untuk pengaduan ini', + icon: Icons.info_outline, + backgroundColor: Colors.grey.shade50, + ); + } + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Menggunakan SectionHeader untuk judul + SectionHeader( + title: 'Riwayat Tindakan', + padding: EdgeInsets.zero, + ), + const SizedBox(height: 16), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: tindakanList.length, + itemBuilder: (context, index) { + final tindakan = tindakanList[index]; + final bool isFirst = index == 0; + final bool isLast = index == tindakanList.length - 1; + + return _buildTimelineTile( + context, + tindakan, + isFirst, + isLast, + ); + }, + ), + ], + ), + ), + ); + } + + Widget _buildTimelineTile( + BuildContext context, + TindakanPengaduanModel tindakan, + bool isFirst, + bool isLast, + ) { + Color dotColor; + switch (tindakan.statusTindakan) { + case 'SELESAI': + dotColor = Colors.green; + break; + case 'PROSES': + dotColor = Colors.blue; + break; + default: + dotColor = Colors.grey; + } + + return TimelineTile( + alignment: TimelineAlign.start, + isFirst: isFirst, + isLast: isLast, + indicatorStyle: IndicatorStyle( + width: 20, + color: dotColor, + iconStyle: IconStyle( + color: Colors.white, + iconData: + tindakan.statusTindakan == 'SELESAI' ? Icons.check : Icons.sync, + ), + ), + endChild: Container( + margin: const EdgeInsets.only(left: 16, bottom: 24), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header dengan kategori dan status + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + tindakan.kategoriTindakanText, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + Row( + children: [ + // Menggunakan StatusPill untuk status tindakan + StatusPill( + status: tindakan.statusTindakanText, + backgroundColor: dotColor, + textColor: Colors.white, + ), + const SizedBox(width: 8), + // Tombol edit + InkWell( + onTap: () { + _showEditTindakanDialog(context, tindakan); + }, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: const Icon( + Icons.edit, + size: 16, + color: Colors.blue, + ), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 8), + + // Deskripsi tindakan + Text( + tindakan.tindakan ?? '', + style: const TextStyle(fontSize: 14), + ), + + // Catatan tindakan (jika ada) + if (tindakan.catatan != null && tindakan.catatan!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'Catatan: ${tindakan.catatan}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + fontStyle: FontStyle.italic, + ), + ), + ], + + // Hasil tindakan (jika ada) + if (tindakan.hasilTindakan != null && + tindakan.hasilTindakan!.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.blue.shade100), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.check_circle_outline, + size: 16, + color: Colors.blue.shade700, + ), + const SizedBox(width: 4), + Text( + 'Hasil Tindakan:', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: Colors.blue.shade700, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + tindakan.hasilTindakan!, + style: TextStyle( + fontSize: 12, + color: Colors.blue.shade900, + ), + ), + ], + ), + ), + ], + + // Bukti tindakan (jika ada) + if (tindakan.buktiTindakan != null && + tindakan.buktiTindakan!.isNotEmpty) ...[ + const SizedBox(height: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Bukti Tindakan:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 8), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: tindakan.buktiTindakan!.map((bukti) { + return Container( + width: 60, + height: 60, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(4), + ), + child: Stack( + children: [ + Center( + child: Icon( + Icons.insert_drive_file, + color: Colors.blue.shade700, + ), + ), + Positioned( + top: 0, + right: 0, + child: InkWell( + onTap: () { + tindakan.buktiTindakan!.remove(bukti); + }, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: 14, + ), + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ), + ], + + // Feedback warga (jika ada) + if (tindakan.feedbackWarga != null && + tindakan.feedbackWarga!.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.amber.shade50, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.amber.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.comment, + size: 16, + color: Colors.amber.shade800, + ), + const SizedBox(width: 4), + Text( + 'Feedback Warga:', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: Colors.amber.shade800, + ), + ), + const Spacer(), + if (tindakan.ratingWarga != null) ...[ + Row( + children: List.generate(5, (index) { + return Icon( + index < (tindakan.ratingWarga ?? 0) + ? Icons.star + : Icons.star_border, + color: Colors.amber, + size: 16, + ); + }), + ), + ], + ], + ), + const SizedBox(height: 4), + Text( + tindakan.feedbackWarga!, + style: TextStyle( + fontSize: 13, + color: Colors.amber.shade900, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 8), + + // Footer dengan info petugas dan tanggal + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Oleh: ${tindakan.namaPetugas}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + Text( + tindakan.tanggalTindakan != null + ? DateFormat('dd MMM yyyy HH:mm', 'id_ID') + .format(tindakan.tanggalTindakan!) + : '-', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + + // Prioritas tindakan (jika ada) + if (tindakan.prioritas != null) ...[ + const SizedBox(height: 8), + // Menggunakan StatusPill untuk prioritas tindakan + StatusPill( + status: tindakan.prioritasText, + backgroundColor: _getPriorityColor(tindakan.prioritas), + textColor: Colors.white, + ), + ], + ], + ), + ), + ); + } + + Color _getPriorityColor(String? priority) { + switch (priority) { + case 'TINGGI': + return Colors.red; + case 'SEDANG': + return Colors.orange; + case 'RENDAH': + return Colors.green; + default: + return Colors.grey; + } + } + + void _showTambahTindakanDialog(BuildContext context, String pengaduanId) { + final formKey = GlobalKey(); + final tindakanController = TextEditingController(); + final catatanController = TextEditingController(); + String? selectedKategori; + String? selectedPrioritas; + + // Gunakan RxList untuk bukti tindakan + final buktiTindakanList = [].obs; + final isUploading = false.obs; + + final List kategoriOptions = [ + 'VERIFIKASI_DATA', + 'KUNJUNGAN_LAPANGAN', + 'KOORDINASI_LINTAS_INSTANSI', + 'PERBAIKAN_DATA_PENERIMA', + 'PENYALURAN_ULANG', + 'PENGGANTIAN_BANTUAN', + 'MEDIASI', + 'KLARIFIKASI', + 'PENYESUAIAN_JUMLAH_BANTUAN', + 'PEMERIKSAAN_KUALITAS_BANTUAN', + 'PERBAIKAN_PROSES_DISTRIBUSI', + 'EDUKASI_PENERIMA', + 'PENYELESAIAN_ADMINISTRATIF', + 'INVESTIGASI_PENYALAHGUNAAN', + 'PELAPORAN_KE_PIHAK_BERWENANG', + ]; + + final List prioritasOptions = [ + 'RENDAH', + 'SEDANG', + 'TINGGI', + ]; + + // Fungsi untuk mengunggah bukti tindakan + Future uploadBukti() async { + try { + isUploading.value = true; + + // Buka image picker untuk memilih file + final ImagePicker picker = ImagePicker(); + final XFile? pickedFile = await picker.pickImage( + source: ImageSource.gallery, + imageQuality: 70, + ); + + if (pickedFile == null) { + isUploading.value = false; + return; + } + + // Upload file ke Supabase Storage + final String filePath = pickedFile.path; + final String fileName = filePath.split('/').last; + final String fileExt = fileName.split('.').last; + final String fileKey = + 'bukti_tindakan_${DateTime.now().millisecondsSinceEpoch}.$fileExt'; + + // Upload ke bucket tindakan_pengaduan + await SupabaseService.to.client.storage + .from('tindakan_pengaduan') + .upload( + fileKey, + File(filePath), + fileOptions: + const FileOptions(cacheControl: '3600', upsert: true), + ); + + // Dapatkan URL publik + final String fileUrl = SupabaseService.to.client.storage + .from('tindakan_pengaduan') + .getPublicUrl(fileKey); + + // Tambahkan URL ke list bukti + buktiTindakanList.add(fileUrl); + + Get.snackbar( + 'Berhasil', + 'Bukti berhasil diunggah', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + } catch (e) { + print('Error uploading bukti: $e'); + Get.snackbar( + 'Error', + 'Gagal mengunggah bukti: ${e.toString()}', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + isUploading.value = false; + } + } + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Tambah Tindakan'), + content: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Kategori Tindakan', + border: OutlineInputBorder(), + ), + value: selectedKategori, + items: kategoriOptions.map((kategori) { + return DropdownMenuItem( + value: kategori, + child: Text( + kategori + .split('_') + .map((word) => + word[0].toUpperCase() + + word.substring(1).toLowerCase()) + .join(' '), + style: const TextStyle(fontSize: 14), + ), + ); + }).toList(), + onChanged: (value) { + selectedKategori = value; + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Pilih kategori tindakan'; + } + return null; + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Prioritas', + border: OutlineInputBorder(), + ), + value: selectedPrioritas, + items: prioritasOptions.map((prioritas) { + return DropdownMenuItem( + value: prioritas, + child: Text( + prioritas[0].toUpperCase() + + prioritas.substring(1).toLowerCase(), + style: const TextStyle(fontSize: 14), + ), + ); + }).toList(), + onChanged: (value) { + selectedPrioritas = value; + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Pilih prioritas tindakan'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: tindakanController, + decoration: const InputDecoration( + labelText: 'Tindakan', + border: OutlineInputBorder(), + ), + maxLines: 3, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Tindakan tidak boleh kosong'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: catatanController, + decoration: const InputDecoration( + labelText: 'Catatan (opsional)', + border: OutlineInputBorder(), + ), + maxLines: 2, + ), + const SizedBox(height: 16), + // Bukti tindakan + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Bukti Tindakan:', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: Colors.grey.shade800, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx(() { + if (buktiTindakanList.isEmpty) { + return const Text( + 'Belum ada bukti tindakan', + style: TextStyle( + fontStyle: FontStyle.italic, + color: Colors.grey, + ), + ); + } else { + return Wrap( + spacing: 8, + runSpacing: 8, + children: buktiTindakanList.map((bukti) { + return Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(8), + ), + child: Stack( + children: [ + Center( + child: Icon( + Icons.insert_drive_file, + color: Colors.blue.shade700, + size: 36, + ), + ), + Positioned( + top: 0, + right: 0, + child: InkWell( + onTap: () { + buktiTindakanList.remove(bukti); + }, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: 14, + ), + ), + ), + ), + ], + ), + ); + }).toList(), + ); + } + }), + const SizedBox(height: 12), + Obx(() => ElevatedButton.icon( + onPressed: + isUploading.value ? null : uploadBukti, + icon: isUploading.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.upload_file), + label: Text(isUploading.value + ? 'Mengunggah...' + : 'Tambah Bukti'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 40), + ), + )), + ], + ), + ), + ], + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () async { + if (formKey.currentState!.validate()) { + try { + // Buat objek tindakan + final Map tindakanData = { + 'pengaduan_id': pengaduanId, + 'tindakan': tindakanController.text, + 'catatan': catatanController.text, + 'status_tindakan': 'PROSES', + 'prioritas': selectedPrioritas, + 'kategori_tindakan': selectedKategori, + 'tanggal_tindakan': DateTime.now().toIso8601String(), + 'petugas_id': controller.user?.id, + 'bukti_tindakan': buktiTindakanList.toList(), + 'created_at': DateTime.now().toIso8601String(), + 'updated_at': DateTime.now().toIso8601String(), + }; + + // Simpan tindakan langsung ke Supabase + await SupabaseService.to + .tambahTindakanPengaduan(tindakanData); + + // Update status pengaduan + await SupabaseService.to + .updateStatusPengaduan(pengaduanId, 'TINDAKAN'); + + // Tutup dialog + Navigator.pop(context); + + // Refresh halaman + Get.forceAppUpdate(); + + // Tampilkan snackbar + Get.snackbar( + 'Berhasil', + 'Tindakan berhasil ditambahkan', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + } catch (e) { + print('Error adding tindakan: $e'); + Get.snackbar( + 'Error', + 'Gagal menambahkan tindakan: ${e.toString()}', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + }, + child: const Text('Simpan'), + ), + ], + ), + ); + } + + void _showEditTindakanDialog( + BuildContext context, TindakanPengaduanModel tindakan) { + final formKey = GlobalKey(); + final tindakanController = TextEditingController(text: tindakan.tindakan); + final catatanController = TextEditingController(text: tindakan.catatan); + final hasilTindakanController = + TextEditingController(text: tindakan.hasilTindakan); + String? selectedKategori = tindakan.kategoriTindakan; + String? selectedPrioritas = tindakan.prioritas; + String? selectedStatus = tindakan.statusTindakan; + + // Gunakan RxList untuk bukti tindakan + final buktiTindakanList = (tindakan.buktiTindakan ?? []).obs; + final isUploading = false.obs; + + final List kategoriOptions = [ + 'VERIFIKASI_DATA', + 'KUNJUNGAN_LAPANGAN', + 'KOORDINASI_LINTAS_INSTANSI', + 'PERBAIKAN_DATA_PENERIMA', + 'PENYALURAN_ULANG', + 'PENGGANTIAN_BANTUAN', + 'MEDIASI', + 'KLARIFIKASI', + 'PENYESUAIAN_JUMLAH_BANTUAN', + 'PEMERIKSAAN_KUALITAS_BANTUAN', + 'PERBAIKAN_PROSES_DISTRIBUSI', + 'EDUKASI_PENERIMA', + 'PENYELESAIAN_ADMINISTRATIF', + 'INVESTIGASI_PENYALAHGUNAAN', + 'PELAPORAN_KE_PIHAK_BERWENANG', + ]; + + final List prioritasOptions = [ + 'RENDAH', + 'SEDANG', + 'TINGGI', + ]; + + final List statusOptions = [ + 'PROSES', + 'SELESAI', + ]; + + // Fungsi untuk mengunggah bukti tindakan + Future uploadBukti() async { + try { + isUploading.value = true; + + // Buka image picker untuk memilih file + final ImagePicker picker = ImagePicker(); + final XFile? pickedFile = await picker.pickImage( + source: ImageSource.gallery, + imageQuality: 70, + ); + + if (pickedFile == null) { + isUploading.value = false; + return; + } + + // Upload file ke Supabase Storage + final String filePath = pickedFile.path; + final String fileName = filePath.split('/').last; + final String fileExt = fileName.split('.').last; + final String fileKey = + 'bukti_tindakan_${DateTime.now().millisecondsSinceEpoch}.$fileExt'; + + // Upload ke bucket tindakan_pengaduan + await SupabaseService.to.client.storage + .from('tindakan_pengaduan') + .upload( + fileKey, + File(filePath), + fileOptions: + const FileOptions(cacheControl: '3600', upsert: true), + ); + + // Dapatkan URL publik + final String fileUrl = SupabaseService.to.client.storage + .from('tindakan_pengaduan') + .getPublicUrl(fileKey); + + // Tambahkan URL ke list bukti + buktiTindakanList.add(fileUrl); + + Get.snackbar( + 'Berhasil', + 'Bukti berhasil diunggah', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + } catch (e) { + print('Error uploading bukti: $e'); + Get.snackbar( + 'Error', + 'Gagal mengunggah bukti: ${e.toString()}', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + isUploading.value = false; + } + } + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon( + Icons.edit, + color: Colors.blue, + size: 24, + ), + const SizedBox(width: 8), + const Text('Edit Tindakan'), + ], + ), + content: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Panel informasi status + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.shade100), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Status Tindakan', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: Colors.blue.shade800, + ), + ), + const SizedBox(height: 8), + Text( + 'Status tindakan menentukan apakah tindakan ini masih dalam proses atau sudah selesai. Jika semua tindakan selesai, pengaduan dapat diselesaikan.', + style: TextStyle( + fontSize: 12, + color: Colors.blue.shade700, + ), + ), + ], + ), + ), + + // Status tindakan + DropdownButtonFormField( + decoration: InputDecoration( + labelText: 'Status Tindakan', + border: const OutlineInputBorder(), + filled: true, + fillColor: selectedStatus == 'SELESAI' + ? Colors.green.shade50 + : Colors.blue.shade50, + ), + value: selectedStatus, + items: statusOptions.map((status) { + return DropdownMenuItem( + value: status, + child: Row( + children: [ + Icon( + status == 'PROSES' + ? Icons.sync + : Icons.check_circle, + color: + status == 'PROSES' ? Colors.blue : Colors.green, + size: 18, + ), + const SizedBox(width: 8), + Text( + status == 'PROSES' ? 'Dalam Proses' : 'Selesai', + style: const TextStyle(fontSize: 14), + ), + ], + ), + ); + }).toList(), + onChanged: (value) { + selectedStatus = value; + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Pilih status tindakan'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Kategori tindakan + DropdownButtonFormField( + decoration: const InputDecoration( + labelText: 'Kategori Tindakan', + border: OutlineInputBorder(), + ), + value: selectedKategori, + items: kategoriOptions.map((kategori) { + return DropdownMenuItem( + value: kategori, + child: Text( + kategori + .split('_') + .map((word) => + word[0].toUpperCase() + + word.substring(1).toLowerCase()) + .join(' '), + style: const TextStyle(fontSize: 14), + ), + ); + }).toList(), + onChanged: (value) { + selectedKategori = value; + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Pilih kategori tindakan'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Prioritas tindakan + DropdownButtonFormField( + decoration: InputDecoration( + labelText: 'Prioritas', + border: const OutlineInputBorder(), + filled: true, + fillColor: selectedPrioritas == 'TINGGI' + ? Colors.red.shade50 + : (selectedPrioritas == 'SEDANG' + ? Colors.orange.shade50 + : Colors.green.shade50), + ), + value: selectedPrioritas, + items: prioritasOptions.map((prioritas) { + Color priorityColor = prioritas == 'TINGGI' + ? Colors.red + : (prioritas == 'SEDANG' + ? Colors.orange + : Colors.green); + + return DropdownMenuItem( + value: prioritas, + child: Row( + children: [ + Icon( + Icons.flag, + color: priorityColor, + size: 18, + ), + const SizedBox(width: 8), + Text( + prioritas[0].toUpperCase() + + prioritas.substring(1).toLowerCase(), + style: TextStyle( + fontSize: 14, + color: priorityColor, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + }).toList(), + onChanged: (value) { + selectedPrioritas = value; + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Pilih prioritas tindakan'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Deskripsi tindakan + TextFormField( + controller: tindakanController, + decoration: const InputDecoration( + labelText: 'Deskripsi Tindakan', + border: OutlineInputBorder(), + hintText: 'Jelaskan tindakan yang dilakukan', + ), + maxLines: 3, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Tindakan tidak boleh kosong'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Catatan tindakan + TextFormField( + controller: catatanController, + decoration: const InputDecoration( + labelText: 'Catatan (opsional)', + border: OutlineInputBorder(), + hintText: 'Tambahkan catatan jika diperlukan', + ), + maxLines: 2, + ), + const SizedBox(height: 16), + + // Hasil tindakan + TextFormField( + controller: hasilTindakanController, + decoration: InputDecoration( + labelText: 'Hasil Tindakan', + border: const OutlineInputBorder(), + hintText: 'Jelaskan hasil dari tindakan yang dilakukan', + filled: true, + fillColor: Colors.blue.shade50, + ), + maxLines: 3, + ), + const SizedBox(height: 16), + + // Bukti tindakan + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Bukti Tindakan:', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: Colors.grey.shade800, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx(() { + if (buktiTindakanList.isEmpty) { + return const Text( + 'Belum ada bukti tindakan', + style: TextStyle( + fontStyle: FontStyle.italic, + color: Colors.grey, + ), + ); + } else { + return Wrap( + spacing: 8, + runSpacing: 8, + children: buktiTindakanList.map((bukti) { + return Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(8), + ), + child: Stack( + children: [ + Center( + child: Icon( + Icons.insert_drive_file, + color: Colors.blue.shade700, + size: 36, + ), + ), + Positioned( + top: 0, + right: 0, + child: InkWell( + onTap: () { + buktiTindakanList.remove(bukti); + }, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: 14, + ), + ), + ), + ), + ], + ), + ); + }).toList(), + ); + } + }), + const SizedBox(height: 12), + Obx(() => ElevatedButton.icon( + onPressed: + isUploading.value ? null : uploadBukti, + icon: isUploading.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.upload_file), + label: Text(isUploading.value + ? 'Mengunggah...' + : 'Tambah Bukti'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 40), + ), + )), + ], + ), + ), + ], + ), + + // Informasi tentang feedback warga + if (tindakan.feedbackWarga != null && + tindakan.feedbackWarga!.isNotEmpty) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.amber.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.amber.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.comment, + color: Colors.amber.shade800, + size: 18, + ), + const SizedBox(width: 8), + Text( + 'Feedback dari Warga', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: Colors.amber.shade800, + ), + ), + ], + ), + const SizedBox(height: 8), + if (tindakan.ratingWarga != null) ...[ + Row( + children: [ + Text( + 'Rating: ', + style: TextStyle( + fontSize: 12, + color: Colors.amber.shade800, + ), + ), + Row( + children: List.generate(5, (index) { + return Icon( + index < (tindakan.ratingWarga ?? 0) + ? Icons.star + : Icons.star_border, + color: Colors.amber, + size: 16, + ); + }), + ), + ], + ), + const SizedBox(height: 4), + ], + Text( + tindakan.feedbackWarga!, + style: TextStyle( + fontSize: 13, + color: Colors.amber.shade900, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () async { + if (formKey.currentState!.validate()) { + try { + // Buat data update + final Map updateData = { + 'tindakan': tindakanController.text, + 'catatan': catatanController.text, + 'hasil_tindakan': hasilTindakanController.text, + 'status_tindakan': selectedStatus, + 'prioritas': selectedPrioritas, + 'kategori_tindakan': selectedKategori, + 'bukti_tindakan': buktiTindakanList.toList(), + 'updated_at': DateTime.now().toIso8601String(), + }; + + // Jika status berubah menjadi SELESAI, tambahkan tanggal verifikasi + if (selectedStatus == 'SELESAI' && + tindakan.statusTindakan != 'SELESAI') { + updateData['tanggal_verifikasi'] = + DateTime.now().toIso8601String(); + } + + // Update tindakan + await SupabaseService.to + .updateTindakanPengaduan(tindakan.id!, updateData); + + // Tutup dialog + Navigator.pop(context); + + // Refresh halaman + Get.forceAppUpdate(); + + // Tampilkan snackbar + Get.snackbar( + 'Berhasil', + 'Tindakan berhasil diperbarui', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + } catch (e) { + print('Error updating tindakan: $e'); + Get.snackbar( + 'Error', + 'Gagal memperbarui tindakan: ${e.toString()}', + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + ), + child: const Text('Simpan Perubahan'), + ), + ], + ), + ); + } + + void _showUbahStatusDialog(BuildContext context, PengaduanModel pengaduan) { + final String currentStatus = pengaduan.status?.toUpperCase() ?? ''; + String selectedStatus = currentStatus; + + final List statusOptions = [ + 'MENUNGGU', + 'TINDAKAN', + 'SELESAI', + ]; + + // Hapus status yang sama dengan status saat ini dari opsi + statusOptions.remove(currentStatus); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Ubah Status Pengaduan'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Status saat ini:', + style: TextStyle( + fontSize: 14, + color: Colors.grey, + ), + ), + const SizedBox(height: 8), + _getStatusPill(currentStatus), + const SizedBox(height: 16), + const Text( + 'Pilih status baru:', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + ...statusOptions.map((status) => RadioListTile( + title: Text( + status == 'MENUNGGU' + ? 'Menunggu' + : (status == 'TINDAKAN' ? 'Tindakan' : 'Selesai'), + ), + value: status, + groupValue: selectedStatus, + onChanged: (value) { + selectedStatus = value!; + Navigator.pop(context); + _showKonfirmasiUbahStatus( + context, pengaduan.id!, selectedStatus); + }, + activeColor: status == 'MENUNGGU' + ? Colors.orange + : (status == 'TINDAKAN' ? Colors.blue : Colors.green), + secondary: Icon( + status == 'MENUNGGU' + ? Icons.hourglass_empty + : (status == 'TINDAKAN' + ? Icons.engineering + : Icons.check_circle), + color: status == 'MENUNGGU' + ? Colors.orange + : (status == 'TINDAKAN' ? Colors.blue : Colors.green), + ), + )), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + ], + ), + ); + } + + void _showKonfirmasiUbahStatus( + BuildContext context, String pengaduanId, String newStatus) { + String statusText = newStatus == 'MENUNGGU' + ? 'Menunggu' + : (newStatus == 'TINDAKAN' ? 'Tindakan' : 'Selesai'); + + Color statusColor = newStatus == 'MENUNGGU' + ? Colors.orange + : (newStatus == 'TINDAKAN' ? Colors.blue : Colors.green); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Konfirmasi'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Apakah Anda yakin ingin mengubah status pengaduan menjadi "$statusText"?', + ), + const SizedBox(height: 16), + if (newStatus == 'SELESAI') + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.amber.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.amber.shade200), + ), + child: Row( + children: [ + Icon( + Icons.warning, + color: Colors.amber.shade800, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Mengubah status menjadi Selesai akan menandai pengaduan ini telah selesai ditangani.', + style: TextStyle( + fontSize: 12, + color: Colors.amber.shade800, + ), + ), + ), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () async { + if (newStatus == 'SELESAI') { + await controller.selesaikanPengaduan(pengaduanId); + } else { + // Gunakan fungsi prosesPengaduan untuk status TINDAKAN + // atau fungsi khusus untuk status lainnya + if (newStatus == 'TINDAKAN') { + await controller.prosesPengaduan(pengaduanId); + } else { + // Tambahkan fungsi updateStatusPengaduan di controller + await controller.updateStatusPengaduan( + pengaduanId, newStatus); + } + } + + Navigator.pop(context); + Get.forceAppUpdate(); + + Get.snackbar( + 'Berhasil', + 'Status pengaduan berhasil diubah menjadi $statusText', + snackPosition: SnackPosition.TOP, + backgroundColor: statusColor, + colorText: Colors.white, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: statusColor, + ), + child: const Text('Ya, Ubah Status'), + ), + ], + ), + ); + } +} diff --git a/lib/app/modules/petugas_desa/views/pengaduan_view.dart b/lib/app/modules/petugas_desa/views/pengaduan_view.dart index 050eff7..2b2a5e4 100644 --- a/lib/app/modules/petugas_desa/views/pengaduan_view.dart +++ b/lib/app/modules/petugas_desa/views/pengaduan_view.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:penyaluran_app/app/modules/petugas_desa/controllers/petugas_desa_controller.dart'; +import 'package:penyaluran_app/app/modules/petugas_desa/controllers/pengaduan_controller.dart'; import 'package:penyaluran_app/app/theme/app_theme.dart'; +import 'package:penyaluran_app/app/utils/date_time_helper.dart'; -class PengaduanView extends GetView { +class PengaduanView extends GetView { const PengaduanView({super.key}); @override @@ -33,58 +34,60 @@ class PengaduanView extends GetView { } Widget _buildPengaduanSummary(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: AppTheme.primaryGradient, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Ringkasan Pengaduan', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - color: Colors.white, + return Obx(() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: AppTheme.primaryGradient, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ringkasan Pengaduan', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildSummaryItem( + context, + icon: Icons.pending_actions, + title: 'Diproses', + value: controller.jumlahDiproses.toString(), + color: Colors.orange, + ), ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: _buildSummaryItem( - context, - icon: Icons.pending_actions, - title: 'Diproses', - value: '3', - color: Colors.orange, + Expanded( + child: _buildSummaryItem( + context, + icon: Icons.engineering, + title: 'Tindakan', + value: controller.jumlahTindakan.toString(), + color: Colors.blue, + ), ), - ), - Expanded( - child: _buildSummaryItem( - context, - icon: Icons.engineering, - title: 'Tindakan', - value: '2', - color: Colors.blue, + Expanded( + child: _buildSummaryItem( + context, + icon: Icons.check_circle, + title: 'Selesai', + value: controller.jumlahSelesai.toString(), + color: Colors.green, + ), ), - ), - Expanded( - child: _buildSummaryItem( - context, - icon: Icons.check_circle, - title: 'Selesai', - value: '8', - color: Colors.green, - ), - ), - ], - ), - ], - ), - ); + ], + ), + ], + ), + ); + }); } Widget _buildSummaryItem( @@ -133,6 +136,7 @@ class PengaduanView extends GetView { children: [ Expanded( child: TextField( + controller: controller.searchController, decoration: InputDecoration( hintText: 'Cari pengaduan...', prefixIcon: const Icon(Icons.search), @@ -144,6 +148,10 @@ class PengaduanView extends GetView { fillColor: Colors.grey.shade100, contentPadding: const EdgeInsets.symmetric(vertical: 0), ), + onChanged: (value) { + // Implementasi pencarian + controller.refreshData(); + }, ), ), const SizedBox(width: 12), @@ -173,21 +181,42 @@ class PengaduanView extends GetView { content: Column( mainAxisSize: MainAxisSize.min, children: [ - CheckboxListTile( - title: const Text('Diproses'), - value: true, - onChanged: (value) {}, - ), - CheckboxListTile( - title: const Text('Tindakan'), - value: true, - onChanged: (value) {}, - ), - CheckboxListTile( - title: const Text('Selesai'), - value: true, - onChanged: (value) {}, - ), + Obx(() => RadioListTile( + title: const Text('Semua'), + value: 0, + groupValue: controller.selectedCategoryIndex.value, + onChanged: (value) { + controller.changeCategory(value!); + Navigator.pop(context); + }, + )), + Obx(() => RadioListTile( + title: const Text('Diproses'), + value: 1, + groupValue: controller.selectedCategoryIndex.value, + onChanged: (value) { + controller.changeCategory(value!); + Navigator.pop(context); + }, + )), + Obx(() => RadioListTile( + title: const Text('Tindakan'), + value: 2, + groupValue: controller.selectedCategoryIndex.value, + onChanged: (value) { + controller.changeCategory(value!); + Navigator.pop(context); + }, + )), + Obx(() => RadioListTile( + title: const Text('Selesai'), + value: 3, + groupValue: controller.selectedCategoryIndex.value, + onChanged: (value) { + controller.changeCategory(value!); + Navigator.pop(context); + }, + )), ], ), actions: [ @@ -195,84 +224,85 @@ class PengaduanView extends GetView { onPressed: () => Navigator.pop(context), child: const Text('Batal'), ), - ElevatedButton( - onPressed: () => Navigator.pop(context), - child: const Text('Terapkan'), - ), ], ), ); } Widget _buildPengaduanList(BuildContext context) { - final List> pengaduanList = [ - { - 'id': '1', - 'nama': 'Budi Santoso', - 'nik': '3201020107030011', - 'jenis_pengaduan': 'Bantuan Tidak Diterima', - 'deskripsi': - 'Saya belum menerima bantuan beras yang dijadwalkan minggu lalu', - 'tanggal': '15 April 2023', - 'status': 'Diproses', - }, - { - 'id': '2', - 'nama': 'Siti Rahayu', - 'nik': '3201020107030010', - 'jenis_pengaduan': 'Kualitas Bantuan', - 'deskripsi': - 'Beras yang diterima berkualitas buruk dan tidak layak konsumsi', - 'tanggal': '14 April 2023', - 'status': 'Tindakan', - }, - { - 'id': '3', - 'nama': 'Ahmad Fauzi', - 'nik': '3201020107030013', - 'jenis_pengaduan': 'Jumlah Bantuan', - 'deskripsi': - 'Jumlah bantuan yang diterima tidak sesuai dengan yang dijanjikan', - 'tanggal': '13 April 2023', - 'status': 'Tindakan', - }, - { - 'id': '4', - 'nama': 'Dewi Lestari', - 'nik': '3201020107030012', - 'jenis_pengaduan': 'Jadwal Penyaluran', - 'deskripsi': - 'Jadwal penyaluran bantuan sering berubah tanpa pemberitahuan', - 'tanggal': '10 April 2023', - 'status': 'Selesai', - }, - ]; + return Obx(() { + if (controller.isLoading.value) { + return const Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ); + } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Daftar Pengaduan', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, + final filteredPengaduan = controller.getFilteredPengaduan(); + + if (filteredPengaduan.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + children: [ + const Icon( + Icons.inbox_outlined, + size: 80, + color: Colors.grey, + ), + const SizedBox(height: 16), + Text( + 'Belum ada pengaduan', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Daftar Pengaduan', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), ), - ), - const SizedBox(height: 12), - ...pengaduanList.map((item) => _buildPengaduanItem(context, item)), - ], - ); + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => controller.refreshData(), + tooltip: 'Refresh', + ), + ], + ), + const SizedBox(height: 12), + ...filteredPengaduan + .map((item) => _buildPengaduanItem(context, item)), + ], + ); + }); } - Widget _buildPengaduanItem(BuildContext context, Map item) { + Widget _buildPengaduanItem(BuildContext context, dynamic item) { Color statusColor; IconData statusIcon; - switch (item['status']) { + switch (item.status?.toUpperCase()) { case 'MENUNGGU': statusColor = AppTheme.warningColor; statusIcon = Icons.pending; break; - case 'DIPROSES': + case 'TINDAKAN': statusColor = AppTheme.infoColor; statusIcon = Icons.sync; break; @@ -285,6 +315,14 @@ class PengaduanView extends GetView { statusIcon = Icons.help_outline; } + // Format tanggal menggunakan DateTimeHelper + String formattedDate = ''; + if (item.tanggalPengaduan != null) { + formattedDate = DateTimeHelper.formatDate(item.tanggalPengaduan); + } else if (item.createdAt != null) { + formattedDate = DateTimeHelper.formatDate(item.createdAt); + } + return Container( width: double.infinity, margin: const EdgeInsets.only(bottom: 12), @@ -310,7 +348,7 @@ class PengaduanView extends GetView { children: [ Expanded( child: Text( - item['nama'] ?? '', + item.warga?['nama'] ?? item.judul ?? '', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), @@ -334,7 +372,7 @@ class PengaduanView extends GetView { ), const SizedBox(width: 4), Text( - item['status'] ?? '', + item.status ?? '', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: statusColor, fontWeight: FontWeight.bold, @@ -347,28 +385,64 @@ class PengaduanView extends GetView { ), const SizedBox(height: 4), Text( - 'NIK: ${item['nik'] ?? ''}', + 'NIK: ${item.warga?['nik'] ?? ''}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey, ), ), const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(4), + if (item.penerimaPenyaluran != null) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Penyaluran: ${item.namaPenyaluran}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), ), - child: Text( - item['jenis_pengaduan'] ?? '', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.bold, + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Jenis: ${item.jenisBantuan}', + style: Theme.of(context).textTheme.bodySmall, + ), ), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Jumlah: ${item.jumlahBantuan}', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + ], ), - ), + ], const SizedBox(height: 8), Text( - item['deskripsi'] ?? '', + item.deskripsi ?? '', style: Theme.of(context).textTheme.bodyMedium, maxLines: 2, overflow: TextOverflow.ellipsis, @@ -383,7 +457,7 @@ class PengaduanView extends GetView { ), const SizedBox(width: 4), Text( - item['tanggal'] ?? '', + formattedDate, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Colors.grey, ), @@ -401,11 +475,10 @@ class PengaduanView extends GetView { ); } - List _buildActionButtons( - BuildContext context, Map item) { - final status = item['status']; + List _buildActionButtons(BuildContext context, dynamic item) { + final status = item.status?.toUpperCase(); - if (status == 'Diproses') { + if (status == 'MENUNGGU') { return [ TextButton.icon( onPressed: () { @@ -421,8 +494,8 @@ class PengaduanView extends GetView { ), TextButton.icon( onPressed: () { - // Implementasi untuk melihat detail pengaduan - _showDetailDialog(context, item); + // Navigasi ke halaman detail pengaduan + Get.toNamed('/detail-pengaduan', arguments: {'id': item.id}); }, icon: const Icon(Icons.info_outline, size: 18), label: const Text('Detail'), @@ -432,7 +505,7 @@ class PengaduanView extends GetView { ), ), ]; - } else if (status == 'Tindakan') { + } else if (status == 'TINDAKAN') { return [ TextButton.icon( onPressed: () { @@ -448,8 +521,8 @@ class PengaduanView extends GetView { ), TextButton.icon( onPressed: () { - // Implementasi untuk melihat detail pengaduan - _showDetailDialog(context, item); + // Navigasi ke halaman detail pengaduan + Get.toNamed('/detail-pengaduan', arguments: {'id': item.id}); }, icon: const Icon(Icons.info_outline, size: 18), label: const Text('Detail'), @@ -463,8 +536,8 @@ class PengaduanView extends GetView { return [ TextButton.icon( onPressed: () { - // Implementasi untuk melihat detail pengaduan - _showDetailDialog(context, item); + // Navigasi ke halaman detail pengaduan + Get.toNamed('/detail-pengaduan', arguments: {'id': item.id}); }, icon: const Icon(Icons.info_outline, size: 18), label: const Text('Detail'), @@ -477,103 +550,41 @@ class PengaduanView extends GetView { } } - void _showDetailDialog(BuildContext context, Map item) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text('Detail Pengaduan: ${item['id']}'), - content: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _buildDetailItem('Nama', item['nama'] ?? ''), - _buildDetailItem('NIK', item['nik'] ?? ''), - _buildDetailItem( - 'Jenis Pengaduan', item['jenis_pengaduan'] ?? ''), - _buildDetailItem('Tanggal', item['tanggal'] ?? ''), - _buildDetailItem('Status', item['status'] ?? ''), - const SizedBox(height: 8), - const Text( - 'Deskripsi:', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - Text(item['deskripsi'] ?? ''), - if (item['status'] == 'Tindakan' || - item['status'] == 'Selesai') ...[ - const SizedBox(height: 8), - const Text( - 'Tindakan:', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - Text(item['tindakan'] ?? - 'Pengecekan ke lokasi dan verifikasi data penerima'), - ], - if (item['status'] == 'Selesai') ...[ - const SizedBox(height: 8), - const Text( - 'Hasil:', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - Text(item['hasil'] ?? - 'Pengaduan telah diselesaikan dengan penyaluran ulang bantuan'), - ], - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Tutup'), - ), - ], - ), - ); - } - - Widget _buildDetailItem(String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 120, - child: Text( - '$label:', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - Expanded(child: Text(value)), - ], - ), - ); - } - - void _showTindakanDialog(BuildContext context, Map item) { - final TextEditingController tindakanController = TextEditingController(); + void _showTindakanDialog(BuildContext context, dynamic item) { + controller.tindakanController.clear(); + controller.catatanController.clear(); showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Tindakan Pengaduan'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('Pengaduan dari: ${item['nama']}'), - const SizedBox(height: 16), - TextField( - controller: tindakanController, - decoration: const InputDecoration( - labelText: 'Tindakan yang dilakukan', - border: OutlineInputBorder(), + content: Form( + key: controller.tindakanFormKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Pengaduan dari: ${item.warga?['nama'] ?? ''}'), + const SizedBox(height: 16), + TextFormField( + controller: controller.tindakanController, + decoration: const InputDecoration( + labelText: 'Tindakan yang dilakukan', + border: OutlineInputBorder(), + ), + maxLines: 3, + validator: controller.validateTindakan, ), - maxLines: 3, - ), - ], + const SizedBox(height: 12), + TextFormField( + controller: controller.catatanController, + decoration: const InputDecoration( + labelText: 'Catatan (opsional)', + border: OutlineInputBorder(), + ), + maxLines: 2, + ), + ], + ), ), actions: [ TextButton( @@ -582,15 +593,7 @@ class PengaduanView extends GetView { ), ElevatedButton( onPressed: () { - // Implementasi untuk menyimpan tindakan - Navigator.pop(context); - Get.snackbar( - 'Berhasil', - 'Status pengaduan berhasil diubah menjadi Tindakan', - snackPosition: SnackPosition.TOP, - backgroundColor: Colors.blue, - colorText: Colors.white, - ); + controller.tambahTindakan(item.id!); }, child: const Text('Simpan'), ), @@ -599,9 +602,7 @@ class PengaduanView extends GetView { ); } - void _showSelesaikanDialog(BuildContext context, Map item) { - final TextEditingController hasilController = TextEditingController(); - + void _showSelesaikanDialog(BuildContext context, dynamic item) { showDialog( context: context, builder: (context) => AlertDialog( @@ -609,15 +610,11 @@ class PengaduanView extends GetView { content: Column( mainAxisSize: MainAxisSize.min, children: [ - Text('Pengaduan dari: ${item['nama']}'), + Text('Pengaduan dari: ${item.warga?['nama'] ?? ''}'), const SizedBox(height: 16), - TextField( - controller: hasilController, - decoration: const InputDecoration( - labelText: 'Hasil penyelesaian', - border: OutlineInputBorder(), - ), - maxLines: 3, + const Text( + 'Apakah Anda yakin ingin menyelesaikan pengaduan ini?', + textAlign: TextAlign.center, ), ], ), @@ -628,15 +625,8 @@ class PengaduanView extends GetView { ), ElevatedButton( onPressed: () { - // Implementasi untuk menyimpan hasil Navigator.pop(context); - Get.snackbar( - 'Berhasil', - 'Status pengaduan berhasil diubah menjadi Selesai', - snackPosition: SnackPosition.TOP, - backgroundColor: Colors.green, - colorText: Colors.white, - ); + controller.selesaikanPengaduan(item.id!); }, child: const Text('Selesaikan'), ), diff --git a/lib/app/modules/warga/controllers/warga_dashboard_controller.dart b/lib/app/modules/warga/controllers/warga_dashboard_controller.dart index 461cecb..c850e90 100644 --- a/lib/app/modules/warga/controllers/warga_dashboard_controller.dart +++ b/lib/app/modules/warga/controllers/warga_dashboard_controller.dart @@ -273,11 +273,15 @@ class WargaDashboardController extends GetxController { // Fungsi untuk mengambil data pengaduan Future fetchPengaduan() async { try { - final response = await _supabaseService.client - .from('pengaduan') - .select('*') - .eq('pelapor', user!.id) - .order('created_at', ascending: false); + final wargaData = await _supabaseService.getWargaByUserId(); + if (wargaData == null) { + print('Data warga tidak ditemukan'); + return; + } + + final String wargaId = wargaData['id']; + final response = await _supabaseService + .getPengaduanWargaWithPenerimaPenyaluran(wargaId); if (response != null) { final List pengaduanList = []; @@ -289,7 +293,7 @@ class WargaDashboardController extends GetxController { // Hitung jumlah berdasarkan status totalPengaduan.value = pengaduanList.length; totalPengaduanProses.value = pengaduanList - .where((p) => p.status == 'PROSES' || p.status == 'DIPROSES') + .where((p) => p.status == 'MENUNGGU' || p.status == 'TINDAKAN') .length; totalPengaduanSelesai.value = pengaduanList.where((p) => p.status == 'SELESAI').length; @@ -342,4 +346,39 @@ class WargaDashboardController extends GetxController { void logout() { _authController.logout(); } + + Future> getDetailPengaduan(String pengaduanId) async { + try { + // Ambil data pengaduan + final pengaduanData = + await _supabaseService.client.from('pengaduan').select(''' + *, + penerima_penyaluran:penerima_penyaluran_id( + *, + penyaluran_bantuan:penyaluran_bantuan_id(*), + stok_bantuan:stok_bantuan_id(*), + warga:warga_id(*) + ), + warga:warga_id(*) + ''').eq('id', pengaduanId).single(); + + // Ambil data tindakan pengaduan + final tindakanData = + await _supabaseService.getTindakanPengaduan(pengaduanId); + + // Gabungkan data + final result = { + 'pengaduan': pengaduanData, + 'tindakan': tindakanData ?? [], + }; + + return result; + } catch (e) { + print('Error getting detail pengaduan: $e'); + return { + 'pengaduan': null, + 'tindakan': [], + }; + } + } } diff --git a/lib/app/modules/warga/views/detail_pengaduan_view.dart b/lib/app/modules/warga/views/detail_pengaduan_view.dart new file mode 100644 index 0000000..fe145a2 --- /dev/null +++ b/lib/app/modules/warga/views/detail_pengaduan_view.dart @@ -0,0 +1,449 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:penyaluran_app/app/data/models/pengaduan_model.dart'; +import 'package:penyaluran_app/app/data/models/tindakan_pengaduan_model.dart'; +import 'package:penyaluran_app/app/modules/warga/controllers/warga_dashboard_controller.dart'; +import 'package:penyaluran_app/app/theme/app_theme.dart'; +import 'package:timeline_tile/timeline_tile.dart'; + +class WargaDetailPengaduanView extends GetView { + const WargaDetailPengaduanView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final Map args = Get.arguments ?? {}; + final String pengaduanId = args['id'] ?? ''; + + if (pengaduanId.isEmpty) { + return Scaffold( + appBar: AppBar( + title: const Text('Detail Pengaduan'), + ), + body: const Center( + child: Text('ID Pengaduan tidak valid'), + ), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Detail Pengaduan'), + elevation: 0, + ), + body: FutureBuilder>( + future: controller.getDetailPengaduan(pengaduanId), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Text('Error: ${snapshot.error}'), + ); + } + + final data = snapshot.data; + if (data == null || data['pengaduan'] == null) { + return const Center( + child: Text('Data pengaduan tidak ditemukan'), + ); + } + + final pengaduan = PengaduanModel.fromJson(data['pengaduan']); + final List tindakanList = + (data['tindakan'] as List) + .map((item) => TindakanPengaduanModel.fromJson(item)) + .toList(); + + return _buildDetailContent(context, pengaduan, tindakanList); + }, + ), + ); + } + + Widget _buildDetailContent( + BuildContext context, + PengaduanModel pengaduan, + List tindakanList, + ) { + // Tentukan status dan warna + Color statusColor; + String statusText; + + switch (pengaduan.status?.toUpperCase()) { + case 'MENUNGGU': + statusColor = Colors.orange; + statusText = 'Menunggu'; + break; + case 'TINDAKAN': + statusColor = Colors.blue; + statusText = 'Tindakan'; + break; + case 'SELESAI': + statusColor = Colors.green; + statusText = 'Selesai'; + break; + default: + statusColor = Colors.grey; + statusText = pengaduan.status ?? 'Tidak Diketahui'; + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header dengan status + _buildHeaderWithStatus(context, pengaduan, statusColor, statusText), + + const SizedBox(height: 24), + + // Informasi penyaluran yang diadukan + if (pengaduan.penerimaPenyaluran != null) + _buildPenyaluranInfo(context, pengaduan), + + const SizedBox(height: 24), + + // Timeline tindakan + _buildTindakanTimeline(context, tindakanList), + ], + ), + ); + } + + Widget _buildHeaderWithStatus( + BuildContext context, + PengaduanModel pengaduan, + Color statusColor, + String statusText, + ) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + pengaduan.judul ?? 'Pengaduan', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: statusColor, + ), + ), + child: Text( + statusText, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + pengaduan.deskripsi ?? '', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.calendar_today, + size: 16, + color: Colors.grey.shade600, + ), + const SizedBox(width: 8), + Text( + pengaduan.tanggalPengaduan != null + ? DateFormat('dd MMMM yyyy', 'id_ID') + .format(pengaduan.tanggalPengaduan!) + : '-', + style: TextStyle( + color: Colors.grey.shade600, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildPenyaluranInfo(BuildContext context, PengaduanModel pengaduan) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Informasi Penyaluran', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + _buildInfoRow('Nama Penyaluran', pengaduan.namaPenyaluran), + _buildInfoRow('Jenis Bantuan', pengaduan.jenisBantuan), + _buildInfoRow('Jumlah Bantuan', pengaduan.jumlahBantuan), + _buildInfoRow('Deskripsi', pengaduan.deskripsiPenyaluran), + ], + ), + ), + ); + } + + Widget _buildTindakanTimeline( + BuildContext context, + List tindakanList, + ) { + if (tindakanList.isEmpty) { + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: const Padding( + padding: EdgeInsets.all(16), + child: Center( + child: Text( + 'Pengaduan Anda sedang menunggu tindakan dari petugas', + style: TextStyle( + fontSize: 14, + fontStyle: FontStyle.italic, + color: Colors.grey, + ), + ), + ), + ), + ); + } + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Riwayat Tindakan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: tindakanList.length, + itemBuilder: (context, index) { + final tindakan = tindakanList[index]; + final bool isFirst = index == 0; + final bool isLast = index == tindakanList.length - 1; + + return _buildTimelineTile( + context, + tindakan, + isFirst, + isLast, + ); + }, + ), + ], + ), + ), + ); + } + + Widget _buildTimelineTile( + BuildContext context, + TindakanPengaduanModel tindakan, + bool isFirst, + bool isLast, + ) { + Color dotColor; + switch (tindakan.statusTindakan) { + case 'SELESAI': + dotColor = Colors.green; + break; + case 'PROSES': + dotColor = Colors.blue; + break; + default: + dotColor = Colors.grey; + } + + return TimelineTile( + alignment: TimelineAlign.start, + isFirst: isFirst, + isLast: isLast, + indicatorStyle: IndicatorStyle( + width: 20, + color: dotColor, + iconStyle: IconStyle( + color: Colors.white, + iconData: + tindakan.statusTindakan == 'SELESAI' ? Icons.check : Icons.sync, + ), + ), + endChild: Container( + margin: const EdgeInsets.only(left: 16, bottom: 24), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + tindakan.kategoriTindakanText, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: dotColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + tindakan.statusTindakanText, + style: TextStyle( + color: dotColor, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + tindakan.tindakan ?? '', + style: const TextStyle(fontSize: 14), + ), + if (tindakan.hasilTindakan != null && + tindakan.hasilTindakan!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'Hasil: ${tindakan.hasilTindakan}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + ), + ), + ], + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Oleh: ${tindakan.namaPetugas}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + Text( + tindakan.tanggalTindakan != null + ? DateFormat('dd MMM yyyy', 'id_ID') + .format(tindakan.tanggalTindakan!) + : '-', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + '$label:', + style: TextStyle( + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ), + ), + ), + Expanded( + child: Text( + value, + style: const TextStyle( + color: Colors.black87, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/app/modules/warga/views/warga_dashboard_view.dart b/lib/app/modules/warga/views/warga_dashboard_view.dart index cac0103..33a3fdf 100644 --- a/lib/app/modules/warga/views/warga_dashboard_view.dart +++ b/lib/app/modules/warga/views/warga_dashboard_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:penyaluran_app/app/modules/warga/controllers/warga_dashboard_controller.dart'; +import 'package:penyaluran_app/app/routes/app_pages.dart'; import 'package:penyaluran_app/app/widgets/bantuan_card.dart'; import 'package:penyaluran_app/app/widgets/section_header.dart'; @@ -290,10 +291,9 @@ class WargaDashboardView extends GetView { title: 'Bantuan Terbaru', viewAllText: 'Lihat Semua', onViewAll: () { - Get.toNamed('/warga-penerimaan'); + Get.toNamed(Routes.wargaPenerimaan); }, ), - const SizedBox(height: 16), ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), diff --git a/lib/app/modules/warga/views/warga_pengaduan_view.dart b/lib/app/modules/warga/views/warga_pengaduan_view.dart index 73fca8e..ec4d7a8 100644 --- a/lib/app/modules/warga/views/warga_pengaduan_view.dart +++ b/lib/app/modules/warga/views/warga_pengaduan_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:penyaluran_app/app/modules/warga/controllers/warga_dashboard_controller.dart'; +import 'package:penyaluran_app/app/utils/date_time_helper.dart'; class WargaPengaduanView extends GetView { const WargaPengaduanView({Key? key}) : super(key: key); @@ -83,11 +84,13 @@ class WargaPengaduanView extends GetView { String statusText; switch (item.status?.toUpperCase()) { - case 'PROSES': - case 'DIPROSES': - case 'TINDAKAN': + case 'MENUNGGU': statusColor = Colors.orange; - statusText = 'Proses'; + statusText = 'Menunggu'; + break; + case 'TINDAKAN': + statusColor = Colors.blue; + statusText = 'Tindakan'; break; case 'SELESAI': statusColor = Colors.green; @@ -102,8 +105,6 @@ class WargaPengaduanView extends GetView { statusText = item.status ?? 'Tidak Diketahui'; } - final isProses = statusText == 'Proses'; - return Card( margin: const EdgeInsets.only(bottom: 16), shape: RoundedRectangleBorder( @@ -112,7 +113,9 @@ class WargaPengaduanView extends GetView { elevation: 2, child: InkWell( onTap: () { - // TODO: Navigasi ke detail pengaduan + // Navigasi ke detail pengaduan + Get.toNamed('/warga/detail-pengaduan', + arguments: {'id': item.id}); }, borderRadius: BorderRadius.circular(12), child: Padding( @@ -158,6 +161,52 @@ class WargaPengaduanView extends GetView { ], ), const SizedBox(height: 12), + + // Informasi penyaluran bantuan + if (item.penerimaPenyaluran != null) + Container( + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Penyaluran: ${item.namaPenyaluran}', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text( + 'Jenis: ${item.jenisBantuan}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + ), + ), + ), + Expanded( + child: Text( + 'Jumlah: ${item.jumlahBantuan}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + ), + ), + ), + ], + ), + ], + ), + ), + if (item.deskripsi != null && item.deskripsi!.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 12), @@ -180,8 +229,8 @@ class WargaPengaduanView extends GetView { const SizedBox(width: 8), Text( item.tanggalPengaduan != null - ? DateFormat('dd MMMM yyyy', 'id_ID') - .format(item.tanggalPengaduan!) + ? DateTimeHelper.formatDateTime( + item.tanggalPengaduan!) : '-', style: TextStyle( color: Colors.grey.shade600, @@ -197,8 +246,8 @@ class WargaPengaduanView extends GetView { children: [ TextButton.icon( onPressed: () { - // TODO: Implementasi navigasi ke detail pengaduan - Get.toNamed('/detail-pengaduan', + // Navigasi ke detail pengaduan + Get.toNamed('/warga/detail-pengaduan', arguments: {'id': item.id}); }, icon: const Icon(Icons.visibility), diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart index b0c3b4b..a05ecea 100644 --- a/lib/app/routes/app_pages.dart +++ b/lib/app/routes/app_pages.dart @@ -27,6 +27,9 @@ import 'package:penyaluran_app/app/modules/warga/views/warga_pengaduan_view.dart import 'package:penyaluran_app/app/modules/warga/views/warga_view.dart'; import 'package:penyaluran_app/app/modules/warga/controllers/warga_dashboard_controller.dart'; import 'package:penyaluran_app/app/modules/warga/views/warga_detail_penerimaan_view.dart'; +import 'package:penyaluran_app/app/modules/petugas_desa/views/detail_pengaduan_view.dart'; +import 'package:penyaluran_app/app/modules/petugas_desa/bindings/pengaduan_binding.dart'; +import 'package:penyaluran_app/app/modules/warga/views/detail_pengaduan_view.dart'; part 'app_routes.dart'; @@ -124,10 +127,20 @@ class AppPages { page: () => DetailPenyaluranPage(), binding: PenyaluranBinding(), ), + GetPage( + name: _Paths.detailPengaduan, + page: () => const DetailPengaduanView(), + binding: PengaduanBinding(), + ), GetPage( name: Routes.wargaDetailPenerimaan, page: () => const WargaDetailPenerimaanView(), binding: WargaBinding(), ), + GetPage( + name: Routes.wargaDetailPengaduan, + page: () => const WargaDetailPengaduanView(), + binding: WargaBinding(), + ), ]; } diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart index b521c92..e71ee22 100644 --- a/lib/app/routes/app_routes.dart +++ b/lib/app/routes/app_routes.dart @@ -28,6 +28,8 @@ abstract class Routes { static const detailPenyaluran = _Paths.detailPenyaluran; static const riwayatPenyaluran = _Paths.riwayatPenyaluran; static const wargaDetailPenerimaan = _Paths.wargaDetailPenerimaan; + static const detailPengaduan = _Paths.detailPengaduan; + static const wargaDetailPengaduan = _Paths.wargaDetailPengaduan; } abstract class _Paths { @@ -58,4 +60,6 @@ abstract class _Paths { static const detailPenyaluran = '/detail-penyaluran'; static const riwayatPenyaluran = '/petugas-desa/riwayat-penyaluran'; static const wargaDetailPenerimaan = '/warga/detail-penerimaan'; + static const detailPengaduan = '/detail-pengaduan'; + static const wargaDetailPengaduan = '/warga/detail-pengaduan'; } diff --git a/lib/app/services/supabase_service.dart b/lib/app/services/supabase_service.dart index 13735fc..b93a249 100644 --- a/lib/app/services/supabase_service.dart +++ b/lib/app/services/supabase_service.dart @@ -701,6 +701,50 @@ class SupabaseService extends GetxService { } } + // Metode untuk mendapatkan pengaduan dengan detail penerima penyaluran + Future>?> + getPengaduanWithPenerimaPenyaluran() async { + try { + final response = await client.from('pengaduan').select(''' + *, + penerima_penyaluran:penerima_penyaluran_id( + *, + penyaluran_bantuan:penyaluran_bantuan_id(*), + stok_bantuan:stok_bantuan_id(*), + warga:warga_id(*) + ), + warga:warga_id(*) + ''').order('created_at', ascending: false); + + return response; + } catch (e) { + print('Error getting pengaduan with penerima penyaluran: $e'); + return null; + } + } + + // Metode untuk mendapatkan pengaduan warga tertentu dengan detail penerima penyaluran + Future>?> getPengaduanWargaWithPenerimaPenyaluran( + String wargaId) async { + try { + final response = await client.from('pengaduan').select(''' + *, + penerima_penyaluran:penerima_penyaluran_id( + *, + penyaluran_bantuan:penyaluran_bantuan_id(*), + stok_bantuan:stok_bantuan_id(*), + warga:warga_id(*) + ), + warga:warga_id(*) + ''').eq('warga_id', wargaId).order('created_at', ascending: false); + + return response; + } catch (e) { + print('Error getting warga pengaduan with penerima penyaluran: $e'); + return null; + } + } + Future prosesPengaduan(String pengaduanId) async { try { await client.from('pengaduan').update({ @@ -722,6 +766,19 @@ class SupabaseService extends GetxService { } } + Future updateTindakanPengaduan( + String tindakanId, Map tindakan) async { + try { + await client + .from('tindakan_pengaduan') + .update(tindakan) + .eq('id', tindakanId); + } catch (e) { + print('Error updating tindakan pengaduan: $e'); + throw e.toString(); + } + } + Future updateStatusPengaduan(String pengaduanId, String status) async { try { await client.from('pengaduan').update({ @@ -739,7 +796,11 @@ class SupabaseService extends GetxService { try { final response = await client .from('tindakan_pengaduan') - .select('*') + .select(''' + *, + petugas:petugas_id(id, nama, email), + verifikator:verifikator_id(id, nama, email) + ''') .eq('pengaduan_id', pengaduanId) .order('created_at', ascending: false); diff --git a/lib/app/widgets/section_header.dart b/lib/app/widgets/section_header.dart index df89541..927aafd 100644 --- a/lib/app/widgets/section_header.dart +++ b/lib/app/widgets/section_header.dart @@ -14,7 +14,7 @@ class SectionHeader extends StatelessWidget { this.onViewAll, this.viewAllText = 'Lihat Semua', this.trailing, - this.padding = const EdgeInsets.only(bottom: 12), + this.padding = const EdgeInsets.only(bottom: 4), this.titleStyle, }) : super(key: key); diff --git a/pubspec.lock b/pubspec.lock index 3c8a7c5..675ae16 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -834,6 +834,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.4" + timeline_tile: + dependency: "direct main" + description: + name: timeline_tile + sha256: "85ec2023c67137397c2812e3e848b2fb20b410b67cd9aff304bb5480c376fc0c" + url: "https://pub.dev" + source: hosted + version: "2.0.0" timezone: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bd83019..5c26ce8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,6 +70,7 @@ dependencies: flutter_localizations: sdk: flutter flutter_staggered_animations: ^1.1.1 + timeline_tile: ^2.0.0 dev_dependencies: flutter_test: