From a3ec431d49c332744058c13ac9b88e94d5d48328 Mon Sep 17 00:00:00 2001 From: ahmadafriadi Date: Thu, 5 Nov 2026 11:01:12 +0700 Subject: [PATCH] Update Monitoring dan Verifikasi Petani --- .../Web/Concerns/AksesMonitoringPetani.php | 63 +++ .../Monitoring/MonitoringPetaniController.php | 88 +++ .../Produktivitas/PetaniStatusController.php | 4 +- .../Verifikasi/VerifikasiPetaniController.php | 130 +++++ app/Http/Middleware/BatasiAksesPetani.php | 25 +- .../Concerns/KembalikanStatusPetaniKeDraf.php | 4 +- app/Models/Pertanian/Petani.php | 5 + .../Produktivitas/KelengkapanPetani.php | 500 +++++++++++++++--- ..._100000_tambah_catatan_ke_petani_table.php | 25 + .../kunci-petani-terverifikasi.blade.php | 58 ++ .../views/components/status-petani.blade.php | 18 +- resources/views/layout/sidebar.blade.php | 18 + .../enumerator.blade.php | 119 +++++ .../monitoring-verifikasi/index.blade.php | 195 +++++++ .../monitoring.blade.php | 9 + .../petani-lengkap.blade.php | 182 +++++++ .../petani-monitoring.blade.php | 136 +++++ .../petani-terverifikasi.blade.php | 112 ++++ .../verifikasi.blade.php | 118 +++++ .../produktivitas/petani/header.blade.php | 23 +- .../produktivitas/petani/index.blade.php | 7 +- routes/web.php | 17 + 22 files changed, 1780 insertions(+), 76 deletions(-) create mode 100644 app/Http/Controllers/Web/Concerns/AksesMonitoringPetani.php create mode 100644 app/Http/Controllers/Web/Monitoring/MonitoringPetaniController.php create mode 100644 app/Http/Controllers/Web/Verifikasi/VerifikasiPetaniController.php create mode 100644 database/migrations/2026_11_05_100000_tambah_catatan_ke_petani_table.php create mode 100644 resources/views/components/kunci-petani-terverifikasi.blade.php create mode 100644 resources/views/monitoring-verifikasi/enumerator.blade.php create mode 100644 resources/views/monitoring-verifikasi/index.blade.php create mode 100644 resources/views/monitoring-verifikasi/monitoring.blade.php create mode 100644 resources/views/monitoring-verifikasi/petani-lengkap.blade.php create mode 100644 resources/views/monitoring-verifikasi/petani-monitoring.blade.php create mode 100644 resources/views/monitoring-verifikasi/petani-terverifikasi.blade.php create mode 100644 resources/views/monitoring-verifikasi/verifikasi.blade.php diff --git a/app/Http/Controllers/Web/Concerns/AksesMonitoringPetani.php b/app/Http/Controllers/Web/Concerns/AksesMonitoringPetani.php new file mode 100644 index 0000000..2c048de --- /dev/null +++ b/app/Http/Controllers/Web/Concerns/AksesMonitoringPetani.php @@ -0,0 +1,63 @@ + Petani (tab Monitoring & Verifikasi). + * Hanya super_admin, admin, dan koordinator; koordinator dibatasi komoditas yang ditugaskan. + */ +trait AksesMonitoringPetani +{ + protected const PER_PAGE = [10, 25, 50]; + + protected function izinkan(): void + { + abort_unless(Gate::any(['super_admin', 'admin', 'koordinator']), 403); + } + + protected function izinkanPetani(Petani $petani): void + { + $this->izinkan(); + abort_if(auth()->user()->isKoordinator() && ! auth()->user()->bolehAksesPetani($petani), 403); + } + + /** Query petani + relasi untuk hitung kelengkapan (eager load, tanpa N+1), sudah dibatasi komoditas koordinator. */ + protected function queryPetani(): Builder + { + return $this->batasiKomoditas(Petani::query()->with(KelengkapanPetani::RELASI)); + } + + protected function batasiKomoditas(Builder $query): Builder + { + $user = auth()->user(); + + return $query->when($user->isKoordinator(), fn ($q) => $q->whereIn('komoditas_id', $user->komoditasKoordinatorIds())); + } + + /** Ringkasan 3 hitungan input (skor, wajib, lainnya) per petani, dikunci id petani. */ + protected function ringkasan(Collection $petani): BaseCollection + { + return $petani->mapWithKeys(fn (Petani $p) => [$p->id => KelengkapanPetani::untuk($p)->ringkasanInput()]); + } + + protected function perPage(Request $request, string $kunci): int + { + $n = (int) $request->query($kunci); + + return in_array($n, self::PER_PAGE, true) ? $n : self::PER_PAGE[0]; + } + + protected function kataCari(Request $request, string $kunci): ?string + { + return $request->filled($kunci) ? Str::lower($request->query($kunci)) : null; + } +} diff --git a/app/Http/Controllers/Web/Monitoring/MonitoringPetaniController.php b/app/Http/Controllers/Web/Monitoring/MonitoringPetaniController.php new file mode 100644 index 0000000..61d4a8e --- /dev/null +++ b/app/Http/Controllers/Web/Monitoring/MonitoringPetaniController.php @@ -0,0 +1,88 @@ + Petani, tab Monitoring (sub-tab Petani & Enumerator). + */ +class MonitoringPetaniController extends Controller +{ + use AksesMonitoringPetani; + + public function index(Request $request) + { + $this->izinkan(); + + // Sub-tab Petani + $petani = $this->queryPetani() + ->with('creator:id,name') + ->when($this->kataCari($request, 'search'), fn ($q, $cari) => $q->whereRaw('lower(nama) like ?', ["%{$cari}%"])) + ->latest() + ->paginate($this->perPage($request, 'per_page')) + ->withQueryString() + ->appends('sub', 'petani'); + + // Sub-tab Enumerator: jumlah petani yang diinput (created_by) + $enumerator = User::query() + ->where('role', Role::ENUMERATOR) + ->withCount(['petaniDibuat as jumlah_petani' => fn (Builder $q) => $this->batasiKomoditas($q)]) + ->when($this->kataCari($request, 'search_enumerator'), fn ($q, $cari) => $q->whereRaw('lower(name) like ?', ["%{$cari}%"])) + ->orderByDesc('jumlah_petani') + ->orderBy('name') + ->paginate($this->perPage($request, 'per_page_enumerator'), ['*'], 'page_enumerator') + ->withQueryString() + ->appends('sub', 'enumerator'); + + return view('monitoring-verifikasi.monitoring', [ + 'menu' => 'petani', + 'petani' => $petani, + 'ringkasan' => $this->ringkasan($petani->getCollection()), + 'enumerator' => $enumerator, + 'perPage' => self::PER_PAGE, + ]); + } + + /** Kartu rincian saat badge Input Skor / Wajib / Lainnya diklik (HTML, dimuat lewat fetch). */ + public function detail(Petani $petani, string $jenis) + { + $this->izinkanPetani($petani); + + $petani->loadMissing(['desaKelurahan.kecamatan.kabupatenKota', 'skor:id,petani_id,skor_kesiapan_adopsi']); + $kelengkapan = KelengkapanPetani::untuk($petani); + + return view('monitoring-verifikasi.petani-monitoring', [ + 'bagian' => 'rincian', + 'petani' => $petani, + 'ringkasan' => $kelengkapan->ringkasanInput()[$jenis], + 'rincian' => $kelengkapan->rincian($jenis)->groupBy('grup'), + ]); + } + + /** Kartu rincian saat jumlah petani seorang enumerator diklik: daftar petani + 3 hitungan input. */ + public function enumerator(User $user) + { + $this->izinkan(); + abort_unless($user->role === Role::ENUMERATOR, 404); + + $daftarPetani = $this->queryPetani() + ->where('created_by', $user->id) + ->orderBy('nama') + ->get(); + + return view('monitoring-verifikasi.enumerator', [ + 'bagian' => 'rincian', + 'user' => $user, + 'daftarPetani' => $daftarPetani, + 'ringkasan' => $this->ringkasan($daftarPetani), + ]); + } +} diff --git a/app/Http/Controllers/Web/Produktivitas/PetaniStatusController.php b/app/Http/Controllers/Web/Produktivitas/PetaniStatusController.php index be62329..bb6ca62 100644 --- a/app/Http/Controllers/Web/Produktivitas/PetaniStatusController.php +++ b/app/Http/Controllers/Web/Produktivitas/PetaniStatusController.php @@ -21,10 +21,10 @@ class PetaniStatusController extends Controller $validated = $request->validate([ 'status' => ['required', Rule::in([StatusDataPetani::LENGKAP->value, StatusDataPetani::DRAFT->value])], - // Enumerator wajib menyetujui pernyataan kelengkapan saat menandai lengkap. + // Enumerator menyetujui pernyataan kelengkapan (tombol konfirmasi di popup) saat menandai lengkap. 'pernyataan' => ['exclude_unless:status,' . StatusDataPetani::LENGKAP->value, 'accepted'], ], [ - 'pernyataan.accepted' => 'Centang pernyataan kelengkapan data terlebih dahulu.', + 'pernyataan.accepted' => 'Setujui pernyataan kelengkapan data terlebih dahulu.', ]); $tujuan = StatusDataPetani::from($validated['status']); diff --git a/app/Http/Controllers/Web/Verifikasi/VerifikasiPetaniController.php b/app/Http/Controllers/Web/Verifikasi/VerifikasiPetaniController.php new file mode 100644 index 0000000..5702082 --- /dev/null +++ b/app/Http/Controllers/Web/Verifikasi/VerifikasiPetaniController.php @@ -0,0 +1,130 @@ + Petani, tab Verifikasi (sub-tab Data Lengkap & Terverifikasi). + * Koordinator/admin memeriksa data berstatus Lengkap lalu menandainya Terverifikasi. + */ +class VerifikasiPetaniController extends Controller +{ + use AksesMonitoringPetani; + + public function index(Request $request) + { + $this->izinkan(); + + $cariNama = fn (string $kunci) => fn ($q) => $q->when( + $this->kataCari($request, $kunci), + fn ($q, $cari) => $q->whereRaw('lower(nama) like ?', ["%{$cari}%"]) + ); + + // Sub-tab Data Lengkap: menunggu verifikasi + $petaniLengkap = $this->queryPetani() + ->with('creator:id,name') + ->where('status', StatusDataPetani::LENGKAP->value) + ->tap($cariNama('search')) + ->latest('updated_at') + ->paginate($this->perPage($request, 'per_page')) + ->withQueryString() + ->appends('sub', 'lengkap'); + + // Sub-tab Terverifikasi + $petaniTerverifikasi = $this->batasiKomoditas(Petani::query()) + ->with(['creator:id,name', 'updater:id,name,role', 'komoditas:id,nama', 'desaKelurahan:id,nama']) + ->where('status', StatusDataPetani::TERVERIFIKASI->value) + ->tap($cariNama('search_terverifikasi')) + ->latest('updated_at') + ->paginate($this->perPage($request, 'per_page_terverifikasi'), ['*'], 'page_terverifikasi') + ->withQueryString() + ->appends('sub', 'terverifikasi'); + + return view('monitoring-verifikasi.verifikasi', [ + 'menu' => 'petani', + 'petaniLengkap' => $petaniLengkap, + 'ringkasan' => $this->ringkasan($petaniLengkap->getCollection()), + 'petaniTerverifikasi' => $petaniTerverifikasi, + 'perPage' => self::PER_PAGE, + ]); + } + + /** Isi kartu verifikasi (3 langkah: Input Skor → Input Wajib → Input Lainnya), dimuat lewat fetch. */ + public function show(Petani $petani) + { + $this->izinkanPetani($petani); + abort_unless(in_array($petani->status, [StatusDataPetani::LENGKAP, StatusDataPetani::TERVERIFIKASI], true), 404); + + $petani->loadMissing(['desaKelurahan.kecamatan.kabupatenKota', 'skor:id,petani_id,skor_kesiapan_adopsi', 'creator:id,name']); + + // Sudah terverifikasi: kartu ringkas (siapa & kapan diverifikasi) + Kembalikan ke Draf. + if ($petani->status === StatusDataPetani::TERVERIFIKASI) { + $petani->loadMissing(['komoditas:id,nama', 'updater:id,name,role']); + + return view('monitoring-verifikasi.petani-terverifikasi', ['bagian' => 'rincian', 'petani' => $petani]); + } + + $kelengkapan = KelengkapanPetani::untuk($petani); + + return view('monitoring-verifikasi.petani-lengkap', [ + 'bagian' => 'rincian', + 'petani' => $petani, + 'ringkasan' => $kelengkapan->ringkasanInput(), + 'rincian' => collect(['skor', 'wajib', 'lainnya']) + ->mapWithKeys(fn (string $jenis) => [$jenis => $kelengkapan->rincian($jenis)->groupBy('grup')]), + ]); + } + + /** Tandai Terverifikasi (dari langkah terakhir kartu verifikasi). */ + public function update(Petani $petani) + { + $this->izinkanPetani($petani); + + $kembali = redirect()->route('monitoring-verifikasi.petani.verifikasi', ['sub' => 'lengkap']); + + if ($petani->status !== StatusDataPetani::LENGKAP) { + return $kembali->with('verifikasi_error', "Data petani {$petani->nama} tidak berstatus Lengkap, tidak dapat diverifikasi."); + } + + $petani->status = StatusDataPetani::TERVERIFIKASI; + $petani->updated_by = auth()->id(); + $petani->save(); + + return $kembali->with('verifikasi_success', "Data petani {$petani->nama} berhasil diverifikasi."); + } + + /** + * Kembalikan ke Draf dengan catatan untuk enumerator: + * - dari Lengkap → tombol "Belum Lengkap" di kartu verifikasi + * - dari Terverifikasi → ada temuan setelah diverifikasi + */ + public function kembalikanKeDraf(Request $request, Petani $petani) + { + $this->izinkanPetani($petani); + + $data = $request->validate( + ['catatan' => ['required', 'string', 'max:2000']], + ['catatan.required' => 'Catatan wajib diisi.', 'catatan.max' => 'Catatan maksimal 2000 karakter.'] + ); + + $dariTerverifikasi = $petani->status === StatusDataPetani::TERVERIFIKASI; + $kembali = redirect()->route('monitoring-verifikasi.petani.verifikasi', ['sub' => $dariTerverifikasi ? 'terverifikasi' : 'lengkap']); + + if (! $dariTerverifikasi && $petani->status !== StatusDataPetani::LENGKAP) { + return $kembali->with('verifikasi_error', "Data petani {$petani->nama} sudah berstatus Draf."); + } + + $petani->status = StatusDataPetani::DRAFT; + $petani->catatan = trim($data['catatan']); + $petani->updated_by = auth()->id(); + $petani->save(); + + return $kembali->with('verifikasi_success', "Data petani {$petani->nama} dikembalikan ke Draf dengan catatan untuk enumerator."); + } +} diff --git a/app/Http/Middleware/BatasiAksesPetani.php b/app/Http/Middleware/BatasiAksesPetani.php index f48d6f8..f4dba05 100644 --- a/app/Http/Middleware/BatasiAksesPetani.php +++ b/app/Http/Middleware/BatasiAksesPetani.php @@ -3,6 +3,7 @@ namespace App\Http\Middleware; use App\Enums\Role; +use App\Enums\StatusDataPetani; use App\Models\Pertanian\Petani; use Closure; use Illuminate\Http\Request; @@ -12,6 +13,7 @@ use Symfony\Component\HttpFoundation\Response; * Dipasang di semua route modul data petani (petani/{petani}/...). * - Hanya role pengelola data petani yang boleh masuk. * - Koordinator hanya boleh mengakses petani dengan komoditas utama yang ditugaskan. + * - Petani berstatus Terverifikasi hanya bisa dilihat (simpan/hapus ditolak). */ class BatasiAksesPetani { @@ -30,9 +32,18 @@ class BatasiAksesPetani $petani = $request->route('petani'); - if ($petani !== null && $user->isKoordinator()) { - $petani = $petani instanceof Petani ? $petani : Petani::find($petani); + // Data terverifikasi dikunci: tidak bisa diubah, ditambah, atau dihapus (hanya dilihat). + // Perubahan status ditangani sendiri oleh PetaniStatusController. + $permintaanUbah = ! $request->isMethodSafe() && ! $request->routeIs('petani.status.*'); + // Petani hanya dimuat kalau perlu dicek (koordinator, atau permintaan simpan/hapus). + if ($petani === null || (! $user->isKoordinator() && ! $permintaanUbah)) { + return $next($request); + } + + $petani = $petani instanceof Petani ? $petani : Petani::find($petani); + + if ($user->isKoordinator()) { abort_unless( $petani && $user->bolehAksesPetani($petani), 403, @@ -40,6 +51,16 @@ class BatasiAksesPetani ); } + if ($permintaanUbah && $petani?->status === StatusDataPetani::TERVERIFIKASI) { + $pesan = 'Data petani sudah terverifikasi dan tidak dapat diubah atau dihapus.'; + + if ($request->expectsJson()) { + abort(403, $pesan); + } + + return back()->with('status_petani_error', $pesan); + } + return $next($request); } } diff --git a/app/Models/Concerns/KembalikanStatusPetaniKeDraf.php b/app/Models/Concerns/KembalikanStatusPetaniKeDraf.php index f3f8caa..64cea27 100644 --- a/app/Models/Concerns/KembalikanStatusPetaniKeDraf.php +++ b/app/Models/Concerns/KembalikanStatusPetaniKeDraf.php @@ -15,9 +15,9 @@ use Closure; */ trait KembalikanStatusPetaniKeDraf { - // Kolom otomatis/audit yang tidak dihitung sebagai perubahan data. + // Kolom otomatis/audit (dan catatan koordinator) yang tidak dihitung sebagai perubahan data. private static array $kolomBukanPerubahanData = [ - 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'deleted_by', 'status', + 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'deleted_by', 'status', 'catatan', ]; public static function bootKembalikanStatusPetaniKeDraf(): void diff --git a/app/Models/Pertanian/Petani.php b/app/Models/Pertanian/Petani.php index 48d13dd..041531e 100644 --- a/app/Models/Pertanian/Petani.php +++ b/app/Models/Pertanian/Petani.php @@ -422,6 +422,11 @@ class Petani extends Model return $this->belongsTo(User::class, 'created_by'); } + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + // Untuk trait KembalikanStatusPetaniKeDraf: petani ini sendiri. protected function petaniIdUntukStatus(): ?int { diff --git a/app/Services/Produktivitas/KelengkapanPetani.php b/app/Services/Produktivitas/KelengkapanPetani.php index 216093a..5462a3d 100644 --- a/app/Services/Produktivitas/KelengkapanPetani.php +++ b/app/Services/Produktivitas/KelengkapanPetani.php @@ -3,12 +3,24 @@ namespace App\Services\Produktivitas; use App\Enums\AksesPasar\PraktikFermentasi; +use App\Enums\AksesPasar\SkorKesadaranPremium; +use App\Enums\AksesPasar\SkorPengalaman; +use App\Enums\AksesPasar\TingkatKepercayaanPembeli; +use App\Enums\PraktikBudidaya\DasarPenentuanPanen; +use App\Enums\PraktikBudidaya\FrekuensiPemangkasanUtama; +use App\Enums\PraktikBudidaya\FrekuensiSanitasiLahan; +use App\Enums\PraktikBudidaya\KondisiPengelolaanAir; use App\Enums\PraktikBudidaya\MetodePenangananHamaPenyakit; use App\Enums\PraktikBudidaya\PemberianPupuk; +use App\Enums\PraktikBudidaya\PenangananGulma; +use App\Enums\PraktikBudidaya\PraktikSortasi; +use App\Enums\PraktikBudidaya\TingkatPanenReject; use App\Enums\SosialEkonomi\TerdaftarKelompokTani; use App\Models\Pertanian\Lahan; use App\Models\Pertanian\Petani; +use Closure; use Illuminate\Support\Collection; +use Illuminate\Support\Str; /** * Daftar isian Profil Petani yang WAJIB (dipakai skor Kesiapan Adopsi) @@ -113,30 +125,100 @@ class KelengkapanPetani ]; private const ISIAN_HARGA_JUAL = ['bentuk_produk', 'harga_panen_terakhir', 'harga_tahun_lalu', 'harga_dua_tahun_lalu']; + /* + * Kolom yang sumber isian skor / dashboard (lihat profil(), lahan(), ..., pertanyaanSkor()). + * Kolom di luar daftar ini dihitung sebagai "isian lainnya". + */ + private const PRIORITAS_PROFIL = ['jenis_kelamin', 'tanggal_lahir', 'pendidikan_terakhir', 'komoditas_id', 'desa_kelurahan_id']; + private const PRIORITAS_LAHAN = [ + 'desa_kelurahan_id', 'path', 'luas_lahan', 'jumlah_tanaman', 'kondisi_tanaman', 'keparahan_serangan', + 'jenis_hama', 'jenis_penyakit', 'umur_tanaman', + ]; + private const PRIORITAS_PRAKTIK_BUDIDAYA = [ + 'frekuensi_pemangkasan_utama', 'pemberian_pupuk', 'metode_penanganan_opt', 'jenis_hama', 'jenis_penyakit', + 'frekuensi_sanitasi_lahan', 'penanganan_gulma', 'kondisi_pengelolaan_air', 'sumber_air', + 'kebiasaan_konservasi_tanah', 'pemahaman_manfaat_konservasi_tanah', 'total_estimasi_biaya_produksi', + ]; + private const PRIORITAS_PANEN = ['dasar_penentuan_panen', 'hasil_komoditas_utama_permusim', 'praktik_sortasi', 'hasil_panen_reject']; + private const PRIORITAS_SOSIAL_EKONOMI = [ + 'jumlah_tanggungan', 'skor_periode_paceklik', 'skor_kemampuan_menabung', 'skor_kecukupan_penghasilan', + 'skor_kejelasan_status_lahan', 'kemauan_mencoba_praktik_baru', 'pemahaman_mutu_harga', 'panutan_petani_sukses', + 'kemauan_ikut_program', 'orientasi_rencana_jangka_panjang', 'terdaftar_kelompok_tani', + 'keaktifan_pertemuan_kelompok', 'peran_dalam_kelompok', 'manfaat_kelompok_tani', 'status_rekening_tabungan', + 'pengalaman_kredit_kebun', 'kondisi_aset_produktif', 'keterlibatan_program_keuangan_formal', + 'keterikatan_hutang_panen', 'praktik_ijon', 'kebebasan_memilih_pembeli', + ]; + private const PRIORITAS_PENDAPATAN_KOMODITAS = ['pendapatan']; + private const PRIORITAS_AKSES_PASAR = [ + 'tujuan_penjualan', 'sifat_hubungan_bisnis', 'frekuensi_penjualan', 'skor_pengalaman', 'praktik_fermentasi', + 'skor_kesadaran_premium', 'tingkat_kepercayaan_pembeli_baru', + ]; + + /** Kolom angka tanpa cast enum di model -> enum untuk teks jawabannya. */ + private const ENUM_JAWABAN = [ + 'frekuensi_pemangkasan_utama' => FrekuensiPemangkasanUtama::class, + 'pemberian_pupuk' => PemberianPupuk::class, + 'metode_penanganan_opt' => MetodePenangananHamaPenyakit::class, + 'frekuensi_sanitasi_lahan' => FrekuensiSanitasiLahan::class, + 'penanganan_gulma' => PenangananGulma::class, + 'kondisi_pengelolaan_air' => KondisiPengelolaanAir::class, + 'dasar_penentuan_panen' => DasarPenentuanPanen::class, + 'praktik_sortasi' => PraktikSortasi::class, + 'hasil_panen_reject' => TingkatPanenReject::class, + 'skor_pengalaman' => SkorPengalaman::class, + 'skor_kesadaran_premium' => SkorKesadaranPremium::class, + 'tingkat_kepercayaan_pembeli_baru' => TingkatKepercayaanPembeli::class, + ]; + + /** Kolom berkas/peta: cukup ditampilkan "Sudah diunggah". */ + private const KOLOM_BERKAS = ['foto', 'path', 'path_kk', 'path_ktp']; + + /** Label isian lainnya yang tidak cukup dibaca dari nama kolom. */ + private const LABEL_KOLOM = [ + 'ktp' => 'NIK', 'kk' => 'No. KK', 'path_kk' => 'Berkas KK', 'path_ktp' => 'Berkas KTP', + 'path' => 'Peta/polygon lahan', 'komoditas_id' => 'Komoditas', 'jenis_komoditas_id' => 'Jenis komoditas', + 'varietas_id' => 'Varietas', 'desa_kelurahan_id' => 'Desa/kelurahan', 'is_aktif' => 'Sertifikat masih aktif', + ]; + + /** Jumlah pertanyaan yang dinilai di skor Kesiapan Adopsi (lihat pertanyaanSkor()). */ + public const JUMLAH_PERTANYAAN_SKOR = 32; + + /** Relasi yang dibaca; eager-load ini di daftar petani supaya tidak N+1. */ + public const RELASI = [ + 'komoditas', + 'lahans', + 'praktikBudidaya', + 'surveyPanen', + 'surveyProduktivitas', + 'surveyPelatihan', + 'surveySertifikasi', + 'sosialEkonomi.pendapatanKomoditas', + 'sosialEkonomi.pendapatanLuarPertanian', + 'aksesPasar', + 'hargaJualPanen', + ]; + /** * Satu instance per objek Petani, supaya header dan ringkasan tidak memuat/menghitung ulang. * WeakMap: otomatis dibuang saat objek Petani tidak dipakai lagi (tidak menumpuk antar-request). */ private static ?\WeakMap $cache = null; + // Hasil hitung disimpan per instance (header, tabel, & kartu rincian memakai instance yang sama). + private ?Collection $semuaItem = null; private ?Collection $semuaKosong = null; + private ?Collection $isianDashboard = null; private ?array $hitungIsian = null; + private ?array $pertanyaanSkor = null; + private ?array $ringkasanInput = null; + + /** Diisi hanya saat rincianLainnya() dipanggil (side panel), supaya daftar petani tetap ringan. */ + private ?array $rincianLainnya = null; + private string $grupRincian = ''; public function __construct(private Petani $petani) { - $petani->loadMissing([ - 'komoditas', - 'lahans', - 'praktikBudidaya', - 'surveyPanen', - 'surveyProduktivitas', - 'surveyPelatihan', - 'surveySertifikasi', - 'sosialEkonomi.pendapatanKomoditas', - 'sosialEkonomi.pendapatanLuarPertanian', - 'aksesPasar', - 'hargaJualPanen', - ]); + $petani->loadMissing(self::RELASI); } public static function untuk(Petani $petani): self @@ -161,19 +243,256 @@ class KelengkapanPetani return $this->hitungIsian ??= $this->hitungSemuaIsian(); } + /** + * Ringkasan untuk tabel Monitoring: masing-masing ['terisi' => int, 'total' => int]. + * - skor: 32 pertanyaan skor Kesiapan Adopsi + * - wajib: isian yang dipakai dashboard (prioritas PERLU) + * - lainnya: isian lain di luar keduanya (yang tidak berlaku tidak dihitung) + */ + public function ringkasanInput(): array + { + if ($this->ringkasanInput !== null) { + return $this->ringkasanInput; + } + + $skor = collect($this->pertanyaanSkor()); + $dashboard = $this->isianDashboard(); + $lainnya = $this->hitungIsian()['lainnya']; + + return $this->ringkasanInput = [ + 'skor' => ['terisi' => $skor->where('terisi', true)->count(), 'total' => $skor->count()], + 'wajib' => ['terisi' => $dashboard->where('terisi', true)->count(), 'total' => $dashboard->count()], + 'lainnya' => ['terisi' => $lainnya['terisi'], 'total' => $lainnya['total'] - $lainnya['tidak_berlaku']], + ]; + } + + /** Warna badge hitungan ['terisi','total']: hijau lengkap, kuning sebagian, merah kosong. */ + public static function warna(array $hitungan): string + { + return match (true) { + $hitungan['total'] > 0 && $hitungan['terisi'] >= $hitungan['total'] => 'success', + $hitungan['terisi'] > 0 => 'warning', + default => 'danger', + }; + } + + /** + * Rincian pertanyaan → jawaban untuk side panel Monitoring. + * Setiap baris: grup, label, jawaban (string|null = belum diisi), terisi. + * + * @param 'skor'|'wajib'|'lainnya' $jenis + */ + public function rincian(string $jenis): Collection + { + return match ($jenis) { + 'skor' => collect($this->pertanyaanSkor()), + 'wajib' => $this->isianDashboard()->map(fn (array $item) => [ + 'grup' => $item['grup'] ?? self::TAB_LABEL[$item['tab']], + 'label' => $item['label'], + 'jawaban' => isset($item['jawaban']) ? ($item['jawaban'])() : self::jawaban($item['nilai'], $item['input']), + 'terisi' => $item['terisi'], + ]), + 'lainnya' => $this->rincianLainnya(), + }; + } + + /** Isian PERLU (dashboard) dari semua tab, tiap item diberi kunci 'tab'. */ + private function isianDashboard(): Collection + { + return $this->isianDashboard ??= $this->semuaItem() + ->flatMap(fn (array $items, string $tab) => array_map(fn ($item) => [...$item, 'tab' => $tab], $items)) + ->where('prioritas', self::PERLU) + ->values(); + } + + /** + * 32 pertanyaan skor (petani_skor): sudah bisa dinilai atau belum, beserta jawabannya. + * Pertanyaan lanjutan yang tidak berlaku (mis. data kelompok kalau bukan anggota) dianggap sudah terjawab. + */ + private function pertanyaanSkor(): array + { + return $this->pertanyaanSkor ??= $this->susunPertanyaanSkor(); + } + + private function susunPertanyaanSkor(): array + { + $p = $this->petani; + $pb = $p->praktikBudidaya; + $panen = $p->surveyPanen; + $se = $p->sosialEkonomi; + $ap = $p->aksesPasar; + + $tanya = fn (string $grup, string $label, ?object $model, string $kolom) => [ + 'grup' => $grup, + 'label' => $label, + 'jawaban' => self::jawaban($model?->{$kolom}, $kolom), + 'terisi' => $model && self::terisi($model->{$kolom}), + ]; + + // Data kelompok hanya ditanyakan ke anggota. + $terdaftar = self::nilaiEnum($se?->terdaftar_kelompok_tani); + $bukanAnggota = self::terisi($terdaftar) && $terdaftar !== TerdaftarKelompokTani::YA->value; + $kelompok = fn (string $label, string $kolom) => $bukanAnggota + ? ['grup' => 'Sosial Ekonomi', 'label' => $label, 'jawaban' => 'Tidak berlaku (bukan anggota kelompok tani)', 'terisi' => true] + : [...$tanya('Sosial Ekonomi', $label, $se, $kolom), 'terisi' => self::terisi($terdaftar) && $se && self::terisi($se->{$kolom})]; + + // Konservasi: pemahaman manfaat hanya ditanyakan kalau ada praktik konservasi. + $konservasi = (array) ($pb?->kebiasaan_konservasi_tanah ?? []); + $adaKonservasi = collect($konservasi)->contains(fn ($v) => $v !== self::KONSERVASI_TIDAK_ADA); + + // Rasio biaya produksi : pendapatan. + $biaya = $pb?->total_estimasi_biaya_produksi; + $pendapatan = (int) ($se?->pendapatanKomoditas->sum('pendapatan') ?? 0); + + return [ + // Sosial Ekonomi (20) + $tanya('Sosial Ekonomi', 'Kemauan mencoba praktik baru', $se, 'kemauan_mencoba_praktik_baru'), + $tanya('Sosial Ekonomi', 'Pemahaman mutu & harga', $se, 'pemahaman_mutu_harga'), + $tanya('Sosial Ekonomi', 'Panutan petani sukses', $se, 'panutan_petani_sukses'), + $tanya('Sosial Ekonomi', 'Kemauan ikut program', $se, 'kemauan_ikut_program'), + $tanya('Sosial Ekonomi', 'Orientasi rencana jangka panjang', $se, 'orientasi_rencana_jangka_panjang'), + $tanya('Sosial Ekonomi', 'Periode paceklik', $se, 'skor_periode_paceklik'), + $tanya('Sosial Ekonomi', 'Kemampuan menabung', $se, 'skor_kemampuan_menabung'), + $tanya('Sosial Ekonomi', 'Kecukupan penghasilan', $se, 'skor_kecukupan_penghasilan'), + [ + 'grup' => 'Sosial Ekonomi', + 'label' => 'Rasio biaya produksi terhadap pendapatan', + 'jawaban' => self::terisi($biaya) || $pendapatan > 0 + ? 'Biaya ' . self::rupiah($biaya) . ' / Pendapatan ' . self::rupiah($pendapatan) + : null, + 'terisi' => self::terisi($biaya) && $pendapatan > 0, + ], + $tanya('Sosial Ekonomi', 'Kejelasan status lahan', $se, 'skor_kejelasan_status_lahan'), + $kelompok('Keaktifan pertemuan kelompok', 'keaktifan_pertemuan_kelompok'), + $kelompok('Peran dalam kelompok', 'peran_dalam_kelompok'), + $kelompok('Manfaat kelompok tani', 'manfaat_kelompok_tani'), + $tanya('Sosial Ekonomi', 'Status rekening tabungan', $se, 'status_rekening_tabungan'), + $tanya('Sosial Ekonomi', 'Pengalaman kredit kebun', $se, 'pengalaman_kredit_kebun'), + $tanya('Sosial Ekonomi', 'Kondisi aset produktif', $se, 'kondisi_aset_produktif'), + $tanya('Sosial Ekonomi', 'Keterlibatan program keuangan formal', $se, 'keterlibatan_program_keuangan_formal'), + $tanya('Sosial Ekonomi', 'Keterikatan hutang panen', $se, 'keterikatan_hutang_panen'), + $tanya('Sosial Ekonomi', 'Praktik ijon', $se, 'praktik_ijon'), + $tanya('Sosial Ekonomi', 'Kebebasan memilih pembeli', $se, 'kebebasan_memilih_pembeli'), + + // Praktik Budidaya (7) + $tanya('Praktik Budidaya', 'Frekuensi pemangkasan', $pb, 'frekuensi_pemangkasan_utama'), + $tanya('Praktik Budidaya', 'Pemberian pupuk', $pb, 'pemberian_pupuk'), + $tanya('Praktik Budidaya', 'Metode penanganan hama & penyakit', $pb, 'metode_penanganan_opt'), + $tanya('Praktik Budidaya', 'Frekuensi sanitasi lahan', $pb, 'frekuensi_sanitasi_lahan'), + $tanya('Praktik Budidaya', 'Penanganan gulma', $pb, 'penanganan_gulma'), + $tanya('Praktik Budidaya', 'Kondisi pengelolaan air & irigasi', $pb, 'kondisi_pengelolaan_air'), + [ + 'grup' => 'Praktik Budidaya', + 'label' => 'Praktik konservasi tanah', + 'jawaban' => self::jawaban(array_filter([ + self::jawaban($konservasi), + $adaKonservasi ? self::jawaban($pb?->pemahaman_manfaat_konservasi_tanah) : null, + ]), null, ' · '), + 'terisi' => self::terisi($konservasi) && (! $adaKonservasi || self::terisi($pb?->pemahaman_manfaat_konservasi_tanah)), + ], + + // Panen (2) + $tanya('Panen', 'Dasar penentuan panen', $panen, 'dasar_penentuan_panen'), + $tanya('Panen', 'Praktik sortasi', $panen, 'praktik_sortasi'), + + // Akses Pasar (3); hambatan mutu dari tingkat hasil panen ditolak pembeli + $tanya('Akses Pasar', 'Pengalaman menjual ke saluran lain', $ap, 'skor_pengalaman'), + $tanya('Akses Pasar', 'Kesadaran pasar premium', $ap, 'skor_kesadaran_premium'), + $tanya('Akses Pasar', 'Hambatan mutu (hasil panen ditolak pembeli)', $panen, 'hasil_panen_reject'), + ]; + } + + /** + * Ubah nilai isian jadi teks jawaban. null = belum diisi. + * Kolom yang disimpan sebagai angka (tanpa cast enum di model) diterjemahkan lewat ENUM_JAWABAN. + */ + public static function jawaban(mixed $nilai, ?string $kolom = null, string $pemisah = ', '): ?string + { + if (! self::terisi($nilai)) { + return null; + } + if ($kolom && in_array($kolom, self::KOLOM_BERKAS, true)) { + return 'Sudah diunggah'; + } + if ($nilai instanceof Collection) { + $nilai = $nilai->all(); + } + if (is_array($nilai)) { + $isi = array_filter(array_map(fn ($v) => self::jawaban($v, $kolom), $nilai)); + + return $isi ? implode($pemisah, $isi) : null; + } + if ($kolom && isset(self::ENUM_JAWABAN[$kolom]) && is_numeric($nilai)) { + $nilai = self::ENUM_JAWABAN[$kolom]::tryFrom((int) $nilai) ?? $nilai; + } + // Kolom uang (biaya, upah, harga, hutang, tabungan, pendapatan) → "Rp 1.500.000". + if ($kolom && is_numeric($nilai) && preg_match('/biaya|upah|harga|hutang_rupiah|tabungan|pendapatan|estimasi_per_bulan/', $kolom)) { + return self::rupiah($nilai); + } + + return match (true) { + $nilai instanceof \BackedEnum => self::labelEnum($nilai), + $nilai instanceof \DateTimeInterface => $nilai->format('d-m-Y'), + is_bool($nilai) => $nilai ? 'Ya' : 'Tidak', + is_object($nilai) => json_encode($nilai), + default => (string) $nilai, + }; + } + + /** Label enum: dari label(), atau dari options() untuk enum yang hanya punya daftar opsi. */ + private static function labelEnum(\BackedEnum $enum): string + { + if (method_exists($enum, 'label')) { + return $enum->label(); + } + + $opsi = method_exists($enum, 'options') ? collect($enum::options())->firstWhere('value', $enum->value) : null; + + return $opsi['label'] ?? (string) $enum->value; + } + + private static function rupiah(mixed $nilai): string + { + return self::terisi($nilai) ? 'Rp ' . number_format((float) $nilai, 0, ',', '.') : '-'; + } + + /** "Biji kering: Rp 65.000, Fermentasi: Rp 80.000" dari baris harga jual yang terisi. */ + private static function jawabanHarga(Collection $harga, string $kolom): ?string + { + return self::jawaban($harga->filter(fn ($h) => (float) $h->{$kolom} > 0) + ->map(fn ($h) => trim(($h->bentuk_produk ? $h->bentuk_produk . ': ' : '') . self::rupiah($h->{$kolom}))) + ->all()); + } + + /** Isian lainnya (di luar skor & dashboard) beserta jawabannya; tidak termasuk yang tidak berlaku. */ + private function rincianLainnya(): Collection + { + $this->rincianLainnya = []; + $this->hitungSemuaIsian(); + $hasil = collect($this->rincianLainnya)->map(fn (array $r) => [...$r, 'jawaban' => ($r['jawaban'])()]); + $this->rincianLainnya = null; + + return $hasil; + } + private function hitungSemuaIsian(): array { - $hasil = ['terisi' => 0, 'total' => 0, 'tidak_berlaku' => 0]; + $hasil = [ + 'terisi' => 0, 'total' => 0, 'tidak_berlaku' => 0, + 'lainnya' => ['terisi' => 0, 'total' => 0, 'tidak_berlaku' => 0], + ]; $p = $this->petani; - // Profil (+ provinsi/kabupaten/kecamatan yang mengikuti desa) - $this->hitungKolom($hasil, $p, self::ISIAN_PROFIL); + // Profil (+ provinsi/kabupaten/kecamatan yang mengikuti desa; termasuk prioritas) + $this->grupRincian = 'Profil'; + $this->hitungKolom($hasil, $p, self::ISIAN_PROFIL, [], self::PRIORITAS_PROFIL); $this->hitungNilai($hasil, 3, self::terisi($p->desa_kelurahan_id)); // Lahan & Tanaman, per lahan. Belum ada lahan = dihitung 1 lahan kosong. $daftarLahan = $p->lahans->isEmpty() ? collect([null]) : $p->lahans; - foreach ($daftarLahan as $lahan) { - $this->hitungKolom($hasil, $lahan, self::ISIAN_LAHAN); + foreach ($daftarLahan->values() as $i => $lahan) { + $this->grupRincian = $lahan?->nama ?: 'Lahan ' . ($i + 1); + $this->hitungKolom($hasil, $lahan, self::ISIAN_LAHAN, [], self::PRIORITAS_LAHAN); $this->hitungNilai($hasil, 3, self::terisi($lahan?->desa_kelurahan_id)); } @@ -190,11 +509,14 @@ class KelengkapanPetani if ((int) $pb?->metode_penanganan_opt === MetodePenangananHamaPenyakit::TIDAK_ADA->value) { array_push($tidakBerlakuPb, 'jenis_hama', 'jenis_penyakit'); } - $this->hitungKolom($hasil, $pb, self::ISIAN_PRAKTIK_BUDIDAYA, $tidakBerlakuPb); - $this->hitungKolom($hasil, $p->surveyPanen, self::ISIAN_PANEN); + $this->grupRincian = 'Praktik Budidaya'; + $this->hitungKolom($hasil, $pb, self::ISIAN_PRAKTIK_BUDIDAYA, $tidakBerlakuPb, self::PRIORITAS_PRAKTIK_BUDIDAYA); + $this->grupRincian = 'Panen & Produktivitas'; + $this->hitungKolom($hasil, $p->surveyPanen, self::ISIAN_PANEN, [], self::PRIORITAS_PANEN); $this->hitungRepeater($hasil, $p->surveyProduktivitas, self::ISIAN_PRODUKTIVITAS); // Pendidikan & Pelatihan + $this->grupRincian = 'Pendidikan & Pelatihan'; $this->hitungRepeater($hasil, $p->surveyPelatihan, self::ISIAN_PELATIHAN); $this->hitungRepeater($hasil, $p->surveySertifikasi, self::ISIAN_SERTIFIKASI); @@ -204,9 +526,10 @@ class KelengkapanPetani ? ['nama_kelompok_tani', 'lama_bergabung_kelompok_tahun', 'kedekatan_sesama_anggota', 'keaktifan_pertemuan_kelompok', 'topik_biasa_dibahas', 'peran_dalam_kelompok', 'manfaat_kelompok_tani'] : []; - $this->hitungKolom($hasil, $se, self::ISIAN_SOSIAL_EKONOMI, $tidakBerlakuSe); + $this->grupRincian = 'Sosial Ekonomi'; + $this->hitungKolom($hasil, $se, self::ISIAN_SOSIAL_EKONOMI, $tidakBerlakuSe, self::PRIORITAS_SOSIAL_EKONOMI); $this->hitungRepeater($hasil, collect($se?->detail_bantuan_ngo_perusahaan ?? []), self::ISIAN_BANTUAN_NGO); - $this->hitungRepeater($hasil, $se?->pendapatanKomoditas ?? collect(), self::ISIAN_PENDAPATAN_KOMODITAS); + $this->hitungRepeater($hasil, $se?->pendapatanKomoditas ?? collect(), self::ISIAN_PENDAPATAN_KOMODITAS, self::PRIORITAS_PENDAPATAN_KOMODITAS); $this->hitungRepeater($hasil, $se?->pendapatanLuarPertanian ?? collect(), self::ISIAN_PENDAPATAN_LUAR); // Akses Pasar (detail fermentasi tidak berlaku kalau belum pernah) @@ -214,8 +537,9 @@ class KelengkapanPetani $tidakBerlakuAp = self::nilaiEnum($ap?->praktik_fermentasi) === PraktikFermentasi::BELUM_PERNAH->value ? ['proses_fermentasi', 'model_fermentasi_memungkinkan'] : []; - $this->hitungKolom($hasil, $ap, self::ISIAN_AKSES_PASAR, $tidakBerlakuAp); - $this->hitungRepeater($hasil, $p->hargaJualPanen, self::ISIAN_HARGA_JUAL); + $this->grupRincian = 'Akses Pasar'; + $this->hitungKolom($hasil, $ap, self::ISIAN_AKSES_PASAR, $tidakBerlakuAp, self::PRIORITAS_AKSES_PASAR); + $this->hitungRepeater($hasil, $p->hargaJualPanen, self::ISIAN_HARGA_JUAL, self::ISIAN_HARGA_JUAL); $hasil['berlaku'] = $hasil['total'] - $hasil['tidak_berlaku']; $hasil['persen'] = $hasil['berlaku'] > 0 ? (int) floor($hasil['terisi'] / $hasil['berlaku'] * 100) : 0; @@ -228,34 +552,79 @@ class KelengkapanPetani return $nilai instanceof \BackedEnum ? $nilai->value : $nilai; } - private function hitungKolom(array &$hasil, ?object $model, array $kolom, array $tidakBerlaku = []): void + /** + * $prioritas: kolom sumber skor/dashboard. Kolom lain juga dicatat di $hasil['lainnya']. + */ + private function hitungKolom(array &$hasil, ?object $model, array $kolom, array $tidakBerlaku = [], array $prioritas = []): void { foreach ($kolom as $k) { - $hasil['total']++; - - if (in_array($k, $tidakBerlaku, true)) { - $hasil['tidak_berlaku']++; - } elseif ($model && self::terisi($model->{$k})) { - $hasil['terisi']++; - } + $status = match (true) { + in_array($k, $tidakBerlaku, true) => 'tidak_berlaku', + $model && self::terisi($model->{$k}) => 'terisi', + default => null, + }; + $this->catat($hasil, $status, ! in_array($k, $prioritas, true), $k, fn () => self::jawabanKolom($model, $k)); } } + /** Wilayah (provinsi/kabupaten/kecamatan) yang mengikuti desa; termasuk isian prioritas. */ private function hitungNilai(array &$hasil, int $jumlah, bool $terisi): void { - $hasil['total'] += $jumlah; - $hasil['terisi'] += $terisi ? $jumlah : 0; + for ($i = 0; $i < $jumlah; $i++) { + $this->catat($hasil, $terisi ? 'terisi' : null, false); + } } /** Kolom repeater dianggap terisi kalau minimal satu baris mengisinya. */ - private function hitungRepeater(array &$hasil, Collection $baris, array $kolom): void + private function hitungRepeater(array &$hasil, Collection $baris, array $kolom, array $prioritas = []): void { foreach ($kolom as $k) { - $hasil['total']++; - $hasil['terisi'] += $baris->contains(fn ($row) => self::terisi(data_get($row, $k))) ? 1 : 0; + $terisi = $baris->contains(fn ($row) => self::terisi(data_get($row, $k))); + $this->catat($hasil, $terisi ? 'terisi' : null, ! in_array($k, $prioritas, true), $k, fn () => self::jawaban( + $baris->map(fn ($row) => is_object($row) ? self::jawabanKolom($row, $k) : self::jawaban(data_get($row, $k), $k))->unique()->all() + )); } } + /** + * $status: 'terisi', 'tidak_berlaku', atau null (kosong). + * $jawaban dipanggil hanya saat rincian lainnya dikumpulkan. + */ + private function catat(array &$hasil, ?string $status, bool $lainnya, ?string $kolom = null, ?Closure $jawaban = null): void + { + $hasil['total']++; + $lainnya && $hasil['lainnya']['total']++; + + if ($status) { + $hasil[$status]++; + $lainnya && $hasil['lainnya'][$status]++; + } + + if ($lainnya && $kolom && $this->rincianLainnya !== null && $status !== 'tidak_berlaku') { + $this->rincianLainnya[] = [ + 'grup' => $this->grupRincian, + 'label' => self::LABEL_KOLOM[$kolom] ?? Str::ucfirst(str_replace('_', ' ', $kolom)), + 'jawaban' => $jawaban, + 'terisi' => $status === 'terisi', + ]; + } + } + + /** Jawaban satu kolom model; kolom *_id ditampilkan nama relasinya (mis. komoditas_id -> komoditas). */ + private static function jawabanKolom(?object $model, string $kolom): ?string + { + if (! $model) { + return null; + } + + $relasi = str_ends_with($kolom, '_id') ? Str::camel(substr($kolom, 0, -3)) : null; + if ($relasi && self::terisi($model->{$kolom}) && method_exists($model, $relasi)) { + return $model->{$relasi}?->nama ?? (string) $model->{$kolom}; + } + + return self::jawaban($model->{$kolom}, $kolom); + } + /** * Isian yang masih kosong di satu tab. * Setiap item: label, prioritas (wajib/perlu), input (atribut name di form), @@ -266,33 +635,30 @@ class KelengkapanPetani return $this->semuaKosong()->get($tab, collect()); } - private function hitungKosong(string $tab): Collection + /** Isian kosong semua tab, dikelompokkan per tab (untuk tab Kesiapan Adopsi). */ + public function semuaKosong(): Collection { - $isian = match ($tab) { + return $this->semuaKosong ??= $this->semuaItem()->map(fn (array $isian) => collect($isian) + ->reject(fn (array $item) => $item['terisi']) + ->map(fn (array $item) => collect($item)->except('terisi')->all()) + ->values()); + } + + /** Semua isian WAJIB/PERLU (terisi maupun kosong), per tab. */ + private function semuaItem(): Collection + { + return $this->semuaItem ??= collect([ 'profil' => $this->profil(), 'lahan' => $this->lahan(), 'praktik-budidaya' => $this->praktikBudidaya(), 'sosial-ekonomi' => $this->sosialEkonomi(), 'akses-pasar' => $this->aksesPasar(), - default => [], - }; - - return collect($isian) - ->reject(fn (array $item) => $item['terisi']) - ->map(fn (array $item) => collect($item)->except('terisi')->all()) - ->values(); - } - - /** Isian kosong semua tab, dikelompokkan per tab (untuk tab Kesiapan Adopsi). */ - public function semuaKosong(): Collection - { - return $this->semuaKosong ??= collect(array_keys(self::TAB_LABEL)) - ->mapWithKeys(fn (string $tab) => [$tab => $this->hitungKosong($tab)]); + ])->map(fn (array $isian) => array_values($isian)); } private function item(string $label, string $prioritas, string $input, mixed $nilai, array $tambahan = []): array { - return $this->itemCek($label, $prioritas, $input, self::terisi($nilai), $tambahan); + return $this->itemCek($label, $prioritas, $input, self::terisi($nilai), ['nilai' => $nilai, ...$tambahan]); } /** Untuk isian yang status terisinya dihitung sendiri (mis. repeater). */ @@ -303,6 +669,7 @@ class KelengkapanPetani 'prioritas' => $prioritas, 'input' => $input, 'terisi' => $terisi, + 'nilai' => null, 'grup' => null, 'lahan_id' => null, 'url' => null, @@ -324,11 +691,17 @@ class KelengkapanPetani $p = $this->petani; return [ - $this->item('Jenis kelamin', self::PERLU, 'jenis_kelamin', $p->jenis_kelamin), + $this->item('Jenis kelamin', self::PERLU, 'jenis_kelamin', $p->jenis_kelamin, [ + 'jawaban' => fn () => is_null($p->jenis_kelamin) ? null : ($p->jenis_kelamin ? 'Laki-laki' : 'Perempuan'), + ]), $this->item('Tanggal lahir', self::PERLU, 'tanggal_lahir', $p->tanggal_lahir), $this->item('Pendidikan terakhir', self::PERLU, 'pendidikan_terakhir', $p->pendidikan_terakhir), - $this->item('Komoditas utama', self::PERLU, 'komoditas_id', $p->komoditas_id), - $this->item('Desa/kelurahan', self::PERLU, 'desa_kelurahan_id', $p->desa_kelurahan_id), + $this->item('Komoditas utama', self::PERLU, 'komoditas_id', $p->komoditas_id, [ + 'jawaban' => fn () => self::jawabanKolom($p, 'komoditas_id'), + ]), + $this->item('Desa/kelurahan', self::PERLU, 'desa_kelurahan_id', $p->desa_kelurahan_id, [ + 'jawaban' => fn () => self::jawabanKolom($p, 'desa_kelurahan_id'), + ]), ]; } @@ -356,7 +729,9 @@ class KelengkapanPetani return array_filter([ $this->item('Luas lahan', self::PERLU, 'luas_lahan', $l->luas_lahan, $tambahan), $this->item('Peta/polygon lahan', self::PERLU, 'path', $l->path, $tambahan), - $this->item('Desa lahan', self::PERLU, 'desa_kelurahan_id', $l->desa_kelurahan_id, $tambahan), + $this->item('Desa lahan', self::PERLU, 'desa_kelurahan_id', $l->desa_kelurahan_id, [ + ...$tambahan, 'jawaban' => fn () => self::jawabanKolom($l, 'desa_kelurahan_id'), + ]), $this->item('Umur tanaman', self::PERLU, 'umur_tanaman', $l->umur_tanaman, $tambahan), $this->item('Jumlah tanaman', self::PERLU, 'jumlah_tanaman', $l->jumlah_tanaman, $tambahan), $this->item('Kondisi tanaman', self::PERLU, 'kondisi_tanaman', $l->kondisi_tanaman, $tambahan), @@ -457,11 +832,14 @@ class KelengkapanPetani $this->item('Kesadaran pasar premium', self::WAJIB, 'skor_kesadaran_premium', $ap?->skor_kesadaran_premium), $this->item('Kepercayaan pada pembeli baru', self::WAJIB, 'tingkat_kepercayaan_pembeli_baru', $ap?->tingkat_kepercayaan_pembeli_baru), $this->itemCek('Harga jual panen terakhir', self::PERLU, 'harga_jual', - $harga->contains(fn ($h) => self::terisi($h->bentuk_produk) && (float) $h->harga_panen_terakhir > 0)), + $harga->contains(fn ($h) => self::terisi($h->bentuk_produk) && (float) $h->harga_panen_terakhir > 0), + ['jawaban' => fn () => self::jawabanHarga($harga, 'harga_panen_terakhir')]), $this->itemCek('Harga jual tahun lalu', self::PERLU, 'harga_jual', - $harga->contains(fn ($h) => (float) $h->harga_tahun_lalu > 0)), + $harga->contains(fn ($h) => (float) $h->harga_tahun_lalu > 0), + ['jawaban' => fn () => self::jawabanHarga($harga, 'harga_tahun_lalu')]), $this->itemCek('Harga jual dua tahun lalu', self::PERLU, 'harga_jual', - $harga->contains(fn ($h) => (float) $h->harga_dua_tahun_lalu > 0)), + $harga->contains(fn ($h) => (float) $h->harga_dua_tahun_lalu > 0), + ['jawaban' => fn () => self::jawabanHarga($harga, 'harga_dua_tahun_lalu')]), ]; } } diff --git a/database/migrations/2026_11_05_100000_tambah_catatan_ke_petani_table.php b/database/migrations/2026_11_05_100000_tambah_catatan_ke_petani_table.php new file mode 100644 index 0000000..7ec6764 --- /dev/null +++ b/database/migrations/2026_11_05_100000_tambah_catatan_ke_petani_table.php @@ -0,0 +1,25 @@ +text('catatan')->nullable(); + }); + } + + public function down(): void + { + Schema::table('petani', function (Blueprint $table) { + $table->dropColumn('catatan'); + }); + } +}; diff --git a/resources/views/components/kunci-petani-terverifikasi.blade.php b/resources/views/components/kunci-petani-terverifikasi.blade.php new file mode 100644 index 0000000..1282270 --- /dev/null +++ b/resources/views/components/kunci-petani-terverifikasi.blade.php @@ -0,0 +1,58 @@ +{{-- + Data petani Terverifikasi: halaman hanya untuk dilihat. + Semua tombol simpan/tambah/hapus dan isian form dinonaktifkan (server juga menolak, lihat BatasiAksesPetani). +--}} + + +@once + @push('style') + + @endpush + + @push('scripts') + + @endpush +@endonce diff --git a/resources/views/components/status-petani.blade.php b/resources/views/components/status-petani.blade.php index 4fee27f..0e7cca3 100644 --- a/resources/views/components/status-petani.blade.php +++ b/resources/views/components/status-petani.blade.php @@ -112,13 +112,19 @@ ...gayaTombolSwal(), }; - // Tandai Lengkap: wajib mencentang pernyataan sebelum bisa lanjut. + // Tandai Lengkap: tampilkan pernyataan; menekan tombol konfirmasi berarti menyetujuinya. if (pernyataan) { - opsi.input = 'checkbox'; - opsi.inputValue = 0; - opsi.inputPlaceholder = pernyataan; - opsi.inputValidator = (dicentang) => !dicentang && 'Centang pernyataan terlebih dahulu.'; - opsi.customClass.input = 'text-start small'; + const isi = document.createElement('div'); + const kotak = document.createElement('div'); + kotak.className = 'border rounded p-3 mb-3 text-start small'; + kotak.textContent = pernyataan; + const catatan = document.createElement('p'); + catatan.className = 'mb-0'; + catatan.textContent = form.dataset.konfirmasi; + isi.append(kotak, catatan); + + delete opsi.text; + opsi.html = isi; } Swal.fire(opsi).then((r) => { if (r.isConfirmed) kirim(); }); diff --git a/resources/views/layout/sidebar.blade.php b/resources/views/layout/sidebar.blade.php index 6574493..118bc09 100644 --- a/resources/views/layout/sidebar.blade.php +++ b/resources/views/layout/sidebar.blade.php @@ -125,6 +125,24 @@ @endif + + {{-- Menu Verifikasi: hanya super_admin, admin, koordinator (enumerator tidak). --}} + @if($isAdmin || $isKoordinator) + + @endif @endif @if($isAdmin && $appName === 'AgroPro') diff --git a/resources/views/monitoring-verifikasi/enumerator.blade.php b/resources/views/monitoring-verifikasi/enumerator.blade.php new file mode 100644 index 0000000..7a3fa82 --- /dev/null +++ b/resources/views/monitoring-verifikasi/enumerator.blade.php @@ -0,0 +1,119 @@ +@use('App\Services\Produktivitas\KelengkapanPetani') + +@if (($bagian ?? 'tabel') === 'rincian') + {{-- ============ Isi kartu rincian: petani yang diinput enumerator ini ============ --}} +
+
+ {{ Str::upper(Str::substr($user->name, 0, 2)) }} +
+
+
{{ $user->name }}
+ {{ $daftarPetani->count() }} petani diinput +
+
+ +
+ + + + + + + + + + + + @forelse ($daftarPetani as $item) + @php $r = $ringkasan[$item->id]; @endphp + + + + @foreach (['skor', 'wajib', 'lainnya'] as $jenis) + + @endforeach + + @empty + + + + @endforelse + +
NoNama PetaniSkorWajibLainnya
{{ $loop->iteration }} + {{ $item->nama }} + {{ $item->komoditas?->nama ?? '-' }} + + {{ $r[$jenis]['terisi'] }}/{{ $r[$jenis]['total'] }} +
Belum ada petani.
+
+@else + {{-- ============ Tabel enumerator; klik jumlah petani → kartu rincian ============ --}} +
+
+
+ +
+
+ + + +
+
+
+
+ + +
+
+
+ +
+ + + + + + + + + + @forelse ($enumerator as $index => $user) + + + + + + @empty + + + + @endforelse + +
NoEnumeratorJumlah Petani
{{ $enumerator->firstItem() + $index }} + {{ $user->name }} + {{ $user->email }} + + @if ($user->jumlah_petani > 0) + + @else + 0 petani + @endif +
Tidak ada data ditemukan.
+
+ +
+ {{ $enumerator->links('pagination::bootstrap-5') }} +
+
+ + +
+@endif diff --git a/resources/views/monitoring-verifikasi/index.blade.php b/resources/views/monitoring-verifikasi/index.blade.php new file mode 100644 index 0000000..172c377 --- /dev/null +++ b/resources/views/monitoring-verifikasi/index.blade.php @@ -0,0 +1,195 @@ +@extends('layout.main') + +@php + $menu = $menu ?? 'petani'; + + $tabs = [ + 'monitoring' => ['label' => 'Monitoring', 'ikon' => 'tabler-chart-dots'], + 'verifikasi' => ['label' => 'Verifikasi', 'ikon' => 'tabler-rosette-discount-check'], + ]; +@endphp + +@section('content') +
+ {{-- Vuexy: justified pills (nav-fill) → tiap tab sama lebar. --}} + + + {{-- Sub-tab: halaman turunan cukup mendefinisikan $idSubTab & $subTabs (label, ikon, view, jumlah opsional). --}} + @isset($subTabs) + @php $subTabAktif = array_key_exists(request('sub'), $subTabs) ? request('sub') : array_key_first($subTabs); @endphp + + @else +
+
+ + + +

Konten tab belum dibuat.

+
+
+ @endisset + + {{-- Kartu rincian: disalin otomatis ke setiap .kolom-detail yang masih kosong. --}} + +
+@endsection + +@push('style') + +@endpush + +@push('scripts') + +@endpush diff --git a/resources/views/monitoring-verifikasi/monitoring.blade.php b/resources/views/monitoring-verifikasi/monitoring.blade.php new file mode 100644 index 0000000..de24e01 --- /dev/null +++ b/resources/views/monitoring-verifikasi/monitoring.blade.php @@ -0,0 +1,9 @@ +@extends('monitoring-verifikasi.index') + +@php + $idSubTab = 'monitoring'; + $subTabs = [ + 'petani' => ['label' => 'Petani', 'ikon' => 'tabler-user-circle', 'view' => 'monitoring-verifikasi.petani-monitoring'], + 'enumerator' => ['label' => 'Enumerator', 'ikon' => 'tabler-clipboard-list', 'view' => 'monitoring-verifikasi.enumerator'], + ]; +@endphp diff --git a/resources/views/monitoring-verifikasi/petani-lengkap.blade.php b/resources/views/monitoring-verifikasi/petani-lengkap.blade.php new file mode 100644 index 0000000..0286ae8 --- /dev/null +++ b/resources/views/monitoring-verifikasi/petani-lengkap.blade.php @@ -0,0 +1,182 @@ +@use('App\Services\Produktivitas\KelengkapanPetani') + +@php + $langkah = [ + 1 => ['jenis' => 'skor', 'label' => 'Input Skor'], + 2 => ['jenis' => 'wajib', 'label' => 'Input Wajib'], + 3 => ['jenis' => 'lainnya', 'label' => 'Input Lainnya'], + ]; +@endphp + +@if (($bagian ?? 'tabel') === 'rincian') + {{-- ============ Isi kartu verifikasi: profil + 3 langkah pemeriksaan ============ --}} + @php $skorIndeks = $petani->skor?->skor_kesiapan_adopsi; @endphp + +
+ @include('monitoring-verifikasi.petani-monitoring', ['bagian' => 'profil']) + +
+
+ + +
+
+ + +
+
+ + @if ($petani->catatan) + {{-- Catatan saat terakhir dikembalikan ke Draf --}} + + @endif + + {{-- Penanda langkah --}} +
+ @foreach ($langkah as $n => $l) + @php $hitung = $ringkasan[$l['jenis']]; @endphp +
+ + $n === 1, 'bg-label-secondary' => $n !== 1])>{{ $n }} + + + {{ $l['label'] }} + {{ $hitung['terisi'] }}/{{ $hitung['total'] }} + +
+ @endforeach +
+ + {{-- Isi tiap langkah: periksa semua isian, lalu klik tombol di bawah --}} + @foreach ($langkah as $n => $l) +
+ @include('monitoring-verifikasi.petani-monitoring', ['bagian' => 'isian', 'rincian' => $rincian[$l['jenis']]]) + + {{-- Langkah terakhir punya tombolnya sendiri di bawah --}} + @if ($n < count($langkah)) +
+ @if ($n > 1) + + @endif + +
+ @endif +
+ @endforeach + + {{-- Langkah terakhir: Belum Lengkap (kembali ke Draf + catatan) atau Lengkap & Verifikasi --}} + {{-- Baris 1: Kembali | Belum Lengkap (sama lebar). Baris 2: Lengkap & Verifikasi (penuh). --}} +
+
+
+ +
+
+
+ @csrf + @method('PUT') + + +
+
+
+
+ @csrf + @method('PUT') + +
+
+
+@else + {{-- ============ Tabel petani berstatus Lengkap; klik di mana saja pada baris → kartu verifikasi ============ --}} +
+
+
+ +
+
+ + + +
+
+
+
+ + +
+
+
+ +
+ + + + + + + + @foreach ($langkah as $l) + + @endforeach + + + + + @forelse ($petaniLengkap as $index => $item) + @php $r = $ringkasan[$item->id]; @endphp + + + + + + @foreach ($langkah as $l) + + @endforeach + + + @empty + + + + @endforelse + +
NoNama PetaniKomoditasEnumerator{{ $l['label'] }}Terakhir Diperbarui
{{ $petaniLengkap->firstItem() + $index }}{{ $item->nama }}{{ $item->komoditas?->nama ?? '-' }}{{ $item->creator?->name ?? '-' }} + {{ $r[$l['jenis']]['terisi'] }}/{{ $r[$l['jenis']]['total'] }} + + {{ $item->updated_at?->translatedFormat('d M Y') ?? '-' }} + {{ $item->updated_at?->format('H:i') }} +
Belum ada data petani yang ditandai lengkap.
+
+ +
+ {{ $petaniLengkap->links('pagination::bootstrap-5') }} +
+
+ + +
+@endif diff --git a/resources/views/monitoring-verifikasi/petani-monitoring.blade.php b/resources/views/monitoring-verifikasi/petani-monitoring.blade.php new file mode 100644 index 0000000..a14981e --- /dev/null +++ b/resources/views/monitoring-verifikasi/petani-monitoring.blade.php @@ -0,0 +1,136 @@ +@use('App\Services\Produktivitas\KelengkapanPetani') + +@if (($bagian ?? 'tabel') === 'profil') + {{-- ============ Profil singkat petani (dipakai juga di kartu verifikasi) ============ --}} + @php $desa = $petani->desaKelurahan; @endphp +
+
+ Foto {{ $petani->nama }} +
+
+
{{ $petani->nama }}
+ {{ is_null($petani->umur) ? 'Umur belum diisi' : $petani->umur . ' tahun' }} + + {{ $desa ? collect([$desa->nama, $desa->kecamatan?->nama, $desa->kecamatan?->kabupatenKota?->nama])->filter()->implode(', ') : 'Desa belum diisi' }} + +
+
+@elseif (($bagian ?? 'tabel') === 'isian') + {{-- ============ Daftar pertanyaan -> jawaban (dipakai juga di kartu verifikasi) ============ --}} + @forelse ($rincian as $grup => $baris) +
{{ $grup }}
+ @foreach ($baris as $r) + @php $kosong = ! $r['terisi'] || $r['jawaban'] === null; @endphp +
+ + @if (! $kosong && mb_strlen($r['jawaban']) > 60) + + @else + $kosong]) readonly + value="{{ $kosong ? 'Belum diisi' : $r['jawaban'] }}"> + @endif +
+ @endforeach + @empty +

Tidak ada isian.

+ @endforelse +@elseif (($bagian ?? 'tabel') === 'rincian') + {{-- ============ Isi kartu rincian: profil singkat + pertanyaan -> jawaban ============ --}} + @php $skorIndeks = $petani->skor?->skor_kesiapan_adopsi; @endphp + +
+ @include('monitoring-verifikasi.petani-monitoring', ['bagian' => 'profil']) + +
+
+ + +
+
+ + +
+
+ + @include('monitoring-verifikasi.petani-monitoring', ['bagian' => 'isian']) +
+@else + {{-- ============ Tabel petani; klik badge Input → kartu rincian ============ --}} + @php + $judulInput = ['skor' => 'Input Skor', 'wajib' => 'Input Wajib', 'lainnya' => 'Input Lainnya']; + @endphp + +
+
+
+ +
+
+ + + +
+
+
+
+ + +
+
+
+ +
+ + + + + + + + + + + + + + @forelse ($petani as $index => $item) + @php $r = $ringkasan[$item->id]; @endphp + + + + + + @foreach ($judulInput as $jenis => $judul) + + @endforeach + + @empty + + + + @endforelse + +
NoNama PetaniKomoditasEnumeratorInput SkorInput WajibInput Lainnya
{{ $petani->firstItem() + $index }}{{ $item->nama }}{{ $item->komoditas?->nama ?? '-' }}{{ $item->creator?->name ?? '-' }} + +
Tidak ada data ditemukan.
+
+ +
+ {{ $petani->links('pagination::bootstrap-5') }} +
+
+ + +
+@endif diff --git a/resources/views/monitoring-verifikasi/petani-terverifikasi.blade.php b/resources/views/monitoring-verifikasi/petani-terverifikasi.blade.php new file mode 100644 index 0000000..af434fd --- /dev/null +++ b/resources/views/monitoring-verifikasi/petani-terverifikasi.blade.php @@ -0,0 +1,112 @@ +@if (($bagian ?? 'tabel') === 'rincian') + {{-- ============ Isi kartu: profil + siapa & kapan diverifikasi + Kembalikan ke Draf ============ --}} +
+ @include('monitoring-verifikasi.petani-monitoring', ['bagian' => 'profil']) + +
+ + +
+
+ + +
+ {{-- Data terverifikasi dikunci, jadi updated_by / updated_at = yang & waktu memverifikasi --}} +
+ + +
+
+ + +
+
+ + {{-- Ada temuan setelah diverifikasi: kembalikan ke Draf dengan catatan --}} +
+ @csrf + @method('PUT') + + +
+@else + {{-- ============ Tabel petani terverifikasi; klik di mana saja pada baris → kartu rincian ============ --}} +
+
+
+ +
+
+ + + +
+
+
+
+ + +
+
+
+ +
+ + + + + + + + + + + + + + + @forelse ($petaniTerverifikasi as $index => $item) + + + + + + + {{-- Data terverifikasi dikunci, jadi updated_by = yang memverifikasi --}} + + + + + @empty + + + + @endforelse + +
NoNama PetaniKomoditasDesaEnumeratorDiverifikasi OlehTerakhir DiperbaruiStatus
{{ $petaniTerverifikasi->firstItem() + $index }}{{ $item->nama }}{{ $item->komoditas?->nama ?? '-' }}{{ $item->desaKelurahan?->nama ?? '-' }}{{ $item->creator?->name ?? '-' }} + {{ $item->updater?->name ?? '-' }} + @if ($item->updater?->role) + {{ $item->updater->role->label() }} + @endif + {{ $item->updated_at?->translatedFormat('d M Y H:i') ?? '-' }}{{ $item->status->label() }}
Belum ada data petani yang terverifikasi.
+
+ +
+ {{ $petaniTerverifikasi->links('pagination::bootstrap-5') }} +
+
+ + +
+@endif diff --git a/resources/views/monitoring-verifikasi/verifikasi.blade.php b/resources/views/monitoring-verifikasi/verifikasi.blade.php new file mode 100644 index 0000000..7d2e180 --- /dev/null +++ b/resources/views/monitoring-verifikasi/verifikasi.blade.php @@ -0,0 +1,118 @@ +@extends('monitoring-verifikasi.index') + +@php + $idSubTab = 'verifikasi'; + $subTabs = [ + 'lengkap' => ['label' => 'Data Lengkap', 'ikon' => 'tabler-checklist', 'view' => 'monitoring-verifikasi.petani-lengkap', 'jumlah' => $petaniLengkap->total()], + 'terverifikasi' => ['label' => 'Terverifikasi', 'ikon' => 'tabler-rosette-discount-check', 'view' => 'monitoring-verifikasi.petani-terverifikasi', 'jumlah' => $petaniTerverifikasi->total()], + ]; +@endphp + +@push('scripts') + +@endpush diff --git a/resources/views/produktivitas/petani/header.blade.php b/resources/views/produktivitas/petani/header.blade.php index 84d8b16..fb86848 100644 --- a/resources/views/produktivitas/petani/header.blade.php +++ b/resources/views/produktivitas/petani/header.blade.php @@ -320,8 +320,23 @@ - - + + + @if ($petani && $petani->catatan && $petani->status === \App\Enums\StatusDataPetani::DRAFT) + {{-- Dikembalikan koordinator (Belum Lengkap): yang perlu diperbaiki enumerator --}} +
+
+ + + +
+
Catatan Koordinator
+

{{ $petani->catatan }}

+
+
+
+ @endif +