56 Commits
Author SHA1 Message Date
afriadirahman a3ec431d49 Update Monitoring dan Verifikasi Petani 2026-11-05 11:01:12 +07:00
afriadirahman fd93e0d265 Update Dinamis header dan form input 2026-11-04 20:25:31 +07:00
afriadirahman 8ef4ae1f15 Update Status kelengkapan data 2026-11-04 17:31:22 +07:00
afriadirahman 89c5a1371a Update Alert Profil Petani 2026-11-04 16:20:33 +07:00
afriadirahman 41da3a71ee Update Role fasilitator, kordinator 2026-11-04 14:24:51 +07:00
afriadirahman b5b046ed86 Update Pendidikan dan Pelatihan 2026-11-04 13:17:36 +07:00
afriadirahman aa91c3fe3f Update Praktik Budidaya 2026-11-04 13:09:30 +07:00
Syaharani 53913bebe9 update kelompok tani 2026-11-03 17:53:53 +07:00
Althaf f455f8b878 Merge branch 'main' of https://git.probindo.com/Probindo/database-pertani-web 2026-11-03 14:47:04 +07:00
Althaf f1112e3b68 update controller 2026-11-03 10:51:50 +07:00
afriadirahman c1370a26ca Update Harga Internasional 2026-11-03 09:37:59 +07:00
Syaharani 013cb7b728 dashboard pasar 2026-10-21 10:14:49 +07:00
Althaf 4ab745cfe5 sumber informasi harga 2026-10-19 10:03:05 +07:00
Althaf c66eed56e2 Merge branch 'main' of https://git.probindo.com/Probindo/database-pertani-web 2026-10-16 15:11:49 +07:00
Syaharani 8be0906a2e Update profil petani 2026-10-16 15:48:42 +07:00
afriadirahman fcd79b7cfe Update dashboard Profil Detail Petani 2026-10-16 15:25:21 +07:00
Althaf bea9be8a39 resolve aksespasar 2026-10-16 15:11:39 +07:00
afriadirahman 5220b10de7 Update Config js 2026-10-16 10:38:30 +07:00
Syaharani 24725e3df1 update chart dashboard 2026-10-16 10:29:42 +07:00
Syaharani 60d32a88e9 update dashboard produksi 2026-10-16 10:04:56 +07:00
Althaf 4370d3c3d0 Merge branch 'main' of https://git.probindo.com/Probindo/database-pertani-web 2026-10-15 15:14:57 +07:00
Althaf 7699430301 new field akses pasar 2026-10-15 15:10:18 +07:00
fir2be df2b5a1312 hover and scroll on map 2026-10-15 14:12:03 +07:00
fir2be 2e8271d27a cars 2026-10-15 14:05:01 +07:00
fir2be 9b5c0a7309 perubahan query 2026-10-15 13:02:42 +07:00
fir2be 9d56b67998 sroll untuk 2026-10-15 11:31:57 +07:00
fir2be f95f43999a peta sebaran dan praktek budaya 2026-10-15 11:25:38 +07:00
Syaharani 3e172cf915 card dashboard produksi 2026-10-14 20:18:15 +07:00
Syaharani 3a9b3dbd7b dashboard produksi 2026-10-14 19:43:39 +07:00
Syaharani 80f0056efe dashboard produksi & ganti warna chart 2026-10-13 15:55:18 +07:00
afriadirahman aef27704f2 Update Karakteristik Petani 2026-10-12 10:22:01 +07:00
Syaharani f49bb0c2db dashboard produksi 2026-10-09 13:22:51 +07:00
afriadirahman 8a2c0292ac Update Header 2026-10-08 09:58:56 +07:00
afriadirahman 9ea324e553 Update Dashboard & Sidebar 2026-10-08 09:48:00 +07:00
afriadirahman 94c3288ea7 Update Dashboard Karakteristik Petani 2026-10-07 13:53:55 +07:00
Syaharani 8539bdcec4 update profil petani 2026-10-07 12:03:02 +07:00
Syaharani 77de648ded dashboard karakteristik petani 2026-10-07 10:20:20 +07:00
Syaharani 393cf0d0d1 karakteristik petani 2026-10-06 17:52:34 +07:00
Syaharani 7fdf9b400d solve jumlah lahan dan tanaman 2026-09-23 17:01:19 +07:00
Syaharani be4d46004e solve jumlah lahan dan tanaman 2026-09-23 16:36:14 +07:00
Syaharani fef5358402 solve filter jumlah lahan dan tanaman 2026-09-23 14:52:30 +07:00
Syaharani 02eed6da34 solve jumlah lahan dan tanaman 2026-09-23 14:37:08 +07:00
Syaharani 94d3f2a582 lower filter profil petani 2026-09-23 10:19:30 +07:00
Syaharani 471ca5ac0e filter profil petani 2026-09-23 10:13:13 +07:00
Althaf 8ec8f0c9d6 Merge branch 'main' of https://git.probindo.com/Probindo/database-pertani-web 2026-09-22 15:21:25 +07:00
Althaf 4d7d16e9f6 add migration riwayat petani 2026-09-22 15:20:45 +07:00
afriadirahman 1155b9b95b Update Header Pertanian 2026-09-22 15:12:32 +07:00
Syaharani 607533a327 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	app/Http/Controllers/Web/Produktivitas/PetaniController.php
2026-09-22 11:02:02 +07:00
Syaharani 6b25bff2c4 Merge branch 'main' of https://git.probindo.com/Probindo/database-pertani-web 2026-09-22 10:59:55 +07:00
Syaharani 71b4d42ea2 Merge branch 'main' of https://git.probindo.com/Probindo/database-pertani-web 2026-09-22 10:58:41 +07:00
Syaharani c42a2571f5 solve profil petani 2026-09-22 10:57:21 +07:00
Althaf 16903b21e3 Merge branch 'main' of https://git.probindo.com/Probindo/database-pertani-web 2026-09-18 16:22:57 +07:00
Althaf 1d4a44b8ac update destroy 2026-09-18 16:22:48 +07:00
Syaharani 539239b507 bumdes 2026-09-16 11:09:08 +07:00
afriadirahman a76b014056 Update Kelompok Tani & APP AGERA 2026-09-06 19:09:37 +07:00
Althaf d50c292cf7 instrumen koperasi 2026-09-03 13:17:16 +07:00
180 changed files with 26159 additions and 5048 deletions
+6
View File
@@ -63,3 +63,9 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Optional CA bundle path for Yahoo Finance when PHP has no system CA configured.
YAHOO_FINANCE_CA_BUNDLE=
ALPHA_VANTAGE_API_KEY=
+714
View File
@@ -0,0 +1,714 @@
# AISTANDAR — Standar Pengembangan Aplikasi database-pertani-web
Dokumen ini adalah **aturan wajib** bagi AI (dan developer) yang bekerja di repo ini.
Baca seluruhnya sebelum membuat, mengubah, atau merefaktor kode.
## 0. Cara memakai dokumen ini
- Aturan di sini **mengalahkan** kebiasaan umum atau contoh dari luar. Jika ada konflik antara kode lama dan dokumen ini, ikuti dokumen ini **untuk kode baru**, dan ikuti Bagian 13 (Prosedur Refaktor) untuk kode lama.
- Jika sesuatu **tidak diatur** di sini: lihat dulu bagaimana modul yang sudah sesuai standar melakukannya, lalu tiru polanya. Jika tetap tidak jelas, **tanya**, jangan mengarang pola baru.
- **Jangan** menambah package/library baru, mengubah struktur database yang sudah ada, atau mengubah kontrak API tanpa izin eksplisit.
- Bahasa: teks yang tampil ke pengguna (label, pesan, validasi) memakai **Bahasa Indonesia**. Nama domain (petani, lahan, komoditas) tetap Bahasa Indonesia; kata kerja teknis Laravel (`index`, `store`, `show`) tetap bahasa Inggris.
---
## 1. Stack
| Bagian | Teknologi |
|---|---|
| Framework | Laravel 12 |
| Frontend web | Blade (tanpa Livewire/Inertia) |
| UI template | Vuexy (Bootstrap 5), ikon Tabler |
| Grafik | ApexCharts |
| Peta | Leaflet |
| Auth web | Session |
| Auth API | Laravel Sanctum (token) |
| Dokumentasi API | dedoc/scramble |
| Role | `app/Enums/Role.php`: `super_admin`, `admin` (admin program), `koordinator` (per komoditas), `enumerator` (penginput data petani), `fasilitator` (pemateri workshop), `petani`, `offtaker` |
---
## 2. Struktur folder & penamaan
### 2.1 Pengelompokan per domain
Controller, Model, Request, dan View **dikelompokkan per domain yang sama**:
```
Domain yang ada: Dashboard, Laporan, Produktivitas, Ekosistem, Program, Master,
Bumdes, Koperasi, Konfigurasi
```
| Lapisan | Lokasi |
|---|---|
| Controller web | `app/Http/Controllers/Web/{Domain}/{Model}Controller.php` |
| Controller API | `app/Http/Controllers/Api/{Domain}/{Model}Controller.php` |
| Form Request | `app/Http/Requests/{Domain}/Store{Model}Request.php`, `Update{Model}Request.php` |
| Model | `app/Models/{DomainModel}/{Model}.php` (ikuti folder model yang ada: `Pertanian/`, `Wilayah/`, `Program/`, `Master/`, dst.) |
| Enum | `app/Enums/` |
| Service / Query | `app/Services/{Domain}/` |
| View | `resources/views/{domain}/{modul}/` (huruf kecil, kebab-case) |
Controller baru **tidak boleh** ditaruh di root `app/Http/Controllers/`. Controller root yang sudah ada (`ProgramController`, `ProgramKomoditasController`, `ProgramLokasiController`) dipindahkan ke domain `Program/` saat direfaktor.
### 2.2 Konvensi nama
| Objek | Aturan | Contoh |
|---|---|---|
| Model | Singular, PascalCase | `Petani`, `JenisKomoditas` |
| Tabel | snake_case, **selalu ditulis eksplisit** di model | `protected $table = 'petani';` |
| Controller | `{Model}Controller` | `PetaniController` |
| Route name | `{modul}.{aksi}`, kebab-case | `jenis-komoditas.index` |
| URL | kebab-case | `/jenis-komoditas` |
| View | `{domain}/{modul}/{aksi}.blade.php` | `master/provinsi/index.blade.php` |
| Method tambahan | camelCase | `byProvinsi()`, `list()` |
> Karena nama domain berbahasa Indonesia, pluralisasi otomatis Laravel salah (`petanis`). **Wajib** set `$table` di setiap model, dan set parameter route bila perlu (`->parameters(['jenis-komoditas' => 'jenisKomoditas'])`).
---
## 3. Controller
> **Cakupan:** Bagian 3 berlaku untuk **modul CRUD** (pengelolaan data: tambah, ubah, hapus).
> Controller **Dashboard** dan **Laporan/Reporting** tidak terikat aturan 7 method dan mengikuti aturan yang lebih fleksibel di **Bagian 11**.
### 3.1 Tujuh method resmi
Untuk modul CRUD, gunakan **hanya** nama method resource Laravel:
| Method | HTTP | URL | Route name | Fungsi |
|---|---|---|---|---|
| `index` | GET | `/items` | `items.index` | Daftar data |
| `create` | GET | `/items/create` | `items.create` | Form tambah |
| `store` | POST | `/items` | `items.store` | Simpan baru |
| `show` | GET | `/items/{item}` | `items.show` | Detail |
| `edit` | GET | `/items/{item}/edit` | `items.edit` | Form ubah |
| `update` | PUT/PATCH | `/items/{item}` | `items.update` | Simpan perubahan |
| `destroy` | DELETE | `/items/{item}` | `items.destroy` | Hapus (soft delete) |
**Dilarang** memakai nama `add`, `view`, `save`, `delete`, `remove`, `detail`, atau sejenisnya untuk aksi CRUD. (Larangan ini tidak berlaku untuk Dashboard dan Laporan, lihat Bagian 11.)
### 3.2 Prinsip controller tipis
- Validasi **selalu** di Form Request, tidak pernah `$request->validate()` di controller.
- Controller hanya: terima request → panggil model/service → kembalikan respons.
- Logika bisnis yang lebih dari ±15 baris, dipakai di lebih dari satu tempat, atau melibatkan banyak tabel → pindahkan ke `app/Services/{Domain}/`.
- Operasi yang menulis ke lebih dari satu tabel → bungkus dengan `DB::transaction()`.
- Pakai route model binding (`Petani $petani`), bukan `find($id)`.
### 3.3 Tiga pola resmi
Pilih **satu** pola per modul berdasarkan tabel ini:
| Pola | Kapan dipakai | Contoh |
|---|---|---|
| **A. Offcanvas (AJAX)** | Data referensi/sederhana: sedikit field, tanpa halaman detail yang kaya | Provinsi, Kabupaten, Komoditas, Varietas, Adat |
| **B. Resource halaman** | Entitas utama: banyak field, ada relasi, perlu halaman detail | Lahan, Produksi, Workshop, BUMDes, Koperasi |
| **C. Detail multi-tab** | Entitas induk dengan banyak bagian data yang diisi bertahap | Petani, Survey Kelompok Tani |
---
#### Pola A — Offcanvas (AJAX)
- `index` mengembalikan **view**; `store`, `show`, `update`, `destroy` mengembalikan **JSON**.
- Tidak ada halaman `create`/`edit`; form ada di offcanvas di `index.blade.php`.
- `show` dipakai JS untuk mengisi form edit di offcanvas.
```php
// routes/web.php
// Route tambahan WAJIB didaftarkan SEBELUM Route::resource agar tidak tertangkap {provinsi}
Route::get('provinsi/list', [ProvinsiController::class, 'list'])->name('provinsi.list');
Route::resource('provinsi', ProvinsiController::class)->except(['create', 'edit']);
```
```php
namespace App\Http\Controllers\Web\Master;
class ProvinsiController extends Controller
{
public function index(Request $request)
{
$provinsi = Provinsi::query()
->when($request->search, fn ($q, $s) => $q->where('nama', 'like', "%{$s}%"))
->latest()
->paginate(15)
->withQueryString();
return view('master.provinsi.index', compact('provinsi'));
}
public function store(StoreProvinsiRequest $request)
{
$provinsi = Provinsi::create($request->validated());
return response()->json([
'success' => true,
'message' => 'Provinsi berhasil ditambahkan.',
'data' => $provinsi,
], 201);
}
public function show(Provinsi $provinsi)
{
return response()->json(['success' => true, 'data' => $provinsi]);
}
public function update(UpdateProvinsiRequest $request, Provinsi $provinsi)
{
$provinsi->update($request->validated());
return response()->json([
'success' => true,
'message' => 'Provinsi berhasil diperbarui.',
'data' => $provinsi,
]);
}
public function destroy(Provinsi $provinsi)
{
DB::transaction(fn () => $provinsi->delete());
return response()->json([
'success' => true,
'message' => 'Provinsi berhasil dihapus.',
]);
}
}
```
**Dropdown berantai** (provinsi → kabupaten → kecamatan → desa): method `by{Induk}` di controller anak, mengembalikan JSON `[{id, nama}]`.
```php
Route::get('kabupaten/by-provinsi/{provinsi}', [KabupatenController::class, 'byProvinsi'])
->name('kabupaten.by-provinsi');
```
---
#### Pola B — Resource halaman
- Semua 7 method; view `index`, `create`, `edit`, `show` + partial `_form`.
- Setelah `store`/`update`/`destroy` → **redirect** dengan flash message.
```php
Route::resource('lahan', LahanController::class);
```
```php
namespace App\Http\Controllers\Web\Produktivitas;
class LahanController extends Controller
{
public function index(Request $request)
{
$lahan = Lahan::query()
->with(['petani', 'desa'])
->when($request->search, fn ($q, $s) => $q->where('nama', 'like', "%{$s}%"))
->latest()
->paginate(15)
->withQueryString();
return view('produktivitas.lahan.index', compact('lahan'));
}
public function create()
{
return view('produktivitas.lahan.create', ['lahan' => new Lahan()]);
}
public function store(StoreLahanRequest $request)
{
$lahan = Lahan::create($request->validated());
return redirect()->route('lahan.show', $lahan)
->with('success', 'Lahan berhasil ditambahkan.');
}
public function show(Lahan $lahan)
{
$lahan->load(['petani', 'tanaman']);
return view('produktivitas.lahan.show', compact('lahan'));
}
public function edit(Lahan $lahan)
{
return view('produktivitas.lahan.edit', compact('lahan'));
}
public function update(UpdateLahanRequest $request, Lahan $lahan)
{
$lahan->update($request->validated());
return redirect()->route('lahan.show', $lahan)
->with('success', 'Lahan berhasil diperbarui.');
}
public function destroy(Lahan $lahan)
{
DB::transaction(fn () => $lahan->delete());
return redirect()->route('lahan.index')
->with('success', 'Lahan berhasil dihapus.');
}
}
```
---
#### Pola C — Detail multi-tab
- Controller induk (mis. `PetaniController`) memakai Pola B.
- Setiap tab punya **controller sendiri** (`PetaniLahanController`, `PraktikBudidayaController`, …) yang tetap memakai nama method resmi.
- Tab dirangkai sebagai **nested shallow resource** di bawah induk.
- View: `header.blade.php` berisi kartu profil + navigasi tab; setiap tab punya subfolder.
- Tab selain Profil **terkunci** sampai profil induk lengkap. Logika penguncian ditaruh di satu tempat (method di model induk, mis. `$petani->profilLengkap()`), bukan diulang di tiap view.
```
resources/views/produktivitas/petani/
├── index.blade.php
├── create.blade.php
├── edit.blade.php
├── show.blade.php ← tab Profil
├── header.blade.php ← kartu profil + navigasi tab
├── _form.blade.php
├── lahan-tanaman/
│ ├── index.blade.php
│ └── ...
├── praktik-budidaya/
└── ...
```
```php
Route::resource('petani', PetaniController::class);
Route::resource('petani.praktik-budidaya', PraktikBudidayaController::class)->shallow();
```
### 3.4 Batas resource
- Jika modul tidak butuh semua aksi, batasi dengan `->only([...])` / `->except([...])`. Jangan biarkan route yang tidak punya method terdaftar.
- Method di luar 7 aksi resmi hanya boleh untuk: dropdown/list (`list`, `by{Induk}`), aksi status yang jelas (`verify`, `approve`), dan export (`export`). Daftarkan sebelum `Route::resource`.
---
## 4. Validasi
- Setiap `store` dan `update` memakai Form Request: `Store{Model}Request`, `Update{Model}Request`.
- Pesan dan atribut dalam Bahasa Indonesia (`messages()` / `attributes()`, atau file `lang/id/validation.php`).
- Form Request yang sama boleh dipakai controller Web dan API.
- Untuk Pola A, Form Request otomatis mengembalikan JSON 422 karena request AJAX mengirim header `Accept: application/json`. **Jangan** menangkap error validasi secara manual.
- **Unique wajib mengabaikan data yang sudah di-soft delete** (lihat 5.3).
```php
public function rules(): array
{
return [
'kode' => [
'required', 'string', 'max:10',
Rule::unique('provinsi', 'kode')
->ignore($this->route('provinsi'))
->withoutTrashed(),
],
'nama' => ['required', 'string', 'max:100'],
];
}
```
---
## 5. Model, migration, dan soft delete
### 5.1 Model
```php
class Lahan extends Model
{
use SoftDeletes;
protected $table = 'lahan';
protected $fillable = ['petani_id', 'desa_id', 'nama', 'luas'];
protected function casts(): array
{
return [
'luas' => 'decimal:2',
'status_milik' => StatusKepemilikan::class, // Enum
];
}
}
```
- Pakai `$fillable`, bukan `$guarded = []`.
- Pilihan tetap (jawaban survei, status) disimpan sebagai **PHP Enum** di `app/Enums/` dan di-cast di model. Jangan menulis daftar pilihan langsung di Blade.
### 5.2 Migration
Setiap tabel baru **wajib** memiliki:
```php
$table->id();
// ... kolom
$table->foreignId('created_by')->nullable()->constrained('users'); // ikuti kolom audit yang sudah ada
$table->foreignId('updated_by')->nullable()->constrained('users');
$table->timestamps();
$table->softDeletes();
$table->foreignId('deleted_by')->nullable()->constrained('users');
```
- Jangan mengubah migration yang sudah dijalankan di production; buat migration baru.
- **Jangan** memakai unique index di database untuk kolom pada tabel ber-soft delete (data terhapus akan menghalangi input ulang). Keunikan dijaga di Form Request.
### 5.3 Soft delete (aturan utama)
1. **Semua penghapusan adalah soft delete.** `forceDelete()` dilarang di kode aplikasi.
2. **`deleted_by` wajib terisi** dengan user yang menghapus. Gunakan mekanisme yang sudah ada di project (trait/observer). Jika belum ada, gunakan trait berikut dan pasang di semua model:
```php
namespace App\Models\Concerns;
trait HasDeletedBy
{
protected static function bootHasDeletedBy(): void
{
static::deleting(function ($model) {
if (! $model->isForceDeleting()) {
$model->deleted_by = auth()->id();
$model->saveQuietly();
}
});
}
}
```
3. **Hapus berantai (cascade soft delete).** Jika induk dihapus, **semua data anaknya ikut di-soft delete**, termasuk cucu dan seterusnya. Tulis di model induk:
```php
protected static function booted(): void
{
static::deleting(function (Petani $petani) {
if ($petani->isForceDeleting()) {
return;
}
// ->each->delete() memicu event per anak, sehingga deleted_by
// terisi dan cascade berlanjut ke cucu
$petani->lahan()->each->delete();
$petani->praktikBudidaya()->each->delete();
// ... semua relasi hasMany/hasOne milik petani
});
}
```
- **Jangan** memakai `$petani->lahan()->delete()` (query massal), karena tidak memicu event, sehingga `deleted_by` kosong dan cascade terputus.
- Setiap kali menambah relasi anak baru, **tambahkan juga** ke blok `deleting` induknya.
- Pemanggilan `delete()` di controller selalu dibungkus `DB::transaction()`.
- Relasi `belongsTo` ke data master (Provinsi, Komoditas, dll.) **bukan** anak dan tidak ikut terhapus.
4. **Restore** belum disediakan di aplikasi; dilakukan langsung di database oleh admin. Jangan membuat halaman sampah/restore tanpa diminta.
### 5.4 Global scope
- `KomoditasProgramScope` otomatis membatasi data ke komoditas milik program aktif (`SystemProgram`). Jika data "tidak muncul", periksa scope ini dulu.
- Melewati scope hanya dengan `withoutGlobalScope(KomoditasProgramScope::class)` secara eksplisit, dengan komentar alasannya.
- Jangan menambah global scope baru tanpa izin.
---
## 6. Routing
### 6.1 Route CRUD wajib memakai `Route::resource`
Semua modul CRUD (Pola A, B, C) **wajib** didaftarkan dengan `Route::resource`. **Dilarang** menulis route CRUD satu per satu secara manual.
```php
// ❌ SALAH — route CRUD manual
Route::get('provinsi', [ProvinsiController::class, 'index'])->name('provinsi.index');
Route::post('provinsi/store', [ProvinsiController::class, 'store'])->name('provinsi.store');
Route::get('provinsi/view/{id}', [ProvinsiController::class, 'view'])->name('provinsi.view');
Route::post('provinsi/update/{id}', [ProvinsiController::class, 'update'])->name('provinsi.update');
Route::get('provinsi/delete/{id}', [ProvinsiController::class, 'destroy'])->name('provinsi.destroy');
// ✅ BENAR
Route::resource('provinsi', ProvinsiController::class)->except(['create', 'edit']);
```
Aturan turunannya:
| Kebutuhan | Cara |
|---|---|
| Tidak semua aksi dipakai | `->only([...])` atau `->except([...])` |
| Pola A (offcanvas) | `->except(['create', 'edit'])` |
| Pola B (resource halaman) | `Route::resource(...)` penuh |
| Pola C (multi-tab) | Induk `Route::resource`, tab `Route::resource('induk.tab', ...)->shallow()` |
| Beberapa modul sekaligus | `Route::resources([...])` boleh dipakai dalam satu grup domain |
| Nama parameter tidak sesuai model | `->parameters(['jenis-komoditas' => 'jenisKomoditas'])` |
| Method tambahan (`list`, `by{Induk}`, `verify`, `export`) | `Route::get/post/patch` biasa, **ditulis sebelum** `Route::resource` modul yang sama |
- Form hapus/ubah memakai method spoofing Blade (`@method('DELETE')`, `@method('PUT')`), dan AJAX mengirim method HTTP yang sesuai. Jangan memakai `GET` untuk menghapus.
- **Pengecualian:** Dashboard dan Laporan tidak memakai `Route::resource` (lihat Bagian 11). Halaman tunggal seperti Profil akun boleh memakai `Route::singleton('profil', ...)` atau route manual `edit`/`update`.
### 6.2 Aturan routing lainnya
- Semua route web di `routes/web.php`, API di `routes/api.php`.
- Kelompokkan per domain dengan `Route::middleware(...)->group()` dan komentar judul domain.
- Nested resource memakai `->shallow()`. Maksimal **dua tingkat** di URL (`/framework/{framework}/transformasi`). Hierarki lebih dalam (Framework → Transformasi → Aktivitas → Target) dipecah menjadi beberapa nested shallow resource berpasangan.
- **Satu route hanya didaftarkan satu kali.** Sebelum menambah route, cari dulu apakah sudah ada (`php artisan route:list --name=...`).
- Setelah login: `enumerator` → Dashboard Survei Petani; `fasilitator` → Profil akun (belum ada menu khusus); role lain → Dashboard Karakteristik Petani. Jangan mengubah logika ini tanpa izin.
---
## 7. Blade & Vuexy
### 7.1 Layout
- Semua halaman admin: `@extends('layout.main')` dan mengisi `@section('content')`.
- Halaman auth memakai `auth/layout.blade.php`.
- Jangan membuat layout baru; jangan menyalin `<head>`/sidebar ke halaman.
### 7.2 File view per pola
| File | Pola | Isi |
|---|---|---|
| `index.blade.php` | A, B, C | Tabel, pencarian, pagination (+ offcanvas form untuk Pola A) |
| `create.blade.php` | B, C | Form tambah, `@include('..._form')` |
| `edit.blade.php` | B, C | Form ubah, `@include('..._form')` |
| `show.blade.php` | B, C | Detail |
| `_form.blade.php` | A, B, C | Field form bersama untuk tambah dan ubah |
| `header.blade.php` | C | Kartu profil + navigasi tab |
- File `add.blade.php` dan `view.blade.php` **tidak boleh** dibuat lagi (ganti dengan `create` dan `show`).
- `_form.blade.php` menerima model (kosong saat tambah) dan memakai `old('field', $model->field)`.
### 7.3 Tampilan error validasi
```blade
<input type="text" name="nama"
class="form-control @error('nama') is-invalid @enderror"
value="{{ old('nama', $lahan->nama) }}">
@error('nama')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
```
Untuk Pola A, JS menampilkan error dari respons 422 dengan class yang sama (`is-invalid` + `invalid-feedback`).
### 7.4 Komponen
- Elemen yang muncul di lebih dari dua halaman dijadikan Blade component di `resources/views/components/` (contoh yang ada: `summary-card`, `wilayah-card`, `wilayah-dashboard`).
- Utamakan komponen yang sudah ada sebelum membuat yang baru.
### 7.5 JavaScript & plugin
- JS halaman ditulis di **file terpisah per modul** (ikuti lokasi JS yang sudah dipakai project), dimuat lewat section/stack script di layout. Hindari blok `<script>` panjang di Blade.
- Semua request AJAX mengirim header `X-CSRF-TOKEN` dan `Accept: application/json`.
- URL di JS diambil dari `route()` yang dicetak Blade (mis. atribut `data-url`), **bukan** di-hardcode.
- Pakai plugin yang sudah tersedia di template Vuexy. Jangan menambah library baru tanpa izin. Pembagian peran:
| Kebutuhan | Plugin |
|---|---|
| Konfirmasi hapus | SweetAlert2 (jika terpasang) |
| Notifikasi sukses/gagal | Sesuai yang dipakai modul lain |
| Select dengan pencarian / dropdown berantai | Select2 (jika terpasang) |
| Input tanggal | Flatpickr (jika terpasang) |
| Grafik | ApexCharts |
| Peta | Leaflet |
| Ikon | Tabler Icons (`ti ti-*`) |
---
## 8. Respons & pesan
### 8.1 Web (Pola B/C)
```php
return redirect()->route('lahan.index')->with('success', 'Lahan berhasil dihapus.');
return back()->withInput()->with('error', 'Data gagal disimpan.');
```
Kunci flash: `success`, `error`, `warning`, `info`.
### 8.2 JSON (Pola A dan AJAX web)
```json
{ "success": true, "message": "Provinsi berhasil ditambahkan.", "data": { } }
```
- Error validasi: format 422 bawaan Laravel (`message` + `errors`).
- Error lain: `{ "success": false, "message": "..." }` dengan kode HTTP yang sesuai.
### 8.3 Pola kalimat pesan
`{Nama data} berhasil ditambahkan.` / `diperbarui.` / `dihapus.`
---
## 9. Otorisasi
- Hak akses berdasarkan role pada `Role` yang sudah ada. Ikuti mekanisme pembatasan role yang sudah dipakai (middleware/policy/gate yang ada); jangan membuat sistem permission baru.
- Penginput data petani adalah role `enumerator` (bukan `fasilitator`). `fasilitator` khusus pemateri workshop.
- Semua route `petani/...` dibungkus middleware `akses.petani` (`BatasiAksesPetani`): hanya `super_admin`, `admin`, `koordinator`, `enumerator`; koordinator hanya boleh mengakses petani dengan komoditas utama yang ditugaskan (`koordinator_komoditas`, cek lewat `User::bolehAksesPetani()`). Route tab petani baru wajib diletakkan di dalam grup ini.
- Menu sidebar hanya menampilkan item yang boleh diakses role pengguna, dan route-nya juga dilindungi di sisi server. Menyembunyikan menu saja tidak cukup.
---
## 10. API (`app/Http/Controllers/Api/`)
- API dipakai aplikasi mobile/lapangan. **Kontrak API (URL, nama field, format respons) tidak boleh diubah** tanpa izin eksplisit, termasuk saat refaktor.
- Endpoint baru mengikuti pola dan format respons API yang sudah ada di folder `Api/`.
- Auth memakai Sanctum; login dibatasi rate limit (5/menit). Jangan longgarkan.
- Setiap endpoint baru harus terbaca rapi di dokumentasi Scramble (type-hint Form Request dan tipe return yang jelas).
---
## 11. Pengecualian
### 11.0 Prinsip umum Dashboard & Laporan
Dashboard dan laporan **bersifat baca (read-only)** dan bentuknya beragam, sehingga controller-nya **fleksibel**:
- **Nama method bebas**, asalkan deskriptif dan camelCase (`index`, `chartProduktivitas`, `petaSebaran`, `rekapPerDesa`, `exportExcel`).
- Boleh satu controller dengan banyak method, atau controller satu aksi (`__invoke`) jika halamannya sederhana.
- Route ditulis manual (`Route::get(...)`), **tidak** memakai `Route::resource`.
- Tidak ada `create/store/edit/update/destroy`. Jika sebuah dashboard butuh input data, input itu dibuat sebagai modul CRUD terpisah.
Yang **tetap berlaku** walau fleksibel:
- Lokasi folder per domain (Bagian 2), penamaan route kebab-case dengan prefix (`dashboard.*`, `laporan.*`).
- Validasi filter memakai Form Request jika filternya lebih dari 2–3 parameter.
- Query sederhana boleh di controller. Query agregat yang panjang, berat, atau dipakai di lebih dari satu tempat dipindah ke `app/Services/{Dashboard|Laporan}/`.
- Data yang ditampilkan tetap menghormati soft delete (data terhapus tidak ikut dihitung) dan global scope program.
- Pembatasan akses per role (Bagian 9).
### 11.1 Dashboard
- Controller: `app/Http/Controllers/Web/Dashboard/{Nama}Controller.php`.
- Pola yang disarankan: `index` mengembalikan halaman; data grafik/peta yang dimuat via AJAX memakai method bernama jelas yang mengembalikan JSON.
- Filter umum diterima lewat query string: program, wilayah (provinsi → desa), periode.
- Query berat di-cache (`Cache::remember`) dengan kunci yang memuat nilai filter.
- View: satu file per dashboard di `resources/views/dashboard/`. Grafik ApexCharts, peta Leaflet.
- Data eksternal (AlphaVantage di `InternationalMarketController`) wajib di-cache dan punya penanganan gagal (tampilkan pesan, jangan error 500).
```php
// routes/web.php
Route::prefix('dashboard')->name('dashboard.')->group(function () {
Route::get('produktivitas', [ProduktivitasController::class, 'index'])->name('produktivitas');
Route::get('produktivitas/chart-panen', [ProduktivitasController::class, 'chartPanen'])
->name('produktivitas.chart-panen');
});
```
### 11.2 Laporan / reporting
- Controller: `app/Http/Controllers/Web/Laporan/{Nama}Controller.php`; view di `resources/views/laporan/{nama}/`.
- Pola yang disarankan per laporan: halaman filter + pratinjau (`index`), lalu method export per format (`exportExcel`, `exportPdf`). Semua menerima **filter yang sama** dengan pratinjau.
- Export kecil yang menempel pada satu modul CRUD atau dashboard boleh berupa method `export` di controller modul tersebut.
- Laporan yang lambat (data besar) boleh diproses di queue, lalu file diunduh setelah siap.
- Gunakan package export yang sudah terpasang di project. Jika belum ada, tanyakan dulu.
```php
Route::prefix('laporan')->name('laporan.')->group(function () {
Route::get('rekap-panen', [RekapPanenController::class, 'index'])->name('rekap-panen');
Route::get('rekap-panen/excel', [RekapPanenController::class, 'exportExcel'])->name('rekap-panen.excel');
Route::get('rekap-panen/pdf', [RekapPanenController::class, 'exportPdf'])->name('rekap-panen.pdf');
});
```
### 11.3 Ekosistem (form builder dinamis)
- Modul `Ekosistem/` (Group, Tabel, Kolom, Formulir, Ringkasan, Value) memiliki struktur dinamis dan **dikecualikan** dari pola CRUD standar.
- Jangan direfaktor ke pola standar kecuali diminta secara eksplisit. Perubahan kecil tetap mengikuti aturan validasi, soft delete, dan kebersihan.
---
## 12. Kebersihan repo
- **Dilarang** menyimpan file salinan/cadangan: `* copy.php`, `*Backup*`, `*01.php`, `*_old*`, dll. Riwayat disimpan di Git.
- Tidak ada kode yang dikomentari dalam jumlah besar ("kode mati"); hapus saja.
- Tidak ada route ganda (lihat Bagian 6).
- Satu commit = satu perubahan logis (satu modul/fitur). Pesan commit Bahasa Indonesia, singkat, diawali jenis: `feat:`, `fix:`, `refactor:`, `chore:`.
Masalah kebersihan yang **sudah diketahui** (selesaikan saat menyentuh area terkait):
- File duplikat/backup: `* copy.php`, `IndikatorPertanianBackupController`, `PanenController01`, `SummaryDashboardController copy.php`, file `composerlock` yang tidak semestinya.
- Route ganda: `users` (resource + manual), `users.verify` (dua kali), `jenis-komoditas.by-komoditas`, `varietas.by-jenis-komoditas`.
---
## 13. Prosedur refaktor modul lama
Modul lama **wajib** disesuaikan dengan standar ini secara bertahap.
### 13.1 Aturan
1. **Satu modul per sesi/commit.** Jangan merefaktor beberapa modul sekaligus.
2. Jangan merefaktor modul yang sedang dikerjakan (ada perubahan belum di-commit) tanpa konfirmasi.
3. API **tidak** ikut diubah (Bagian 10).
4. Perilaku untuk pengguna harus tetap sama; refaktor bukan tempat menambah fitur.
### 13.2 Langkah
1. Simpan daftar route modul: `php artisan route:list --name={modul} > /tmp/before.txt`.
2. Tentukan pola target (A/B/C) sesuai 3.3.
3. Cari **semua** pemakaian nama route, URL, dan view lama di seluruh repo (Blade, JS, controller lain, sidebar), mis. `grep -rn "provinsi.view\|provinsi/add\|master.provinsi.add" app resources routes`.
4. Ubah:
- method `add` → `create`, `view` → `show`;
- file `add.blade.php` → `create.blade.php`, `view.blade.php` → `show.blade.php`;
- route manual → `Route::resource` (+ `only/except`, route tambahan di atasnya);
- validasi inline → Form Request (dengan `withoutTrashed()` pada unique);
- pastikan model memakai `SoftDeletes`, `deleted_by` terisi, dan cascade ke anak sudah ditulis;
- semua referensi yang ditemukan di langkah 3.
5. Bandingkan `route:list` sesudah dengan `/tmp/before.txt`; pastikan tidak ada route yang hilang tanpa sengaja.
6. Uji manual alur lengkap: index → tambah → detail → ubah → hapus (cek `deleted_at`, `deleted_by`, dan data anak).
7. Perbarui tabel status di 13.3.
### 13.3 Status migrasi modul
Status: `Belum` / `Proses` / `Selesai` / `Dikecualikan`
| Domain | Modul | Pola target | Status | Catatan |
|---|---|---|---|---|
| Master | Provinsi | A | Belum | Ada `list` + dropdown berantai |
| Master | Kabupaten/Kota | A | Belum | `by-provinsi` |
| Master | Kecamatan | A | Belum | `by-kabupaten` |
| Master | Desa/Kelurahan | A | Belum | `by-kecamatan` |
| Master | Komoditas | A | Belum | |
| Master | Jenis Komoditas | A | Belum | Route `by-komoditas` dobel |
| Master | Varietas | A | Belum | Route `by-jenis-komoditas` dobel |
| Master | Adat | A | Belum | |
| Master | Keluarga | ? | Belum | Tentukan pola saat refaktor |
| Produktivitas | Petani (+ semua tab) | C | Belum | |
| Produktivitas | Lahan | B | Belum | |
| Produktivitas | Tanaman | B | Belum | |
| Produktivitas | MapTanaman | ? | Belum | |
| Produktivitas | Produksi | B | Belum | |
| Produktivitas | Survey Kelompok Tani (+ tab) | C | Belum | |
| Produktivitas | Sebaran Petani | ? | Belum | Sedang dikerjakan, konfirmasi dulu |
| Program | Program, Program Komoditas, Program Lokasi | B | Belum | Pindah dari root ke `Program/` |
| Program | Framework → Transformasi → Aktivitas → Target | B | Belum | Nested shallow |
| Program | Realisasi | B | Belum | |
| Program | Verifikasi | ? | Belum | Sedang dikerjakan, konfirmasi dulu |
| Program | Workshop (+ Sesi, Peserta) | B | Belum | Sesi/Peserta sedang dikerjakan |
| Program | Kelompok Belajar Tani | B | Belum | |
| Bumdes | Profil, Kelembagaan, Survei | B | Belum | |
| Koperasi | Profil, Kelembagaan, Survei | B | Belum | |
| Konfigurasi | User | B | Belum | Route `users` & `users.verify` dobel |
| Konfigurasi | Profil akun | — | Belum | Hanya `edit`/`update` |
| Ekosistem | Semua | — | Dikecualikan | Lihat 11.3 |
| Dashboard | Semua | — | Dikecualikan | Ikuti 11.1 |
---
## 14. Checklist modul baru
- [ ] Pola ditentukan (A/B/C) sesuai 3.3
- [ ] Migration: `id`, kolom audit, `timestamps`, `softDeletes`, `deleted_by`; tanpa unique index
- [ ] Model: `$table` eksplisit, `$fillable`, `SoftDeletes`, `deleted_by` terisi, casts/Enum
- [ ] Relasi anak ditambahkan ke cascade `deleting` di model induk
- [ ] Form Request `Store`/`Update` dengan pesan Bahasa Indonesia dan unique `withoutTrashed()`
- [ ] Controller di `Web/{Domain}/` dengan nama method resmi saja
- [ ] Route: `Route::resource` (+ `only/except`, `shallow`), tidak dobel, route tambahan di atas resource
- [ ] View di `{domain}/{modul}/`: `index`, `create`, `edit`, `show`, `_form` sesuai pola
- [ ] JS di file terpisah, URL dari `route()`, CSRF terpasang
- [ ] Menu sidebar ditambahkan dan dibatasi per role; route dilindungi di server
- [ ] Respons/flash mengikuti Bagian 8
- [ ] Diuji manual: tambah, ubah, detail, hapus (cek `deleted_at`, `deleted_by`, data anak)
- [ ] Tidak ada file salinan/backup tertinggal
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Enums\Koperasi;
enum PosisiKoperasi: string
{
case PENGURUS = 'Pengurus koperasi';
case ANGGOTA = 'Anggota koperasi';
case PENDAMPING = 'Pendamping koperasi';
}
@@ -4,12 +4,13 @@ namespace App\Enums\PendidikanPelatihan;
enum PelatihanGAP: string
{
case PEMANGKASAN = 'Pemangkasan atau perawatan tanaman';
case PEMUPUKAN = 'Pemupukan atau nutrisi tanah';
case PENGENDALIAN = 'Pengendalian hama dan penyakit';
case PEMANGKASAN = 'Pemangkasan/perawatan tanaman';
case PEMUPUKAN = 'Pemupukan/nutrisi';
case PENGENDALIAN = 'Pengendalian hama & penyakit';
case PRAKTIK = 'Praktik Berkebun yang Baik (GAP)';
case PENGOLAHAN = 'Pengolahan/pascapanen';
case PENYERBUKAN = 'Penyerbukan manual (Vanili)';
case PENYERBUKAN = 'Penyerbukan Manual (Vanili)';
case LAINNYA = 'Lainnya';
public function label(): string
{
@@ -8,7 +8,7 @@ enum FrekuensiPemangkasanPendamping: string
case TIDAK_RUTIN = 'Memangkas, tapi tidak rutin dan tidak tahu tekniknya';
case SATU_KALI_TAHUN = 'Memangkas 1 kali/tahun, tahu jenis tekniknya';
case DUA_KALI_TAHUN = 'Memangkas 2 kali/tahun, tahu jenis tekniknya';
case LEBIH_DUA_KALI_TAHUN = 'Memangkas lebih dari 2 kali/tahun dengan teknik yang tepat dan pohon penaung juga dipangkas rutin';
case LEBIH_DUA_KALI_TAHUN = 'Memangkas lebih dari 2 kali/tahun dengan teknik yang tepat, dan pohon penaung juga dipangkas rutin';
public function label(): string
{
@@ -17,7 +17,7 @@ enum FrekuensiPemangkasanPendamping: string
self::TIDAK_RUTIN => 'Memangkas, tapi tidak rutin dan tidak tahu tekniknya',
self::SATU_KALI_TAHUN => 'Memangkas 1 kali/tahun, tahu jenis tekniknya',
self::DUA_KALI_TAHUN => 'Memangkas 2 kali/tahun, tahu jenis tekniknya',
self::LEBIH_DUA_KALI_TAHUN => 'Memangkas lebih dari 2 kali/tahun dengan teknik yang tepat dan pohon penaung juga dipangkas rutin',
self::LEBIH_DUA_KALI_TAHUN => 'Memangkas lebih dari 2 kali/tahun dengan teknik yang tepat, dan pohon penaung juga dipangkas rutin',
};
}
@@ -17,7 +17,7 @@ enum FrekuensiPemangkasanUtama: int
self::TIDAK_RUTIN => 'Memangkas, tapi tidak rutin dan tidak tahu tekniknya',
self::SATU_KALI_TAHUN => 'Memangkas 1 kali/tahun, tahu jenis tekniknya',
self::DUA_KALI_TAHUN => 'Memangkas 2 kali/tahun, tahu jenis tekniknya',
self::LEBIH_DUA_KALI_TAHUN => 'Memangkas lebih dari 2 kali/tahun dengan teknik yang tepat dan pohon penaung juga dipangkas rutin',
self::LEBIH_DUA_KALI_TAHUN => 'Memangkas lebih dari 2 kali/tahun dengan teknik yang tepat, dan pohon penaung juga dipangkas rutin',
};
}
@@ -16,8 +16,8 @@ enum MetodePenangananHamaPenyakit: int
self::TIDAK_ADA => 'Tidak ada penanganan',
self::CARA_TRADISIONAL => 'Cara tradisional (doa/ritual)',
self::PESTISIDA_SANITASI => 'Pestisida atau sanitasi',
self::KOMBINASI => 'Kombinasi pestisada dan sanitasi',
self::PHT => 'Pengendalian hama terpadu(PHT)',
self::KOMBINASI => 'Kombinasi pestisida dan sanitasi',
self::PHT => 'Pengendalian hama terpadu (PHT)',
};
}
+16 -1
View File
@@ -6,7 +6,22 @@ enum Role: string
{
case SUPER_ADMIN = 'super_admin';
case ADMIN = 'admin'; // admin program
case FASILITATOR = 'fasilitator';
case KOORDINATOR = 'koordinator'; // koordinator per komoditas
case ENUMERATOR = 'enumerator'; // penginput data petani di lapangan
case FASILITATOR = 'fasilitator'; // pemateri workshop
case PETANI = 'petani';
case OFFTAKER = 'offtaker';
public function label(): string
{
return match ($this) {
self::SUPER_ADMIN => 'Super Admin',
self::ADMIN => 'Admin',
self::KOORDINATOR => 'Koordinator',
self::ENUMERATOR => 'Enumerator',
self::FASILITATOR => 'Fasilitator',
self::PETANI => 'Petani',
self::OFFTAKER => 'Offtaker',
};
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Enums;
// Status kelengkapan data profil petani.
// Draf → Lengkap (ditandai enumerator penginput) → Terverifikasi (oleh koordinator, menu menyusul).
enum StatusDataPetani: string
{
case DRAFT = 'draft';
case LENGKAP = 'lengkap';
case TERVERIFIKASI = 'terverifikasi';
public function label(): string
{
return match ($this) {
self::DRAFT => 'Draf',
self::LENGKAP => 'Lengkap',
self::TERVERIFIKASI => 'Terverifikasi',
};
}
// Warna badge Vuexy (bg-label-{warna})
public function warna(): string
{
return match ($this) {
self::DRAFT => 'secondary',
self::LENGKAP => 'info',
self::TERVERIFIKASI => 'success',
};
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
@@ -20,7 +20,7 @@ class FormBasisDataController extends Controller
*/
public function index(Request $request, string $tabelId): JsonResponse
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -20,7 +20,7 @@ class TabelBasisDataController extends Controller
*/
public function index(Request $request): AnonymousResourceCollection
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -50,7 +50,7 @@ class TabelBasisDataController extends Controller
*/
public function show(Request $request, string $id): JsonResponse
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -19,7 +19,7 @@ class ValueBasisDataController extends Controller
public function index(Request $request, string $tabelId): JsonResponse
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -213,7 +213,7 @@ class ValueBasisDataController extends Controller
public function store(Request $request, string $tabelId): JsonResponse
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -278,7 +278,7 @@ class ValueBasisDataController extends Controller
public function update(Request $request, string $tabelId, string $id): JsonResponse
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -354,7 +354,7 @@ class ValueBasisDataController extends Controller
public function destroy(Request $request, string $tabelId, string $id): JsonResponse
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -20,7 +20,7 @@ class JenisKomoditasController extends Controller
*/
public function index(Request $request): AnonymousResourceCollection
{
if (!Gate::any(['petani', 'fasilitator', 'super_admin'])) {
if (!Gate::any(['petani', 'enumerator', 'super_admin'])) {
abort(403);
}
@@ -63,7 +63,7 @@ class JenisKomoditasController extends Controller
*/
public function store(Request $request): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator', 'super_admin'])) {
if (!Gate::any(['petani', 'enumerator', 'super_admin'])) {
abort(403);
}
@@ -85,7 +85,7 @@ class JenisKomoditasController extends Controller
*/
public function show(string $id): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator', 'super_admin'])) {
if (!Gate::any(['petani', 'enumerator', 'super_admin'])) {
abort(403);
}
@@ -107,7 +107,7 @@ class JenisKomoditasController extends Controller
*/
public function update(Request $request, string $id): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator', 'super_admin'])) {
if (!Gate::any(['petani', 'enumerator', 'super_admin'])) {
abort(403);
}
@@ -129,7 +129,7 @@ class JenisKomoditasController extends Controller
*/
public function destroy(string $id): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator', 'super_admin'])) {
if (!Gate::any(['petani', 'enumerator', 'super_admin'])) {
abort(403);
}
@@ -13,7 +13,7 @@ class DistributionController extends Controller
{
public function index(): JsonResponse
{
if (!Gate::any(['super_admin', 'petani','fasilitator', 'offtaker'])) {
if (!Gate::any(['super_admin', 'petani','enumerator', 'offtaker'])) {
abort(403);
}
@@ -35,7 +35,7 @@ class DistributionController extends Controller
public function store(Request $request): JsonResponse
{
if (!Gate::any(['super_admin', 'petani','fasilitator', 'offtaker'])) {
if (!Gate::any(['super_admin', 'petani','enumerator', 'offtaker'])) {
abort(403);
}
@@ -60,7 +60,7 @@ class DistributionController extends Controller
public function update(Request $request, string $id): JsonResponse
{
if (!Gate::any(['super_admin', 'petani','fasilitator', 'offtaker'])) {
if (!Gate::any(['super_admin', 'petani','enumerator', 'offtaker'])) {
abort(403);
}
@@ -86,7 +86,7 @@ class DistributionController extends Controller
public function batchUpsert(Request $request): JsonResponse
{
if (!Gate::any(['super_admin', 'petani','fasilitator', 'offtaker'])) {
if (!Gate::any(['super_admin', 'petani','enumerator', 'offtaker'])) {
abort(403);
}
@@ -21,7 +21,7 @@ class LahanController extends Controller
*/
public function index(Request $request): AnonymousResourceCollection
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
@@ -77,7 +77,7 @@ class LahanController extends Controller
*/
public function store(Request $request): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
@@ -91,7 +91,7 @@ class LahanController extends Controller
$request->merge(['profile_id' => (int) $request->profile_id]);
}
$profileRule = $user->role === 'fasilitator' ? ['required', 'numeric', 'exists:profiles,id'] : ['nullable'];
$profileRule = $user->role === 'enumerator' ? ['required', 'numeric', 'exists:profiles,id'] : ['nullable'];
$validated = $request->validate([
'nama' => ['required', 'string', 'max:255'],
@@ -105,11 +105,11 @@ class LahanController extends Controller
'foto.*' => ['image', 'mimes:jpeg,png,jpg', 'max:5120'], // Max 5MB
]);
$targetProfileId = $user->role === 'fasilitator'
$targetProfileId = $user->role === 'enumerator'
? $validated['profile_id']
: $profile->id;
if ($user->role === 'fasilitator') {
if ($user->role === 'enumerator') {
$targetProfile = Profile::findOrFail($targetProfileId);
if ($targetProfile->desa_kelurahan_id !== $profile->desa_kelurahan_id) {
@@ -117,7 +117,7 @@ class LahanController extends Controller
}
}
$desaKelurahanId = $user->role === 'fasilitator'
$desaKelurahanId = $user->role === 'enumerator'
? $profile->desa_kelurahan_id
: ($validated['desa_kelurahan_id'] ?? $profile->desa_kelurahan_id);
@@ -149,14 +149,14 @@ class LahanController extends Controller
*/
public function show(string $id): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
$lahan = Lahan::with('desaKelurahan')->findOrFail($id);
if ($lahan->profile_id !== Auth::user()->profile->id && Auth::user()->role === 'petani') {
return response()->json(['message' => 'Unauthorized'], 403);
} else if (Auth::user()->role === 'fasilitator' && $lahan->desa_kelurahan_id !== Auth::user()->profile->desa_kelurahan_id) {
} else if (Auth::user()->role === 'enumerator' && $lahan->desa_kelurahan_id !== Auth::user()->profile->desa_kelurahan_id) {
return response()->json(['message' => 'Unauthorized'], 403);
}
@@ -168,14 +168,14 @@ class LahanController extends Controller
*/
public function update(Request $request, string $id): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
$lahan = Lahan::findOrFail($id);
if ($lahan->profile_id !== Auth::user()->profile->id && Auth::user()->role === 'petani') {
return response()->json(['message' => 'Unauthorized'], 403);
} else if (Auth::user()->role === 'fasilitator' && $lahan->desa_kelurahan_id !== Auth::user()->profile->desa_kelurahan_id) {
} else if (Auth::user()->role === 'enumerator' && $lahan->desa_kelurahan_id !== Auth::user()->profile->desa_kelurahan_id) {
return response()->json(['message' => 'Unauthorized'], 403);
}
@@ -215,14 +215,14 @@ class LahanController extends Controller
*/
public function destroy(string $id): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
$lahan = Lahan::findOrFail($id);
if ($lahan->profile_id !== Auth::user()->profile->id && Auth::user()->role === 'petani') {
return response()->json(['message' => 'Unauthorized'], 403);
} else if (Auth::user()->role === 'fasilitator' && $lahan->desa_kelurahan_id !== Auth::user()->profile->desa_kelurahan_id) {
} else if (Auth::user()->role === 'enumerator' && $lahan->desa_kelurahan_id !== Auth::user()->profile->desa_kelurahan_id) {
return response()->json(['message' => 'Unauthorized'], 403);
}
@@ -233,7 +233,7 @@ class LahanController extends Controller
public function batchUpsert(Request $request): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
@@ -12,7 +12,7 @@ class OfftakerController extends Controller
{
public function index(Request $request): JsonResponse
{
if (!Gate::any(['super_admin', 'petani', 'fasilitator', 'admin','offtaker'])) {
if (!Gate::any(['super_admin', 'petani', 'enumerator', 'admin','offtaker'])) {
abort(403, 'Unauthorized');
}
@@ -29,7 +29,7 @@ class OfftakerController extends Controller
public function store(Request $request): JsonResponse
{
if (!Gate::any(['super_admin', 'petani', 'fasilitator', 'admin','offtaker'])) {
if (!Gate::any(['super_admin', 'petani', 'enumerator', 'admin','offtaker'])) {
abort(403, 'Unauthorized');
}
@@ -58,7 +58,7 @@ class OfftakerController extends Controller
public function show(string $id): JsonResponse
{
if (!Gate::any(['super_admin', 'petani', 'fasilitator', 'admin','offtaker'])) {
if (!Gate::any(['super_admin', 'petani', 'enumerator', 'admin','offtaker'])) {
abort(403, 'Unauthorized');
}
$offtaker = Offtaker::findOrFail($id);
@@ -67,7 +67,7 @@ class OfftakerController extends Controller
public function update(Request $request, string $id): JsonResponse
{
if (!Gate::any(['super_admin', 'petani', 'fasilitator', 'admin','offtaker'])) {
if (!Gate::any(['super_admin', 'petani', 'enumerator', 'admin','offtaker'])) {
abort(403, 'Unauthorized');
}
$offtaker = Offtaker::findOrFail($id);
@@ -87,7 +87,7 @@ class OfftakerController extends Controller
public function destroy(string $id): JsonResponse
{
if (!Gate::any(['super_admin', 'petani', 'fasilitator', 'admin','offtaker'])) {
if (!Gate::any(['super_admin', 'petani', 'enumerator', 'admin','offtaker'])) {
abort(403, 'Unauthorized');
}
Offtaker::findOrFail($id)->delete();
@@ -100,7 +100,7 @@ class OfftakerController extends Controller
*/
public function batchUpsert(Request $request): JsonResponse
{
if (!Gate::any(['super_admin', 'petani', 'fasilitator', 'admin','offtaker'])) {
if (!Gate::any(['super_admin', 'petani', 'enumerator', 'admin','offtaker'])) {
abort(403, 'Unauthorized');
}
@@ -15,7 +15,7 @@ class PanenController extends Controller
{
public function index(Request $request): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
@@ -30,7 +30,7 @@ class PanenController extends Controller
public function store(Request $request): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator', 'super_admin'])) {
if (!Gate::any(['petani', 'enumerator', 'super_admin'])) {
abort(403);
}
@@ -87,7 +87,7 @@ class PanenController extends Controller
public function show(string $id): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
$panen = Panen::findOrFail($id);
@@ -96,7 +96,7 @@ class PanenController extends Controller
public function update(Request $request, string $id): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
$panen = Panen::findOrFail($id);
@@ -114,7 +114,7 @@ class PanenController extends Controller
public function destroy(string $id): JsonResponse
{
Gate::authorize(['petani', 'fasilitator']);
Gate::authorize(['petani', 'enumerator']);
Panen::findOrFail($id)->delete();
return response()->json(null, 204);
}
@@ -124,7 +124,7 @@ class PanenController extends Controller
*/
public function batchUpsert(Request $request): JsonResponse
{
if (Gate::authorize(['petani', 'fasilitator','super_admin'])) {
if (Gate::authorize(['petani', 'enumerator','super_admin'])) {
abort(403);
}
$profileId = Auth::user()->profile->id;
@@ -14,7 +14,7 @@ class PanenController01 extends Controller
{
public function index(Request $request): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
@@ -29,7 +29,7 @@ class PanenController01 extends Controller
public function store(Request $request): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
@@ -58,7 +58,7 @@ class PanenController01 extends Controller
public function show(string $id): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
$panen = Panen::findOrFail($id);
@@ -67,7 +67,7 @@ class PanenController01 extends Controller
public function update(Request $request, string $id): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
$panen = Panen::findOrFail($id);
@@ -85,7 +85,7 @@ class PanenController01 extends Controller
public function destroy(string $id): JsonResponse
{
Gate::authorize(['petani', 'fasilitator']);
Gate::authorize(['petani', 'enumerator']);
Panen::findOrFail($id)->delete();
return response()->json(null, 204);
}
@@ -95,7 +95,7 @@ class PanenController01 extends Controller
*/
public function batchUpsert(Request $request): JsonResponse
{
if (Gate::authorize(['petani', 'fasilitator','super_admin'])) {
if (Gate::authorize(['petani', 'enumerator','super_admin'])) {
abort(403);
}
$profileId = Auth::user()->profile->id;
@@ -23,7 +23,7 @@ class ProfileController extends Controller
*/
public function index(Request $request)
{
if(!Gate::any(['fasilitator', 'super_admin'])) {
if(!Gate::any(['enumerator', 'super_admin'])) {
abort(403);
}
@@ -34,7 +34,7 @@ class ProfileController extends Controller
$profileId = $user->role === 'petani' ? $profile->id : $requestID;
$desaKelurahanId = null;
if ($profile->role === 'fasilitator' || $profile->role === 'super_admin') {
if ($profile->role === 'enumerator' || $profile->role === 'super_admin') {
$desaKelurahanId = $profile->desa_kelurahan_id;
}
@@ -95,7 +95,7 @@ class ProfileController extends Controller
*/
public function store(Request $request)
{
if(!Gate::any(['fasilitator','super_admin'])){
if(!Gate::any(['enumerator','super_admin'])){
abort(403);
}
@@ -139,7 +139,7 @@ class ProfileController extends Controller
public function show(string $id): JSONResponse
{
// attempt to access profile, only for allowed roles
if (!Gate::any(['petani', 'fasilitator', 'offtaker', 'super_admin'])) {
if (!Gate::any(['petani', 'enumerator', 'offtaker', 'super_admin'])) {
abort(403);
}
@@ -166,7 +166,7 @@ class ProfileController extends Controller
*/
public function update(Request $request, string $id): JSONResponse
{
if (!Gate::any(['petani', 'fasilitator', 'offtaker', 'super_admin'])) {
if (!Gate::any(['petani', 'enumerator', 'offtaker', 'super_admin'])) {
abort(403);
}
@@ -177,7 +177,7 @@ class ProfileController extends Controller
$profile = Profile::findOrFail($id);
if ($user->role === 'fasilitator' && $profile->desa_kelurahan_id != $user->profile->desa_kelurahan_id) {
if ($user->role === 'enumerator' && $profile->desa_kelurahan_id != $user->profile->desa_kelurahan_id) {
abort(403, 'Unauthorized');
}
@@ -239,7 +239,7 @@ class ProfileController extends Controller
public function downloadFile(string $id, string $type): JsonResponse | BinaryFileResponse
{
if (!Gate::any(['petani', 'fasilitator', 'super_admin'])) {
if (!Gate::any(['petani', 'enumerator', 'super_admin'])) {
abort(403);
}
@@ -260,7 +260,7 @@ class ProfileController extends Controller
public function verify(Request $request, string $id): JSONResponse
{
if (!Gate::any(['fasilitator'])) {
if (!Gate::any(['enumerator'])) {
abort(403);
}
@@ -22,7 +22,7 @@ class TanamanController extends Controller
*/
public function index(Request $request): AnonymousResourceCollection
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
$user = Auth::user();
@@ -91,7 +91,7 @@ class TanamanController extends Controller
*/
public function store(Request $request): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
@@ -135,7 +135,7 @@ class TanamanController extends Controller
*/
public function show(string $id): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
@@ -160,7 +160,7 @@ class TanamanController extends Controller
*/
public function update(Request $request, string $id): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
$tanaman = Tanaman::findOrFail($id);
@@ -195,7 +195,7 @@ class TanamanController extends Controller
*/
public function destroy(string $id): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
$tanaman = Tanaman::findOrFail($id);
@@ -22,7 +22,7 @@ class TanamanController extends Controller
*/
public function index(Request $request): AnonymousResourceCollection
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
$user = Auth::user();
@@ -91,7 +91,7 @@ class TanamanController extends Controller
*/
public function store(Request $request): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
@@ -141,7 +141,7 @@ class TanamanController extends Controller
*/
public function show(string $id): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
@@ -166,7 +166,7 @@ class TanamanController extends Controller
*/
public function update(Request $request, string $id): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
$tanaman = Tanaman::findOrFail($id);
@@ -201,7 +201,7 @@ class TanamanController extends Controller
*/
public function destroy(string $id): JsonResponse
{
if (!Gate::any(['petani', 'fasilitator','super_admin'])) {
if (!Gate::any(['petani', 'enumerator','super_admin'])) {
abort(403);
}
$tanaman = Tanaman::findOrFail($id);
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Web\Bumdes;
use App\Enums\Bumdes\JabatanBumdes;
use App\Enums\Pendidikan;
use App\Http\Controllers\Controller;
use App\Models\Bumdes\KelembagaanAktorBumdes;
use App\Models\Bumdes\PengurusBumdes;
use App\Models\Wilayah\Provinsi;
use Carbon\Carbon;
@@ -17,15 +18,12 @@ class PengurusBumdesController extends Controller
{
public function index(Request $request)
{
if (! Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (! Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
$query = PengurusBumdes::with([
'desaKelurahan:id,nama,kecamatan_id',
'desaKelurahan.kecamatan:id,nama,kabupaten_kota_id',
'desaKelurahan.kecamatan.kabupatenKota:id,nama,provinsi_id',
'desaKelurahan.kecamatan.kabupatenKota.provinsi:id,nama',
...$this->wilayahRelationsWithoutProgramScope(),
'creator:id,name',
])
->orderBy('created_at', 'desc');
@@ -38,7 +36,7 @@ class PengurusBumdesController extends Controller
});
}
if (auth()->user()->role->value === 'fasilitator') {
if (auth()->user()->role->value === 'enumerator') {
$query->where('created_by', auth()->id());
}
@@ -66,11 +64,11 @@ class PengurusBumdesController extends Controller
public function create()
{
if (! Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (! Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
$provinsi = Provinsi::select('id', 'kode', 'nama')->orderBy('kode', 'asc')->get();
$provinsi = $this->provinsiOptions();
$jabatan = JabatanBumdes::options();
$pendidikan = Pendidikan::options();
@@ -79,7 +77,7 @@ class PengurusBumdesController extends Controller
public function store(Request $request)
{
if (! Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (! Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -102,14 +100,14 @@ class PengurusBumdesController extends Controller
public function edit(string $id)
{
if (! Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (! Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
$pengurus = $this->pengurusQuery()->with([
'desaKelurahan.kecamatan.kabupatenKota.provinsi',
])->findOrFail($id);
$provinsi = Provinsi::select('id', 'kode', 'nama')->orderBy('kode', 'asc')->get();
$pengurus = $this->pengurusQuery()
->with($this->wilayahRelationsWithoutProgramScope())
->findOrFail($id);
$provinsi = $this->provinsiOptions();
$jabatan = JabatanBumdes::options();
$pendidikan = Pendidikan::options();
@@ -118,7 +116,7 @@ class PengurusBumdesController extends Controller
public function update(Request $request, string $id)
{
if (! Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (! Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -135,7 +133,7 @@ class PengurusBumdesController extends Controller
public function destroy(string $id)
{
if (! Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (! Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -148,28 +146,76 @@ class PengurusBumdesController extends Controller
public function kelembagaanAktor(string $id)
{
if (! Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (! Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
$pengurus = $this->pengurusQuery()->with([
'desaKelurahan.kecamatan.kabupatenKota.provinsi',
])->findOrFail($id);
$pengurus = $this->pengurusQuery()
->with([
...$this->wilayahRelationsWithoutProgramScope(),
'kelembagaanAktor',
])
->findOrFail($id);
$kelembagaanAktor = $pengurus->kelembagaanAktor;
return view('bumdes.kelembagaan-aktor.index', compact('pengurus'));
return view('bumdes.kelembagaan-aktor.index', array_merge(
compact('pengurus', 'kelembagaanAktor'),
$this->kelembagaanAktorOptions()
));
}
public function storeKelembagaanAktor(Request $request, string $id)
{
if (! Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
$pengurus = $this->pengurusQuery()->findOrFail($id);
$validatedData = $this->validatedKelembagaanAktorData($request);
KelembagaanAktorBumdes::updateOrCreate(
['pengurus_bumdes_id' => $pengurus->id],
[
...$this->formatKelembagaanAktorData($validatedData),
'created_by' => $pengurus->kelembagaanAktor?->created_by ?? auth()->id(),
'updated_by' => auth()->id(),
]
);
return redirect()
->route('bumdes.kelembagaan-aktor.index', $pengurus->id)
->with('success', 'Data kelembagaan dan aktor BUMDes berhasil disimpan.');
}
private function pengurusQuery()
{
$query = PengurusBumdes::query();
if (auth()->user()->role->value === 'fasilitator') {
if (auth()->user()->role->value === 'enumerator') {
$query->where('created_by', auth()->id());
}
return $query;
}
private function provinsiOptions()
{
return Provinsi::withoutGlobalScope('program')
->select('id', 'kode', 'nama')
->orderBy('kode', 'asc')
->get();
}
private function wilayahRelationsWithoutProgramScope(): array
{
return [
'desaKelurahan' => fn ($query) => $query->withoutGlobalScope('program')->select('id', 'nama', 'kecamatan_id'),
'desaKelurahan.kecamatan' => fn ($query) => $query->withoutGlobalScope('program')->select('id', 'nama', 'kabupaten_kota_id'),
'desaKelurahan.kecamatan.kabupatenKota' => fn ($query) => $query->withoutGlobalScope('program')->select('id', 'nama', 'provinsi_id'),
'desaKelurahan.kecamatan.kabupatenKota.provinsi' => fn ($query) => $query->withoutGlobalScope('program')->select('id', 'nama'),
];
}
private function validatedData(Request $request): array
{
return $request->validate([
@@ -215,4 +261,320 @@ class PengurusBumdesController extends Controller
return $data;
}
private function validatedKelembagaanAktorData(Request $request): array
{
$this->normalizeKelembagaanAktorInput($request);
$maxYear = now()->year + 1;
return $request->validate([
'status_badan_hukum' => ['nullable', Rule::in(array_keys($this->statusBadanHukumOptions()))],
'nomor_dasar_hukum' => ['nullable', 'string', 'max:255'],
'tahun_dasar_hukum' => ['nullable', 'integer', 'digits:4', 'min:1900', 'max:' . $maxYear],
'status_keaktifan' => ['nullable', Rule::in(array_keys($this->statusKeaktifanOptions()))],
'alasan_tidak_aktif' => ['nullable', 'string'],
'unit_usaha' => ['nullable', 'array'],
'unit_usaha.*' => ['string', Rule::in(array_keys($this->unitUsahaOptions()))],
'unit_usaha_jasa_lainnya' => ['nullable', 'string', 'max:255'],
'status_unit_komoditas' => ['nullable', Rule::in(array_keys($this->statusUnitKomoditasOptions()))],
'nama_unit_komoditas' => ['nullable', 'string', 'max:255'],
'status_ad_art' => ['nullable', Rule::in(array_keys($this->statusAdArtOptions()))],
'tahun_ad_art' => ['nullable', 'integer', 'digits:4', 'min:1900', 'max:' . $maxYear],
'pengurus_inti' => ['nullable', 'array'],
'pengurus_inti.*.jabatan' => ['nullable', 'string', 'max:255'],
'pengurus_inti.*.nama' => ['nullable', 'string', 'max:255'],
'pengurus_inti.*.aktif' => ['nullable', 'boolean'],
'memiliki_staf' => ['nullable', 'boolean'],
'jumlah_staf' => ['nullable', 'integer', 'min:0'],
'unit_usaha_staf' => ['nullable', 'string'],
'modal_awal' => ['nullable', 'integer', 'min:0'],
'tahun_modal_awal' => ['nullable', 'integer', 'digits:4', 'min:1900', 'max:' . $maxYear],
'pernah_penambahan_modal' => ['nullable', 'boolean'],
'penambahan_modal' => ['nullable', 'array'],
'penambahan_modal.*.tahun' => ['nullable', 'integer', 'digits:4', 'min:1900', 'max:' . $maxYear],
'penambahan_modal.*.jumlah' => ['nullable', 'integer', 'min:0'],
'aset_produktif' => ['nullable', 'array'],
'aset_produktif.*.aset' => ['nullable', 'string', 'max:255'],
'aset_produktif.*.dipilih' => ['nullable', 'boolean'],
'aset_produktif.*.jumlah' => ['nullable', 'string', 'max:255'],
'aset_produktif.*.kondisi' => ['nullable', 'string', 'max:255'],
'aset_produktif_lainnya' => ['nullable', 'string', 'max:255'],
'status_kepemilikan_aset' => ['nullable', Rule::in(array_keys($this->statusKepemilikanAsetOptions()))],
'infrastruktur_pascapanen' => ['nullable', 'array'],
'infrastruktur_pascapanen.*.infrastruktur' => ['nullable', 'string', 'max:255'],
'infrastruktur_pascapanen.*.dipilih' => ['nullable', 'boolean'],
'infrastruktur_pascapanen.*.jumlah' => ['nullable', 'string', 'max:255'],
'infrastruktur_pascapanen.*.kondisi' => ['nullable', Rule::in(array_keys($this->kondisiInfrastrukturOptions()))],
'infrastruktur_pascapanen_lainnya' => ['nullable', 'string', 'max:255'],
], $this->kelembagaanAktorValidationMessages(), $this->kelembagaanAktorValidationAttributes());
}
private function normalizeKelembagaanAktorInput(Request $request): void
{
foreach (['modal_awal'] as $field) {
if ($request->has($field)) {
$request->merge([$field => preg_replace('/\D/', '', (string) $request->input($field))]);
}
}
$penambahanModal = collect($request->input('penambahan_modal', []))
->map(function ($row) {
if (isset($row['jumlah'])) {
$row['jumlah'] = preg_replace('/\D/', '', (string) $row['jumlah']);
}
return $row;
})
->all();
if ($request->has('penambahan_modal')) {
$request->merge(['penambahan_modal' => $penambahanModal]);
}
}
private function formatKelembagaanAktorData(array $data): array
{
$payload = array_merge([
'status_badan_hukum' => null,
'nomor_dasar_hukum' => null,
'tahun_dasar_hukum' => null,
'status_keaktifan' => null,
'alasan_tidak_aktif' => null,
'unit_usaha' => [],
'unit_usaha_jasa_lainnya' => null,
'status_unit_komoditas' => null,
'nama_unit_komoditas' => null,
'status_ad_art' => null,
'tahun_ad_art' => null,
'pengurus_inti' => [],
'memiliki_staf' => null,
'jumlah_staf' => null,
'unit_usaha_staf' => null,
'modal_awal' => null,
'tahun_modal_awal' => null,
'pernah_penambahan_modal' => null,
'penambahan_modal' => [],
'aset_produktif' => [],
'aset_produktif_lainnya' => null,
'status_kepemilikan_aset' => null,
'infrastruktur_pascapanen' => [],
'infrastruktur_pascapanen_lainnya' => null,
], $data);
$payload['unit_usaha'] = array_values($payload['unit_usaha'] ?? []);
$payload['pengurus_inti'] = $this->filterRows($payload['pengurus_inti'] ?? [], ['nama', 'aktif']);
$payload['penambahan_modal'] = $this->filterRows($payload['penambahan_modal'] ?? [], ['tahun', 'jumlah']);
$payload['aset_produktif'] = $this->filterRows($payload['aset_produktif'] ?? [], ['dipilih', 'jumlah', 'kondisi']);
$payload['infrastruktur_pascapanen'] = $this->filterRows($payload['infrastruktur_pascapanen'] ?? [], ['dipilih', 'jumlah', 'kondisi']);
if (($payload['status_keaktifan'] ?? null) !== 'tidak_aktif_vakum') {
$payload['alasan_tidak_aktif'] = null;
}
if (! in_array('jasa_lainnya', $payload['unit_usaha'], true)) {
$payload['unit_usaha_jasa_lainnya'] = null;
}
if (($payload['status_unit_komoditas'] ?? null) !== 'ya') {
$payload['nama_unit_komoditas'] = null;
}
if (($payload['status_ad_art'] ?? null) !== 'ada_lengkap') {
$payload['tahun_ad_art'] = null;
}
if (! (bool) ($payload['memiliki_staf'] ?? false)) {
$payload['jumlah_staf'] = null;
$payload['unit_usaha_staf'] = null;
}
if (! (bool) ($payload['pernah_penambahan_modal'] ?? false)) {
$payload['penambahan_modal'] = null;
}
if (! collect($payload['aset_produktif'] ?? [])->contains(fn ($row) => ($row['aset'] ?? null) === 'lainnya')) {
$payload['aset_produktif_lainnya'] = null;
}
if (! collect($payload['infrastruktur_pascapanen'] ?? [])->contains(fn ($row) => ($row['infrastruktur'] ?? null) === 'lainnya')) {
$payload['infrastruktur_pascapanen_lainnya'] = null;
}
return $payload;
}
private function filterRows(array $rows, array $valueKeys): array
{
return collect($rows)
->map(function ($row) {
return collect($row)
->map(fn ($value) => $value === '' ? null : $value)
->all();
})
->filter(function ($row) use ($valueKeys) {
foreach ($valueKeys as $key) {
if (filled($row[$key] ?? null)) {
return true;
}
}
return false;
})
->values()
->all();
}
private function kelembagaanAktorOptions(): array
{
return [
'statusBadanHukumOptions' => $this->statusBadanHukumOptions(),
'statusKeaktifanOptions' => $this->statusKeaktifanOptions(),
'unitUsahaOptions' => $this->unitUsahaOptions(),
'statusUnitKomoditasOptions' => $this->statusUnitKomoditasOptions(),
'statusAdArtOptions' => $this->statusAdArtOptions(),
'pengurusIntiRows' => $this->pengurusIntiRows(),
'asetProduktifRows' => $this->asetProduktifRows(),
'statusKepemilikanAsetOptions' => $this->statusKepemilikanAsetOptions(),
'infrastrukturPascapanenRows' => $this->infrastrukturPascapanenRows(),
'kondisiInfrastrukturOptions' => $this->kondisiInfrastrukturOptions(),
];
}
private function statusBadanHukumOptions(): array
{
return [
'sudah_berbadan_hukum' => 'Sudah berbadan hukum (terdaftar Kemenkumham)',
'proses_pendaftaran' => 'Proses pendaftaran',
'belum_berbadan_hukum' => 'Belum berbadan hukum',
'tidak_tahu' => 'Tidak tahu',
];
}
private function statusKeaktifanOptions(): array
{
return [
'aktif_penuh' => 'Aktif beroperasi penuh',
'aktif_terbatas' => 'Aktif tapi terbatas (sebagian unit usaha berjalan)',
'tidak_aktif_vakum' => 'Tidak aktif/vakum',
'berdiri_belum_beroperasi' => 'Sudah berdiri tapi belum pernah beroperasi',
];
}
private function unitUsahaOptions(): array
{
return [
'simpan_pinjam' => 'Simpan pinjam',
'perdagangan_toko_desa' => 'Perdagangan/toko desa',
'pengelolaan_air_bersih' => 'Pengelolaan air bersih',
'wisata_desa_agrowisata' => 'Wisata desa/agrowisata',
'pengelolaan_hasil_pertanian_komoditas' => 'Pengelolaan hasil pertanian/komoditas',
'penyewaan_alat_aset' => 'Penyewaan alat/aset',
'jasa_lainnya' => 'Jasa lainnya',
];
}
private function statusUnitKomoditasOptions(): array
{
return [
'ya' => 'Ya, ada unit khusus',
'ditangani_pengurus_inti' => 'Tidak ada unit khusus, ditangani langsung pengurus inti',
'direncanakan' => 'Belum ada, masih direncanakan',
];
}
private function statusAdArtOptions(): array
{
return [
'ada_lengkap' => 'Ada, lengkap',
'ada_tidak_lengkap' => 'Ada tapi tidak lengkap',
'tidak_ada' => 'Tidak ada',
'tidak_tahu' => 'Tidak tahu',
];
}
private function pengurusIntiRows(): array
{
return [
'penasihat_kepala_desa' => 'Penasihat (Kepala Desa)',
'ketua_pengawas' => 'Ketua Pengawas',
'direktur_ketua_bumdes' => 'Direktur/Ketua BUMDes',
'sekretaris' => 'Sekretaris',
'bendahara' => 'Bendahara',
'kepala_unit_usaha_komoditas' => 'Kepala Unit Usaha Komoditas',
'lainnya' => 'Lainnya',
];
}
private function asetProduktifRows(): array
{
return [
'bangunan_kantor' => 'Bangunan / kantor',
'gudang_penyimpanan' => 'Gudang penyimpanan',
'kendaraan_operasional_angkut' => 'Kendaraan operasional/angkut',
'lahan_usaha' => 'Lahan usaha',
'alat_pengolahan_komoditas' => 'Alat pengolahan komoditas',
'lainnya' => 'Lainnya',
];
}
private function statusKepemilikanAsetOptions(): array
{
return [
'milik_bumdes_sendiri' => 'Milik BUMDes sendiri',
'milik_desa_dipinjamkan_dikelola' => 'Milik desa (dipinjamkan/dikelola)',
'sewa_pinjam_pihak_lain' => 'Sewa/pinjam pihak lain',
];
}
private function infrastrukturPascapanenRows(): array
{
return [
'gudang_penyimpanan_hasil_panen' => 'Gudang penyimpanan hasil panen',
'alat_mesin_pengering' => 'Alat/mesin pengering',
'mesin_pulper_huller' => 'Mesin pulper/huller',
'timbangan' => 'Timbangan',
'alat_sortasi' => 'Alat sortasi',
'lainnya' => 'Lainnya',
];
}
private function kondisiInfrastrukturOptions(): array
{
return [
'baik_berfungsi' => 'Baik dan berfungsi',
'rusak_tidak_terpakai' => 'Rusak/tidak terpakai',
];
}
private function kelembagaanAktorValidationMessages(): array
{
return [
'integer' => ':attribute harus berupa angka.',
'digits' => ':attribute harus terdiri dari :digits digit.',
'min' => ':attribute tidak boleh kurang dari :min.',
'max' => ':attribute tidak valid.',
'in' => ':attribute tidak valid.',
'array' => ':attribute harus berupa daftar data.',
'boolean' => ':attribute harus dipilih.',
];
}
private function kelembagaanAktorValidationAttributes(): array
{
return [
'status_badan_hukum' => 'Status badan hukum',
'nomor_dasar_hukum' => 'Nomor dasar hukum',
'tahun_dasar_hukum' => 'Tahun dasar hukum',
'status_keaktifan' => 'Status keaktifan',
'unit_usaha' => 'Unit usaha',
'status_unit_komoditas' => 'Unit usaha komoditas',
'status_ad_art' => 'Dokumen AD/ART',
'memiliki_staf' => 'Status staf/karyawan',
'jumlah_staf' => 'Jumlah staf',
'modal_awal' => 'Modal awal',
'tahun_modal_awal' => 'Tahun modal awal',
'pernah_penambahan_modal' => 'Status penambahan modal',
'status_kepemilikan_aset' => 'Status kepemilikan aset',
];
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Web\Concerns;
use App\Models\Pertanian\Petani;
use App\Services\Produktivitas\KelengkapanPetani;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
use Illuminate\Support\Collection as BaseCollection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
/**
* Bantuan bersama menu Verifikasi > 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;
}
}
@@ -11,16 +11,66 @@ use App\Models\Pertanian\Petani;
use App\Models\Pertanian\Komoditas;
use App\Models\Pertanian\Lahan;
use App\Models\Pertanian\PendapatanKomoditas;
use App\Models\Pertanian\SosialEkonomi;
class DemografiPetaniController extends Controller
{
public function index(Request $request)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
$petaniCounts = Petani::selectRaw("
return redirect()->route('dashboard.karakteristik-petani', array_merge($request->query(), [
'tab' => 'demografi-petani',
]));
}
public function karakteristik(Request $request)
{
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
$activeTab = $request->query('tab') === 'indeks-petani'
? 'indeks-petani'
: 'demografi-petani';
$demografiData = $this->contentData($request);
$indeksData = app(IndeksPetaniController::class)->contentData($request);
$tableFilters = [
'gender' => ['Gender', ['1' => 'Laki-laki', '0' => 'Perempuan']],
'umur' => ['Kelompok Umur', ['kurang_30' => '< 30 tahun', '30_39' => '30–39 tahun', '40_49' => '40–49 tahun', '50_59' => '50–59 tahun', '60_keatas' => '≥ 60 tahun']],
'pendidikan' => ['Pendidikan', ['tidak_sekolah' => 'Tidak Sekolah', 'sd' => 'SD', 'smp' => 'SMP', 'sma' => 'SMA', 'diploma' => 'Diploma', 's1_keatas' => 'Sarjana ke atas']],
'jumlah_tanggungan' => ['Jumlah Tanggungan', ['0' => '0 orang', '1_2' => '1–2 orang', '3_4' => '3–4 orang', '5_keatas' => '≥ 5 orang']],
'kelompok_tani' => ['Keanggotaan Kelompok Tani', ['terdaftar' => 'Terdaftar', 'tidak_terdaftar' => 'Tidak Terdaftar', 'belum_terisi' => 'Belum Terisi']],
'komoditas' => ['Komoditas', $indeksData['komoditas']->pluck('nama', 'id')->all()],
'luas_lahan' => ['Luas Lahan', ['kurang_05' => '< 0,5 ha', '05_1' => '0,5–1 ha', '1_2' => '> 1–2 ha', 'lebih_2' => '> 2 ha']],
'status_kepemilikan_lahan' => ['Status Kepemilikan Lahan', ['sendiri' => 'Milik Pribadi', 'sewa' => 'Sewa', 'adat' => 'Tanah Adat']],
'penghasilan' => ['Rentang Pendapatan', ['kurang_1' => '< Rp1 juta', '1_3' => 'Rp1–3 juta', '3_5' => '> Rp3–5 juta', '5_10' => '> Rp5–10 juta', 'lebih_10' => '> Rp10 juta']],
];
return view('dashboard.karakteristik-petani', [
'activeTab' => $activeTab,
'tableFilters' => $tableFilters,
'dashboardFilters' => $request->only([
'provinsi_id',
'kabupatenKota_id',
'kecamatan_id',
'desaKelurahan_id',
'komoditas_id',
'segmen',
]),
'indeksFilterAction' => route('dashboard.karakteristik-petani'),
] + $demografiData + $indeksData);
}
public function contentData(Request $request): array
{
$filteredPetaniIds = $this->filteredPetaniIdsQuery($request);
$petaniCounts = Petani::whereIn('petani.id', clone $filteredPetaniIds)->selectRaw("
COUNT(*) as total,
COUNT(*) FILTER (WHERE jenis_kelamin = true) as laki,
COUNT(*) FILTER (WHERE jenis_kelamin = false) as perempuan
@@ -30,14 +80,18 @@ class DemografiPetaniController extends Controller
$petaniLakilaki = $petaniCounts->laki;
$petaniPerempuan = $petaniCounts->perempuan;
$totalKomoditas = Petani::whereNotNull('komoditas_id')
->distinct()
->count('komoditas_id');
$totalKomoditas = Komoditas::query()
->where('is_utama', true)
->whereHas('petani', fn ($query) => $query->whereIn('petani.id', clone $filteredPetaniIds))
->count();
$totalLuasLahan = Lahan::has('petani')->sum('luas_lahan');
$totalLuasLahan = Lahan::has('petani')
->whereIn('petani_id', clone $filteredPetaniIds)
->sum('luas_lahan');
$ratarataLuasLahan = $totalLuasLahan && $totalPetani ? $totalLuasLahan / $totalPetani : 0;
$umurCounts = Petani::whereNotNull('tanggal_lahir')->selectRaw("
$umurCounts = Petani::whereIn('petani.id', clone $filteredPetaniIds)
->whereNotNull('tanggal_lahir')->selectRaw("
COUNT(*) FILTER (WHERE EXTRACT(YEAR FROM AGE(CURRENT_DATE, tanggal_lahir)) < 30) as kurang_30,
COUNT(*) FILTER (WHERE EXTRACT(YEAR FROM AGE(CURRENT_DATE, tanggal_lahir)) BETWEEN 30 AND 39) as umur_30_39,
COUNT(*) FILTER (WHERE EXTRACT(YEAR FROM AGE(CURRENT_DATE, tanggal_lahir)) BETWEEN 40 AND 49) as umur_40_49,
@@ -45,7 +99,8 @@ class DemografiPetaniController extends Controller
COUNT(*) FILTER (WHERE EXTRACT(YEAR FROM AGE(CURRENT_DATE, tanggal_lahir)) >= 60) as umur_60_keatas
")->first();
$pendidikanCounts = Petani::whereNotNull('pendidikan_terakhir')
$pendidikanCounts = Petani::whereIn('petani.id', clone $filteredPetaniIds)
->whereNotNull('pendidikan_terakhir')
->select('pendidikan_terakhir', DB::raw('COUNT(*) as total'))
->groupBy('pendidikan_terakhir')
->pluck('total', 'pendidikan_terakhir');
@@ -59,20 +114,71 @@ class DemografiPetaniController extends Controller
's1_keatas' => (int) ($pendidikanCounts['06_sarjana'] ?? 0) + (int) ($pendidikanCounts['07_magister'] ?? 0),
];
$jumlahTanggunganBuckets = SosialEkonomi::query()
->whereIn('petani_id', clone $filteredPetaniIds)
->whereNotNull('jumlah_tanggungan')
->selectRaw("
COUNT(DISTINCT petani_id) FILTER (WHERE jumlah_tanggungan = 0) as tanggungan_0,
COUNT(DISTINCT petani_id) FILTER (WHERE jumlah_tanggungan BETWEEN 1 AND 2) as tanggungan_1_2,
COUNT(DISTINCT petani_id) FILTER (WHERE jumlah_tanggungan BETWEEN 3 AND 4) as tanggungan_3_4,
COUNT(DISTINCT petani_id) FILTER (WHERE jumlah_tanggungan >= 5) as tanggungan_5_keatas
")
->first();
$jumlahTanggungan = [
'0' => (int) ($jumlahTanggunganBuckets->tanggungan_0 ?? 0),
'1_2' => (int) ($jumlahTanggunganBuckets->tanggungan_1_2 ?? 0),
'3_4' => (int) ($jumlahTanggunganBuckets->tanggungan_3_4 ?? 0),
'5_keatas' => (int) ($jumlahTanggunganBuckets->tanggungan_5_keatas ?? 0),
];
$kelompokTaniBuckets = Petani::query()
->whereIn('petani.id', clone $filteredPetaniIds)
->leftJoin('sosial_ekonomi', function ($join) {
$join->on('sosial_ekonomi.petani_id', '=', 'petani.id')
->whereNull('sosial_ekonomi.deleted_at');
})
->selectRaw("
COUNT(DISTINCT petani.id) FILTER (WHERE sosial_ekonomi.terdaftar_kelompok_tani = 'ya') as terdaftar,
COUNT(DISTINCT petani.id) FILTER (WHERE sosial_ekonomi.terdaftar_kelompok_tani = 'tidak') as tidak_terdaftar,
COUNT(DISTINCT petani.id) FILTER (WHERE sosial_ekonomi.terdaftar_kelompok_tani IS NULL OR sosial_ekonomi.terdaftar_kelompok_tani NOT IN ('ya', 'tidak')) as belum_terisi
")
->first();
$kelompokTani = [
'terdaftar' => (int) ($kelompokTaniBuckets->terdaftar ?? 0),
'tidak_terdaftar' => (int) ($kelompokTaniBuckets->tidak_terdaftar ?? 0),
'belum_terisi' => (int) ($kelompokTaniBuckets->belum_terisi ?? 0),
];
$desaSasaranRows = Petani::query()
->leftJoin('master_desa_kelurahan as desa', 'petani.desa_kelurahan_id', '=', 'desa.id')
->whereIn('petani.id', clone $filteredPetaniIds)
->selectRaw('petani.desa_kelurahan_id as desa_id')
->selectRaw("COALESCE(desa.nama, 'Tidak diketahui') as desa")
->selectRaw('COUNT(*) as total')
->groupBy('petani.desa_kelurahan_id', 'desa.nama')
->orderByDesc('total')
->orderBy('desa')
->get();
$komoditasUtama = Komoditas::query()
->whereHas('petani')
->withCount('petani')
->whereHas('petani', fn ($query) => $query->whereIn('petani.id', clone $filteredPetaniIds))
->withCount(['petani' => fn ($query) => $query->whereIn('petani.id', clone $filteredPetaniIds)])
->orderByDesc('petani_count')
->get(['id', 'nama']);
$luasLahanBuckets = DB::query()->fromSub(
Lahan::query()->select('petani_id')->selectRaw('SUM(luas_lahan) as total_luas')->groupBy('petani_id'),
Lahan::query()
->whereIn('petani_id', clone $filteredPetaniIds)
->select('petani_id')
->selectRaw('luas_lahan'),
'agg'
)->selectRaw("
COUNT(*) FILTER (WHERE total_luas < 5000) as kurang_05,
COUNT(*) FILTER (WHERE total_luas >= 5000 AND total_luas <= 10000) as luas_05_1,
COUNT(*) FILTER (WHERE total_luas > 10000 AND total_luas <= 20000) as luas_1_2,
COUNT(*) FILTER (WHERE total_luas > 20000) as lebih_2
COUNT(DISTINCT petani_id) FILTER (WHERE luas_lahan < 0.5) as kurang_05,
COUNT(DISTINCT petani_id) FILTER (WHERE luas_lahan >= 0.5 AND luas_lahan <= 1) as luas_05_1,
COUNT(DISTINCT petani_id) FILTER (WHERE luas_lahan > 1 AND luas_lahan <= 2) as luas_1_2,
COUNT(DISTINCT petani_id) FILTER (WHERE luas_lahan > 2) as lebih_2
")->first();
$luasLahan = [
@@ -82,14 +188,27 @@ class DemografiPetaniController extends Controller
'lebih_2' => $luasLahanBuckets->lebih_2,
];
$penghasilanBuckets = DB::query()->fromSub(
PendapatanKomoditas::query()
$statusKepemilikanLahanRows = Lahan::query()
->whereIn('petani_id', clone $filteredPetaniIds)
->whereNotNull('status_kepemilikan')
->selectRaw('status_kepemilikan as status')
->selectRaw('COUNT(DISTINCT petani_id) as total')
->groupBy('status_kepemilikan')
->orderByDesc('total')
->get();
$pendapatanPerPetaniQuery = PendapatanKomoditas::query()
->join('sosial_ekonomi', 'sosial_ekonomi.id', '=', 'pendapatan_komoditas.sosial_ekonomi_id')
->whereNotNull('sosial_ekonomi.petani_id')
->whereIn('sosial_ekonomi.petani_id', clone $filteredPetaniIds)
->groupBy('sosial_ekonomi.petani_id')
->selectRaw('SUM(pendapatan_komoditas.pendapatan) as total_pendapatan'),
->selectRaw('SUM(pendapatan_komoditas.pendapatan) as total_pendapatan');
$penghasilanBuckets = DB::query()->fromSub(
clone $pendapatanPerPetaniQuery,
'agg'
)->selectRaw("
AVG(total_pendapatan) as rata_rata,
COUNT(*) FILTER (WHERE total_pendapatan < 1000000) as kurang_1,
COUNT(*) FILTER (WHERE total_pendapatan >= 1000000 AND total_pendapatan <= 3000000) as p1_3,
COUNT(*) FILTER (WHERE total_pendapatan > 3000000 AND total_pendapatan <= 5000000) as p3_5,
@@ -97,6 +216,8 @@ class DemografiPetaniController extends Controller
COUNT(*) FILTER (WHERE total_pendapatan > 10000000) as lebih_10
")->first();
$ratarataPenghasilan = (float) ($penghasilanBuckets->rata_rata ?? 0);
$penghasilan = [
'kurang_1' => $penghasilanBuckets->kurang_1,
'1_3' => $penghasilanBuckets->p1_3,
@@ -112,6 +233,7 @@ class DemografiPetaniController extends Controller
'total_komoditas' => $totalKomoditas,
'total_luas_lahan' => $totalLuasLahan,
'ratarata_luas_lahan' => $ratarataLuasLahan,
'ratarata_penghasilan' => $ratarataPenghasilan,
'kelompok_umur' => [
'kurang_30' => $umurCounts->kurang_30,
'30_39' => $umurCounts->umur_30_39,
@@ -120,31 +242,88 @@ class DemografiPetaniController extends Controller
'60_keatas' => $umurCounts->umur_60_keatas,
],
'pendidikan' => $pendidikan,
'jumlah_tanggungan' => $jumlahTanggungan,
'kelompok_tani' => $kelompokTani,
'desa_sasaran' => [
'total' => $desaSasaranRows->count(),
'ids' => $desaSasaranRows->pluck('desa_id')->values(),
'labels' => $desaSasaranRows->pluck('desa')->values(),
'series' => $desaSasaranRows->pluck('total')->map(fn ($v) => (int) $v)->values(),
],
'komoditas_utama' => [
'ids' => $komoditasUtama->pluck('id')->values(),
'labels' => $komoditasUtama->pluck('nama')->values(),
'series' => $komoditasUtama->pluck('petani_count')->map(fn ($v) => (int) $v)->values(),
],
'luas_lahan' => $luasLahan,
'status_kepemilikan_lahan' => [
'keys' => $statusKepemilikanLahanRows->pluck('status')->values(),
'labels' => $statusKepemilikanLahanRows
->pluck('status')
->map(fn ($status) => $this->statusLahanLabel((string) $status))
->values(),
'series' => $statusKepemilikanLahanRows->pluck('total')->map(fn ($v) => (int) $v)->values(),
],
'penghasilan' => $penghasilan,
];
$daftarPetani = Petani::with(['komoditas'])
->withSum('lahans as total_luas_lahan', 'luas_lahan')
->orderBy('id')
->paginate(10);
return compact('data');
}
return view('dashboard.demografi-petani', compact('data', 'daftarPetani'));
private function filteredPetaniIdsQuery(Request $request)
{
$query = Petani::query()
->leftJoin('master_desa_kelurahan as desa', 'petani.desa_kelurahan_id', '=', 'desa.id')
->leftJoin('master_kecamatan as kecamatan', 'desa.kecamatan_id', '=', 'kecamatan.id')
->leftJoin('master_kabupaten_kota as kabupaten', 'kecamatan.kabupaten_kota_id', '=', 'kabupaten.id')
->when($request->filled('provinsi_id'), fn ($query) => $query->where('kabupaten.provinsi_id', $request->provinsi_id))
->when($request->filled('kabupatenKota_id'), fn ($query) => $query->where('kecamatan.kabupaten_kota_id', $request->kabupatenKota_id))
->when($request->filled('kecamatan_id'), fn ($query) => $query->where('desa.kecamatan_id', $request->kecamatan_id))
->when($request->filled('desaKelurahan_id'), fn ($query) => $query->where('petani.desa_kelurahan_id', $request->desaKelurahan_id))
->when($request->filled('komoditas_id'), fn ($query) => $query->where('petani.komoditas_id', $request->komoditas_id));
if ($request->filled('segmen')) {
$query->whereHas('skor', function ($query) use ($request) {
match ($request->segmen) {
'Penggerak' => $query->where('skor_kesiapan_adopsi', '>=', 4),
'Perintis' => $query->where('skor_kesiapan_adopsi', '>=', 3)
->where('skor_kesiapan_adopsi', '<', 4),
'Pemerhati' => $query->where('skor_kesiapan_adopsi', '>=', 2)
->where('skor_kesiapan_adopsi', '<', 3),
'Pemula' => $query->where('skor_kesiapan_adopsi', '>=', 1)
->where('skor_kesiapan_adopsi', '<', 2),
'Belum Lengkap' => $query->where(function ($query) {
$query->whereNull('skor_kesiapan_adopsi')
->orWhere('skor_kesiapan_adopsi', '<', 1);
}),
default => null,
};
});
}
return $query->select('petani.id');
}
private function statusLahanLabel(string $status): string
{
return match ($status) {
'sendiri' => 'Milik Pribadi',
'sewa' => 'Sewa',
'adat' => 'Tanah Adat',
default => ucwords(str_replace('_', ' ', $status)),
};
}
public function data(Request $request)
{
$filteredPetaniIds = $this->filteredPetaniIdsQuery($request);
$pendapatanSub = PendapatanKomoditas::query()
->join('sosial_ekonomi', 'sosial_ekonomi.id', '=', 'pendapatan_komoditas.sosial_ekonomi_id')
->whereColumn('sosial_ekonomi.petani_id', 'petani.id')
->selectRaw('SUM(pendapatan_komoditas.pendapatan)');
$query = Petani::with('komoditas')
->whereIn('petani.id', clone $filteredPetaniIds)
->withSum('lahans as total_luas_lahan', 'luas_lahan')
->addSelect(['total_pendapatan' => $pendapatanSub]);
@@ -187,6 +366,45 @@ class DemografiPetaniController extends Controller
}
}
if ($request->filled('jumlah_tanggungan')) {
$tanggunganQuery = SosialEkonomi::query()
->whereIn('petani_id', clone $filteredPetaniIds)
->whereNotNull('jumlah_tanggungan')
->select('petani_id')
->distinct();
match ($request->jumlah_tanggungan) {
'0' => $tanggunganQuery->where('jumlah_tanggungan', 0),
'1_2' => $tanggunganQuery->whereBetween('jumlah_tanggungan', [1, 2]),
'3_4' => $tanggunganQuery->whereBetween('jumlah_tanggungan', [3, 4]),
'5_keatas' => $tanggunganQuery->where('jumlah_tanggungan', '>=', 5),
default => null,
};
$query->whereIn('petani.id', $tanggunganQuery);
}
if ($request->filled('kelompok_tani')) {
match ($request->kelompok_tani) {
'terdaftar' => $query->whereIn('petani.id', SosialEkonomi::query()
->whereIn('petani_id', clone $filteredPetaniIds)
->where('terdaftar_kelompok_tani', 'ya')
->select('petani_id')
->distinct()),
'tidak_terdaftar' => $query->whereIn('petani.id', SosialEkonomi::query()
->whereIn('petani_id', clone $filteredPetaniIds)
->where('terdaftar_kelompok_tani', 'tidak')
->select('petani_id')
->distinct()),
'belum_terisi' => $query->whereNotIn('petani.id', SosialEkonomi::query()
->whereIn('petani_id', clone $filteredPetaniIds)
->whereIn('terdaftar_kelompok_tani', ['ya', 'tidak'])
->select('petani_id')
->distinct()),
default => null,
};
}
if ($request->filled('komoditas')) {
$query->where('komoditas_id', $request->komoditas);
}
@@ -213,15 +431,28 @@ class DemografiPetaniController extends Controller
}
if ($request->filled('luas_lahan')) {
$lahanQuery = Lahan::query()->select('petani_id')->groupBy('petani_id');
$lahanQuery = Lahan::query()
->whereIn('petani_id', clone $filteredPetaniIds)
->select('petani_id')
->distinct();
match ($request->luas_lahan) {
'kurang_05' => $lahanQuery->havingRaw('SUM(luas_lahan) < 5000'),
'05_1' => $lahanQuery->havingRaw('SUM(luas_lahan) >= 5000 AND SUM(luas_lahan) <= 10000'),
'1_2' => $lahanQuery->havingRaw('SUM(luas_lahan) > 10000 AND SUM(luas_lahan) <= 20000'),
'lebih_2' => $lahanQuery->havingRaw('SUM(luas_lahan) > 20000'),
'kurang_05' => $lahanQuery->where('luas_lahan', '<', 0.5),
'05_1' => $lahanQuery->whereBetween('luas_lahan', [0.5, 1]),
'1_2' => $lahanQuery->where('luas_lahan', '>', 1)->where('luas_lahan', '<=', 2),
'lebih_2' => $lahanQuery->where('luas_lahan', '>', 2),
default => null,
};
$query->whereIn('id', $lahanQuery->pluck('petani_id'));
$query->whereIn('petani.id', $lahanQuery);
}
if ($request->filled('status_kepemilikan_lahan')) {
$query->whereIn('petani.id', Lahan::query()
->whereIn('petani_id', clone $filteredPetaniIds)
->where('status_kepemilikan', $request->status_kepemilikan_lahan)
->select('petani_id')
->distinct());
}
$daftarPetani = $query->orderBy('id')->paginate(10);
@@ -5,21 +5,14 @@ namespace App\Http\Controllers\Web\Dashboard;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Database\Eloquent\Builder;
use App\Models\Pertanian\Petani;
use App\Models\Pertanian\Komoditas;
use App\Models\Pertanian\Lahan;
use App\Models\Pertanian\Tanaman;
use App\Models\Pertanian\SkorPetani;
use App\Models\Wilayah\Provinsi;
use App\Models\Wilayah\KabupatenKota;
use App\Models\Wilayah\Kecamatan;
use App\Models\Wilayah\DesaKelurahan;
class IndeksPetaniController extends Controller
{
@@ -36,13 +29,13 @@ class IndeksPetaniController extends Controller
];
}
private function petaniQuery(array $filters): \Illuminate\Database\Eloquent\Builder
private function petaniQuery(array $filters): Builder
{
$petaniQuery = Petani::query()
->join('master_desa_kelurahan', 'petani.desa_kelurahan_id', '=', 'master_desa_kelurahan.id')
->join('master_kecamatan', 'master_desa_kelurahan.kecamatan_id', '=', 'master_kecamatan.id')
->join('master_kabupaten_kota', 'master_kecamatan.kabupaten_kota_id', '=', 'master_kabupaten_kota.id')
->join('master_provinsi', 'master_kabupaten_kota.provinsi_id', '=', 'master_provinsi.id')
->leftJoin('master_desa_kelurahan', 'petani.desa_kelurahan_id', '=', 'master_desa_kelurahan.id')
->leftJoin('master_kecamatan', 'master_desa_kelurahan.kecamatan_id', '=', 'master_kecamatan.id')
->leftJoin('master_kabupaten_kota', 'master_kecamatan.kabupaten_kota_id', '=', 'master_kabupaten_kota.id')
->leftJoin('master_provinsi', 'master_kabupaten_kota.provinsi_id', '=', 'master_provinsi.id')
->whereNull('petani.deleted_at')
->when($filters['provinsi_id'], fn($q, $id) => $q->where('master_kabupaten_kota.provinsi_id', $id))
->when($filters['kabupatenKota_id'], fn($q, $id) => $q->where('master_kecamatan.kabupaten_kota_id', $id))
@@ -50,17 +43,21 @@ class IndeksPetaniController extends Controller
->when($filters['desaKelurahan_id'], fn($q, $id) => $q->where('petani.desa_kelurahan_id', $id))
->when($filters['komoditas_id'], fn($q, $id) => $q->where('petani.komoditas_id', $id));
if ($filters['segmen']) {
$petaniQuery->whereHas('skor', fn (Builder $query) => $this->applySegmentFilter($query, $filters['segmen']));
}
return $petaniQuery;
}
private function skorPetaniQuery(array $filters, array $komoditas): \Illuminate\Database\Eloquent\Builder
private function skorPetaniQuery(array $filters, array $komoditas): Builder
{
$skor = SkorPetani::query()
->join('petani', 'petani_skor.petani_id', '=', 'petani.id')
->join('master_desa_kelurahan', 'petani.desa_kelurahan_id', '=', 'master_desa_kelurahan.id')
->join('master_kecamatan', 'master_desa_kelurahan.kecamatan_id', '=', 'master_kecamatan.id')
->join('master_kabupaten_kota', 'master_kecamatan.kabupaten_kota_id', '=', 'master_kabupaten_kota.id')
->join('master_provinsi', 'master_kabupaten_kota.provinsi_id', '=', 'master_provinsi.id')
->leftJoin('master_desa_kelurahan', 'petani.desa_kelurahan_id', '=', 'master_desa_kelurahan.id')
->leftJoin('master_kecamatan', 'master_desa_kelurahan.kecamatan_id', '=', 'master_kecamatan.id')
->leftJoin('master_kabupaten_kota', 'master_kecamatan.kabupaten_kota_id', '=', 'master_kabupaten_kota.id')
->leftJoin('master_provinsi', 'master_kabupaten_kota.provinsi_id', '=', 'master_provinsi.id')
->whereNull('petani.deleted_at')
->when($filters['provinsi_id'], fn($q, $id) => $q->where('master_kabupaten_kota.provinsi_id', $id))
->when($filters['kabupatenKota_id'], fn($q, $id) => $q->where('master_kecamatan.kabupaten_kota_id', $id))
@@ -68,53 +65,61 @@ class IndeksPetaniController extends Controller
->when($filters['desaKelurahan_id'], fn($q, $id) => $q->where('petani.desa_kelurahan_id', $id))
->whereIn('petani.komoditas_id', $komoditas);
if ($filters['segmen']) {
$skor->where(function ($q) use ($filters) {
match ($filters['segmen']) {
'Penggerak' => $q->where('skor_kesiapan_adopsi', '>=', 4),
'Perintis' => $q->where(function($q) {
$q->where('skor_kesiapan_adopsi', '>=', 3)
->where('skor_kesiapan_adopsi', '<', 4);
}),
'Pemerhati' => $q->where(function($q) {
$q->where('skor_kesiapan_adopsi', '>=', 2)
->where('skor_kesiapan_adopsi', '<', 3);
}),
'Pemula' => $q->where(function($q) {
$q->where('skor_kesiapan_adopsi', '>=', 1)
->where('skor_kesiapan_adopsi', '<', 2);
}),
'Belum Lengkap' => $q->where(function ($q) {
$q->whereNull('skor_kesiapan_adopsi')
->orWhere('skor_kesiapan_adopsi', '<', 1);
}),
default => null,
};
});
return $this->applySegmentFilter($skor, $filters['segmen']);
}
return $skor;
private function applySegmentFilter(Builder $query, ?string $segment): Builder
{
$ranges = [
'Penggerak' => [4, null],
'Perintis' => [3, 4],
'Pemerhati' => [2, 3],
'Pemula' => [1, 2],
];
if (isset($ranges[$segment])) {
[$min, $max] = $ranges[$segment];
return $query->where('skor_kesiapan_adopsi', '>=', $min)
->when($max !== null, fn (Builder $query) => $query->where('skor_kesiapan_adopsi', '<', $max));
}
if ($segment === 'Belum Lengkap') {
$query->where(fn (Builder $query) => $query->whereNull('skor_kesiapan_adopsi')
->orWhere('skor_kesiapan_adopsi', '<', 1));
}
return $query;
}
public function index(Request $request)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
return redirect()->route('dashboard.karakteristik-petani', array_merge($request->query(), [
'tab' => 'indeks-petani',
]));
}
public function contentData(Request $request): array
{
$filters = $this->filters($request);
$title = 'Dashboard Profil Petani';
$segments = $this->segments();
$colors = collect($segments)
->pluck('color')
->map(fn($c) => "var(--bs-{$c})")
->values();
$komoditas = Komoditas::where('is_utama', true)->get();
$petaniQuery = $this->petaniQuery($filters);
$komoditas = Komoditas::where('is_utama', true)->get(['id', 'nama']);
$petaniQuery = $this->petaniQuery(array_fill_keys(array_keys($filters), null));
$desaKelurahan = $petaniQuery->clone()
->selectRaw("
master_desa_kelurahan.id as desa_kelurahan_id,
master_desa_kelurahan.nama as desa_kelurahan,
master_desa_kelurahan.path as path,
master_kecamatan.id as kecamatan_id,
master_kecamatan.nama as kecamatan,
master_kabupaten_kota.id as kabupaten_id,
@@ -122,9 +127,15 @@ class IndeksPetaniController extends Controller
master_provinsi.id as provinsi_id,
master_provinsi.nama as provinsi
")
->whereNotNull('master_provinsi.id')
->distinct()
->get();
$provinsi = $desaKelurahan
->unique('provinsi_id')
->sortBy('provinsi')
->values();
$wilayah = $desaKelurahan
->groupBy('provinsi_id')
->map(function ($provinsi) {
@@ -147,7 +158,6 @@ class IndeksPetaniController extends Controller
return [
'id' => $desa->desa_kelurahan_id,
'nama' => $desa->desa_kelurahan,
'path' => $desa->path,
];
})->values(),
];
@@ -157,12 +167,15 @@ class IndeksPetaniController extends Controller
];
})->values();
return view('dashboard.indeks-petani', array_merge($filters,
return array_merge($filters,
compact(
'komoditas',
'desaKelurahan',
'provinsi',
'wilayah',
)));
'segments',
'colors',
));
}
public function summary(Request $request): JsonResponse
@@ -171,23 +184,21 @@ class IndeksPetaniController extends Controller
$petaniQuery = $this->petaniQuery($filters);
$komoditas = Komoditas::where('is_utama', true)
->when($filters['komoditas_id'], fn($q, $id) => $q->where('id', $id))->get();
->when($filters['komoditas_id'], fn($q, $id) => $q->where('id', $id))->get(['id', 'nama']);
$countKomoditas = $petaniQuery->clone()
->whereHas('komoditas', fn ($query) => $query->where('is_utama', true))
->distinct()->count('komoditas_id');
$counts = $petaniQuery->clone()
->selectRaw('COUNT(*) as petani')
->selectRaw('COUNT(DISTINCT petani.komoditas_id) FILTER (WHERE petani.komoditas_id IN (SELECT id FROM master_komoditas WHERE is_utama = true AND deleted_at IS NULL)) as komoditas')
->selectRaw('COUNT(DISTINCT petani.desa_kelurahan_id) FILTER (WHERE petani.komoditas_id IN (SELECT id FROM master_komoditas WHERE is_utama = true AND deleted_at IS NULL)) as desa')
->selectRaw('COUNT(DISTINCT master_kecamatan.id) FILTER (WHERE petani.komoditas_id IN (SELECT id FROM master_komoditas WHERE is_utama = true AND deleted_at IS NULL)) as kecamatan')
->first();
$filteredPetaniIds = $petaniQuery
->clone()
->select('petani.id')
->distinct()
->pluck('petani.id');
$sumLahan = Lahan::has('petani')->whereIn('petani_id', $filteredPetaniIds)->whereIn('komoditas_id', $komoditas->pluck('id'))->sum('luas_lahan');
$avgLahan = Lahan::has('petani')->whereIn('petani_id', $filteredPetaniIds)->whereIn('komoditas_id', $komoditas->pluck('id'))->avg('luas_lahan');
$countDesa = $petaniQuery->clone()->whereIn('komoditas_id', $komoditas->pluck('id'))->distinct()->count('desa_kelurahan_id');
$countKecamatan = $petaniQuery->clone()->whereIn('komoditas_id', $komoditas->pluck('id'))->distinct()->count('master_kecamatan.id');
$filteredPetaniIds = $petaniQuery->clone()->select('petani.id');
$lahan = Lahan::query()
->whereIn('petani_id', $filteredPetaniIds)
->whereIn('komoditas_id', $komoditas->pluck('id'))
->selectRaw('COALESCE(SUM(luas_lahan), 0) as total, AVG(luas_lahan) as rata_rata')
->first();
$skorQuery = $this->skorPetaniQuery($filters, $komoditas->pluck('id')->toArray());
$avgSkor = $skorQuery->clone()->avg('skor_kesiapan_adopsi');
@@ -205,12 +216,12 @@ class IndeksPetaniController extends Controller
}
return response()->json([
'petani' => $petaniQuery->clone()->count(),
'komoditas' => $countKomoditas,
'sum_lahan' => $sumLahan,
'avg_lahan' => $avgLahan,
'desa' => $countDesa,
'kecamatan' => $countKecamatan,
'petani' => (int) $counts->petani,
'komoditas' => (int) $counts->komoditas,
'sum_lahan' => (float) $lahan->total,
'avg_lahan' => (float) $lahan->rata_rata,
'desa' => (int) $counts->desa,
'kecamatan' => (int) $counts->kecamatan,
'skor' => $avgSkor,
'segmen' => $avgSegmen
]);
@@ -269,7 +280,7 @@ class IndeksPetaniController extends Controller
$case = $this->cases();
$komoditas = Komoditas::where('is_utama', true)
->when($filters['komoditas_id'], fn($q, $id) => $q->where('id', $id))->get();
->when($filters['komoditas_id'], fn($q, $id) => $q->where('id', $id))->get(['id', 'nama']);
$skorQuery = $this->skorPetaniQuery($filters, $komoditas->pluck('id')->toArray());
@@ -313,7 +324,7 @@ class IndeksPetaniController extends Controller
$case = $this->cases();
$komoditas = Komoditas::where('is_utama', true)
->when($filters['komoditas_id'], fn($q, $id) => $q->where('id', $id))->get();
->when($filters['komoditas_id'], fn($q, $id) => $q->where('id', $id))->get(['id', 'nama']);
$skorQuery = $this->skorPetaniQuery($filters, $komoditas->pluck('id')->toArray());
@@ -355,7 +366,7 @@ class IndeksPetaniController extends Controller
$filters = $this->filters($request);
$komoditas = Komoditas::where('is_utama', true)
->when($filters['komoditas_id'], fn($q, $id) => $q->where('id', $id))->get();
->when($filters['komoditas_id'], fn($q, $id) => $q->where('id', $id))->get(['id', 'nama']);
$skorQuery = $this->skorPetaniQuery($filters, $komoditas->pluck('id')->toArray());
@@ -380,7 +391,7 @@ class IndeksPetaniController extends Controller
$filters = $this->filters($request);
$komoditas = Komoditas::where('is_utama', true)
->when($filters['komoditas_id'], fn($q, $id) => $q->where('id', $id))->get();
->when($filters['komoditas_id'], fn($q, $id) => $q->where('id', $id))->get(['id', 'nama']);
$skorQuery = $this->skorPetaniQuery($filters, $komoditas->pluck('id')->toArray());
@@ -405,17 +416,8 @@ class IndeksPetaniController extends Controller
]);
}
public function map(Request $request): JsonResponse
private function villageData(array $filters, bool $includePath = true): \Illuminate\Support\Collection
{
$filters = $this->filters($request);
$segments = $this->segments();
$colors = collect($segments)
->pluck('color')
->map(fn($c) => "var(--bs-{$c})")
->values();
$segmentStyles = collect($segments)->keyBy('name');
$case = $this->cases();
$petaniQuery = $this->petaniQuery($filters);
@@ -424,7 +426,6 @@ class IndeksPetaniController extends Controller
->selectRaw("
master_desa_kelurahan.id as desa_kelurahan_id,
master_desa_kelurahan.nama as desa_kelurahan,
master_desa_kelurahan.path as path,
master_kecamatan.id as kecamatan_id,
master_kecamatan.nama as kecamatan,
master_kabupaten_kota.id as kabupaten_id,
@@ -432,11 +433,12 @@ class IndeksPetaniController extends Controller
master_provinsi.id as provinsi_id,
master_provinsi.nama as provinsi
")
->when($includePath, fn (Builder $query) => $query->addSelect('master_desa_kelurahan.path as path'))
->distinct()
->get();
$komoditas = Komoditas::where('is_utama', true)
->when($filters['komoditas_id'], fn($q, $id) => $q->where('id', $id))->get();
->when($filters['komoditas_id'], fn($q, $id) => $q->where('id', $id))->get(['id', 'nama']);
$skorQuery = $this->skorPetaniQuery($filters, $komoditas->pluck('id')->toArray());
@@ -464,17 +466,21 @@ class IndeksPetaniController extends Controller
->groupBy('petani.desa_kelurahan_id')
->get();
$segmenDominan = $mapSegmentasi
->groupBy('desa_kelurahan_id')
$segmentasiGroups = $mapSegmentasi->groupBy('desa_kelurahan_id');
$jumlahSegmenByDesa = $segmentasiGroups
->map(fn ($items) => $items->pluck('total', 'segmen'));
$segmenDominan = $segmentasiGroups
->map(function ($items) {
return $items->sortByDesc('total')->first();
return $items->reduce(fn ($largest, $item) => $largest === null || $item->total > $largest->total ? $item : $largest);
});
$mapAvgSkorSegmentasi->each(function ($item) use ($segmenDominan) {
$mapAvgSkorSegmentasi->each(function ($item) use ($segmenDominan, $jumlahSegmenByDesa) {
$dominan = $segmenDominan->get($item->desa_kelurahan_id);
$item->segmen_dominan = $dominan?->segmen;
$item->jumlah_dominan = $dominan?->total;
$item->jumlah_segmen = $jumlahSegmenByDesa->get($item->desa_kelurahan_id, collect());
});
$segmentasiByDesa = $mapAvgSkorSegmentasi->keyBy('desa_kelurahan_id');
@@ -490,97 +496,22 @@ class IndeksPetaniController extends Controller
return $desa;
})->filter()->values();
return response()->json($mapDesaSegmentasi);
return $mapDesaSegmentasi;
}
public function map(Request $request): JsonResponse
{
return response()->json($this->villageData($this->filters($request)));
}
public function desa(Request $request): JsonResponse
{
$filters = $this->filters($request);
$segments = $this->segments();
$segmentStyles = collect($this->segments())->keyBy('name');
$mapDesaSegmentasi = $this->villageData($this->filters($request), false);
$colors = collect($segments)
->pluck('color')
->map(fn($c) => "var(--bs-{$c})")
->values();
$segmentStyles = collect($segments)->keyBy('name');
$case = $this->cases();
$petaniQuery = $this->petaniQuery($filters);
$desaKelurahan = $petaniQuery->clone()
->selectRaw("
master_desa_kelurahan.id as desa_kelurahan_id,
master_desa_kelurahan.nama as desa_kelurahan,
master_desa_kelurahan.path as path,
master_kecamatan.id as kecamatan_id,
master_kecamatan.nama as kecamatan,
master_kabupaten_kota.id as kabupaten_id,
master_kabupaten_kota.nama as kabupaten,
master_provinsi.id as provinsi_id,
master_provinsi.nama as provinsi
")
->distinct()
->get();
$komoditas = Komoditas::where('is_utama', true)
->when($filters['komoditas_id'], fn($q, $id) => $q->where('id', $id))->get();
$skorQuery = $this->skorPetaniQuery($filters, $komoditas->pluck('id')->toArray());
$mapSegmentasi = $skorQuery->clone()
->selectRaw("petani.desa_kelurahan_id,
$case AS segmen,
COUNT(petani.id) AS total
")
->groupBy('petani.desa_kelurahan_id')
->groupByRaw($case)
->get();
$mapAvgSkorSegmentasi = $skorQuery->clone()
->selectRaw("petani.desa_kelurahan_id,
COUNT(petani.id) AS jumlah_petani,
avg(skor_kesiapan_adopsi) AS skor,
CASE
WHEN AVG(skor_kesiapan_adopsi) >= 4 THEN 'Penggerak'
WHEN AVG(skor_kesiapan_adopsi) >= 3 THEN 'Perintis'
WHEN AVG(skor_kesiapan_adopsi) >= 2 THEN 'Pemerhati'
WHEN AVG(skor_kesiapan_adopsi) >= 1 THEN 'Pemula'
ELSE 'Belum Lengkap'
END AS segmen
")
->groupBy('petani.desa_kelurahan_id')
->get();
$segmenDominan = $mapSegmentasi
->groupBy('desa_kelurahan_id')
->map(function ($items) {
return $items->sortByDesc('total')->first();
});
$mapAvgSkorSegmentasi->each(function ($item) use ($segmenDominan) {
$dominan = $segmenDominan->get($item->desa_kelurahan_id);
$item->segmen_dominan = $dominan?->segmen;
$item->jumlah_dominan = $dominan?->total;
});
$segmentasiByDesa = $mapAvgSkorSegmentasi->keyBy('desa_kelurahan_id');
$desaLookup = $desaKelurahan->keyBy('desa_kelurahan_id');
$mapDesaSegmentasi = $segmentasiByDesa->map(function ($segmentasi, $desaId) use ($desaLookup) {
$desa = $desaLookup->get($desaId);
if (!$desa) {
return null;
}
$desa->segmentasi = $segmentasi;
return $desa;
})->filter()->values();
$rows = $mapDesaSegmentasi->map(function ($desa) use ($segmentStyles) {
$segmentNames = ['Pemula', 'Perintis', 'Pemerhati', 'Penggerak', 'Belum Lengkap'];
$rows = $mapDesaSegmentasi->map(function ($desa) use ($segmentStyles, $segmentNames) {
$total = (int) $desa->segmentasi->jumlah_petani;
$style = $segmentStyles[$desa->segmentasi->segmen] ?? ['color' => 'secondary'];
$styleDominan = $segmentStyles[$desa->segmentasi->segmen_dominan] ?? ['color' => 'secondary'];
@@ -594,6 +525,16 @@ class IndeksPetaniController extends Controller
'jumlah_dominan' => $desa->segmentasi->jumlah_dominan,
'color' => $style['color'],
'color_dominan' => $styleDominan['color'],
'segments' => collect($segmentNames)
->mapWithKeys(function ($name) use ($desa, $segmentStyles, $total) {
$count = (int) $desa->segmentasi->jumlah_segmen->get($name, 0);
return [strtolower(str_replace(' ', '_', $name)) => [
'count' => $count,
'percentage' => $total > 0 ? round($count / $total * 100, 2) : 0,
'color' => $segmentStyles[$name]['color'] ?? 'secondary',
]];
})->all(),
];
});
@@ -605,30 +546,21 @@ class IndeksPetaniController extends Controller
$filters = $this->filters($request);
$segments = $this->segments();
$colors = collect($segments)
->pluck('color')
->map(fn($c) => "var(--bs-{$c})")
->values();
$segmentStyles = collect($segments)->keyBy('name');
$petaniQuery = $this->petaniQuery($filters);
$komoditas = Komoditas::where('is_utama', true)
->when($filters['komoditas_id'], fn($q, $id) => $q->where('id', $id))->get();
->when($filters['komoditas_id'], fn($q, $id) => $q->where('id', $id))->get(['id', 'nama']);
$daftarPetani = $petaniQuery->clone()
->select('petani.*')
->with(['desaKelurahan', 'komoditas', 'skor'])
->withSum('lahans as total_luas_lahan', 'luas_lahan')
->whereIn('komoditas_id', $komoditas->pluck('id'))
->get();
$data = $daftarPetani
->filter(function ($item) use ($filters) {
if (!$filters['segmen']) {
return true;
}
$segmen = $item->skor ? $item->segmen() : 'Belum Lengkap';
return $segmen === $filters['segmen'];
})
->map(function ($item) use ($segmentStyles) {
$segmen = $item->skor
? $item->segmen()
@@ -642,7 +574,7 @@ class IndeksPetaniController extends Controller
'desa' => $item->desaKelurahan?->nama,
'desa_kelurahan_id' => $item->desa_kelurahan_id,
'komoditas' => $item->komoditas?->nama,
'luas_lahan' => $item->lahans?->sum('luas_lahan') ?? 0,
'luas_lahan' => (float) ($item->total_luas_lahan ?? 0),
'skor' => $item->skor
? $item->skor->skor_kesiapan_adopsi
: null,
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Web\Dashboard;
use App\Enums\KeparahanSerangan;
use App\Enums\PenyakitTanaman;
use App\Enums\PraktikBudidaya\KondisiPengelolaanAir;
use App\Enums\PraktikBudidaya\PemberianPupuk;
use App\Enums\SeranganHama;
use App\Enums\SosialEkonomi\StatusKepemilikanLahanSosialEkonomi;
@@ -13,12 +14,14 @@ use App\Models\Pertanian\Lahan;
use App\Models\Pertanian\Petani;
use App\Models\Pertanian\PraktikBudidaya;
use App\Models\Pertanian\Produksi;
use App\Models\Pertanian\SurveyPanen;
use App\Models\Pertanian\SkorPetani;
use App\Models\Pertanian\SosialEkonomi;
use App\Models\Pertanian\SurveyProduktivitas;
use App\Models\Wilayah\DesaKelurahan;
use App\Models\Wilayah\KabupatenKota;
use App\Models\Wilayah\Kecamatan;
use App\Models\Wilayah\Provinsi;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -95,11 +98,180 @@ class IndikatorPertanianController extends Controller
private function dashboardPayload(array $filters): array
{
return [
'overviewData' => $this->overviewData($filters),
'landProfileData' => $this->landProfileData($filters),
'summaryData' => $this->summaryData($filters),
'chartData' => $this->chartData($filters),
];
}
private function overviewData(array $filters): array
{
$totalPetani = $this->applyPetaniFilters(Petani::query(), $filters)->count();
$totalLahan = $this->applyLahanFilters(Lahan::query(), $filters)->count();
$totalLuasLahan = (float) $this->applyLahanFilters(Lahan::has('petani'), $filters)->sum('luas_lahan');
$jumlahDesa = $this->applyLahanFilters(Lahan::query(), $filters)
->whereNotNull('desa_kelurahan_id')
->distinct('desa_kelurahan_id')
->count('desa_kelurahan_id');
$jumlahKecamatan = $this->applyLahanFilters(Lahan::query(), $filters)
->leftJoin('master_desa_kelurahan as d', 'd.id', '=', 'map_lahan.desa_kelurahan_id')
->whereNotNull('d.kecamatan_id')
->distinct('d.kecamatan_id')
->count('d.kecamatan_id');
$totalProduksiKg = $this->totalProduksiKg($filters);
$rataRataProduktivitas = $totalLuasLahan > 0 ? $totalProduksiKg / $totalLuasLahan : 0;
$tanamanProduktif = $this->tanamanProduktifCount($filters);
$batchPanen = $this->batchPanenCount($filters);
return [
'kpiCards' => [
[
'value' => number_format($totalPetani, 0, ',', '.'),
'label' => 'Jumlah Petani',
'description' => 'Petani yang tercakup dalam filter aktif',
'icon' => 'tabler-users',
'variant' => 'primary',
],
[
'value' => number_format($totalLahan, 0, ',', '.'),
'label' => 'Jumlah Kebun/Lahan',
'description' => 'Kebun atau lahan terdata',
'icon' => 'tabler-trees',
'variant' => 'success',
],
[
'value' => number_format($totalLuasLahan, 2, ',', '.').' ha',
'label' => 'Luas Lahan',
'description' => 'Akumulasi luas lahan terdata',
'icon' => 'tabler-ruler-measure',
'variant' => 'warning',
],
[
'value' => number_format($jumlahDesa, 0, ',', '.').' desa',
'label' => 'Desa/Kecamatan',
'description' => number_format($jumlahKecamatan, 0, ',', '.').' kecamatan tercakup',
'icon' => 'tabler-map-pin',
'variant' => 'info',
],
[
'value' => number_format($totalProduksiKg, 0, ',', '.').' kg',
'label' => 'Total Produksi',
'description' => $this->productionSummaryDescription($filters),
'icon' => 'tabler-wheat',
'variant' => 'primary',
],
[
'value' => number_format($rataRataProduktivitas, 0, ',', '.').' kg/ha',
'label' => 'Produktivitas Rata-rata',
'description' => 'Berdasarkan produksi dan luas lahan terdata',
'icon' => 'tabler-chart-bar',
'variant' => 'success',
],
[
'value' => number_format($tanamanProduktif, 0, ',', '.'),
'label' => 'Tanaman Produktif',
'description' => 'Tanaman dengan kondisi baik',
'icon' => 'tabler-plant-2',
'variant' => 'warning',
],
[
'value' => number_format($batchPanen, 0, ',', '.'),
'label' => 'Batch Panen Tercatat',
'description' => 'Catatan produksi dan survey panen',
'icon' => 'tabler-basket',
'variant' => 'info',
],
],
'commodityFocus' => $this->commodityFocusData($filters),
'map' => [
'features' => $this->farmMapFeatures($filters),
'levels' => $this->mapLevelData($filters),
],
];
}
private function landProfileData(array $filters): array
{
$totalTanaman = (int) $this->applyLahanFilters(Lahan::query(), $filters)->sum('jumlah_tanaman');
$rataRataUmurTanaman = (float) $this->applyLahanFilters(Lahan::query(), $filters)
->whereNotNull('umur_tanaman')
->avg('umur_tanaman');
$komoditasCount = (int) $this->applyLahanFilters(Lahan::query(), $filters)
->whereNotNull('komoditas_id')
->distinct('komoditas_id')
->count('komoditas_id');
$statusLahanCount = (int) $this->applyLahanFilters(Lahan::query(), $filters)
->whereNotNull('status_kepemilikan')
->distinct('status_kepemilikan')
->count('status_kepemilikan');
$tanamanPenaung = $this->shadowTreeSummary($filters);
$tanamanPendamping = $this->companionPlantSummary($filters);
$diversityAverage = $this->plantDiversityAverage($filters);
return [
'conditionCards' => [
[
'value' => number_format($komoditasCount, 0, ',', '.'),
'label' => 'Luas Lahan per Komoditas',
'description' => 'Komoditas dengan luas lahan terdata',
'icon' => 'tabler-chart-treemap',
'variant' => 'primary',
],
[
'value' => number_format($totalTanaman, 0, ',', '.'),
'label' => 'Jumlah Tanaman',
'description' => 'Akumulasi jumlah tanaman pada lahan',
'icon' => 'tabler-trees',
'variant' => 'success',
],
[
'value' => number_format($rataRataUmurTanaman, 1, ',', '.').' tahun',
'label' => 'Umur Tanaman',
'description' => 'Rata-rata umur tanaman terdata',
'icon' => 'tabler-calendar-stats',
'variant' => 'warning',
],
[
'value' => number_format($statusLahanCount, 0, ',', '.'),
'label' => 'Status Lahan',
'description' => 'Jumlah kategori status kepemilikan lahan',
'icon' => 'tabler-certificate',
'variant' => 'info',
],
],
'agroecologyItems' => [
['label' => 'Sumber Air Lahan', 'value' => $this->sourceWaterSummary($filters), 'status' => 'available'],
['label' => 'Pengelolaan Air & Irigasi', 'value' => $this->waterManagementSummary($filters), 'status' => 'available'],
['label' => 'Kebutuhan Air per Musim', 'value' => $this->seasonalWaterNeedSummary($filters), 'status' => 'available'],
['label' => 'Praktik Konservasi Tanah', 'value' => $this->soilConservationSummary($filters), 'status' => 'available'],
],
'agroforestryCards' => [
[
'value' => number_format($tanamanPenaung['total'], 0, ',', '.'),
'label' => 'Jumlah Tanaman Penaung',
'description' => $tanamanPenaung['types'].' jenis tanaman penaung',
'icon' => 'tabler-leaf',
'variant' => 'success',
],
[
'value' => number_format($tanamanPendamping['types'], 0, ',', '.'),
'label' => 'Jenis Tanaman Pendamping',
'description' => number_format($tanamanPendamping['total'], 0, ',', '.').' entri tanaman pendamping',
'icon' => 'tabler-flower',
'variant' => 'warning',
],
[
'value' => number_format(round($diversityAverage), 0, ',', '.'),
'label' => 'Tingkat Campuran Tanaman',
'description' => 'Rata-rata kombinasi tanaman utama, pendamping, dan penaung',
'icon' => 'tabler-binary-tree',
'variant' => 'primary',
],
],
];
}
private function summaryData(array $filters): array
{
$totalPetani = $this->applyPetaniFilters(Petani::query(), $filters)->count();
@@ -166,6 +338,10 @@ class IndikatorPertanianController extends Controller
'statusPanen' => $this->statusPanenChart($filters),
'surveyProduktivitasHa' => $this->surveyProduktivitasByUnitChart($filters, 'ha_tahun'),
'surveyProduktivitasPohon' => $this->surveyProduktivitasByUnitChart($filters, 'pohon_tahun'),
'luasLahanPerKomoditas' => $this->luasLahanPerKomoditasChart($filters),
'jumlahTanamanPerKomoditas' => $this->jumlahTanamanPerKomoditasChart($filters),
'umurTanaman' => $this->umurTanamanChart($filters),
'sumberAir' => $this->sumberAirChart($filters),
];
}
@@ -355,6 +531,82 @@ class IndikatorPertanianController extends Controller
];
}
private function luasLahanPerKomoditasChart(array $filters): array
{
$rows = $this->applyLahanFilters(Lahan::query(), $filters)
->leftJoin('master_komoditas as k', 'k.id', '=', 'map_lahan.komoditas_id')
->whereNotNull('map_lahan.luas_lahan')
->selectRaw("COALESCE(k.nama, 'Belum ditentukan') as label")
->selectRaw('SUM(map_lahan.luas_lahan) as total')
->groupBy('k.id', 'k.nama')
->havingRaw('SUM(map_lahan.luas_lahan) > 0')
->orderByDesc('total')
->get();
return [
'labels' => $rows->pluck('label')->values()->all(),
'series' => $rows->pluck('total')->map(fn ($value) => round((float) $value, 2))->values()->all(),
];
}
private function jumlahTanamanPerKomoditasChart(array $filters): array
{
$rows = $this->applyLahanFilters(Lahan::query(), $filters)
->leftJoin('master_komoditas as k', 'k.id', '=', 'map_lahan.komoditas_id')
->whereNotNull('map_lahan.jumlah_tanaman')
->selectRaw("COALESCE(k.nama, 'Belum ditentukan') as label")
->selectRaw('SUM(map_lahan.jumlah_tanaman) as total')
->groupBy('k.id', 'k.nama')
->havingRaw('SUM(map_lahan.jumlah_tanaman) > 0')
->orderByDesc('total')
->get();
return [
'labels' => $rows->pluck('label')->values()->all(),
'series' => $rows->pluck('total')->map(fn ($value) => (int) $value)->values()->all(),
];
}
private function umurTanamanChart(array $filters): array
{
$bucketOrder = ['< 3 tahun', '3-7 tahun', '8-15 tahun', '> 15 tahun'];
$rows = $this->applyLahanFilters(Lahan::query(), $filters)
->whereNotNull('umur_tanaman')
->pluck('umur_tanaman')
->map(function ($age) {
$age = (float) $age;
return match (true) {
$age < 3 => '< 3 tahun',
$age <= 7 => '3-7 tahun',
$age <= 15 => '8-15 tahun',
default => '> 15 tahun',
};
})
->countBy()
->sortBy(fn ($value, $label) => array_search($label, $bucketOrder, true));
return [
'labels' => $rows->keys()->values()->all(),
'series' => $rows->values()->map(fn ($value) => (int) $value)->values()->all(),
];
}
private function sumberAirChart(array $filters): array
{
$rows = $this->applyPraktikBudidayaFilters(PraktikBudidaya::query(), $filters)
->get(['sumber_air'])
->flatMap(fn (PraktikBudidaya $praktik) => $this->arrayValues($praktik->sumber_air))
->filter()
->countBy()
->sortDesc();
return [
'labels' => $rows->keys()->values()->all(),
'series' => $rows->values()->map(fn ($value) => (int) $value)->values()->all(),
];
}
private function kondisiTanamanChart(array $filters): array
{
$rows = $this->applyTanamanTableFilters(DB::table('map_tanaman'), $filters)
@@ -572,6 +824,415 @@ class IndikatorPertanianController extends Controller
];
}
private function tanamanProduktifCount(array $filters): int
{
return (int) $this->applyTanamanTableFilters(DB::table('map_tanaman'), $filters)
->whereNull('map_tanaman.deleted_at')
->where('map_tanaman.kondisi_tanaman', 'baik')
->count('map_tanaman.id');
}
private function batchPanenCount(array $filters): int
{
$produksiCount = $this->applyProduksiFilters(Produksi::query(), $filters)->count();
$surveyPanenCount = $this->applySurveyPanenFilters(SurveyPanen::query(), $filters)->count();
return (int) $produksiCount + (int) $surveyPanenCount;
}
private function productionSummaryDescription(array $filters): string
{
$chart = $this->produksiPerKomoditasChart($filters);
if (empty($chart['series'])) {
return 'Belum ada produksi aktual per komoditas';
}
return collect($chart['labels'])
->map(fn ($label, $index) => $label.': '.number_format((float) ($chart['series'][$index] ?? 0), 0, ',', '.').' kg')
->take(3)
->implode(' | ');
}
private function commodityFocusData(array $filters): array
{
$focusCommodities = ['Kopi', 'Kakao', 'Vanili', 'Kemiri'];
$rows = $this->applyLahanFilters(Lahan::query(), $filters)
->leftJoin('master_komoditas as k', 'k.id', '=', 'map_lahan.komoditas_id')
->whereIn(DB::raw('LOWER(k.nama)'), collect($focusCommodities)->map(fn ($name) => strtolower($name))->all())
->selectRaw('k.nama as label')
->selectRaw('COUNT(map_lahan.id) as total')
->groupBy('k.nama')
->get()
->mapWithKeys(fn ($row) => [strtolower((string) $row->label) => (int) $row->total]);
return collect($focusCommodities)
->map(fn ($name) => [
'label' => $name,
'total' => $rows[strtolower($name)] ?? 0,
])
->values()
->all();
}
private function farmMapFeatures(array $filters): array
{
return $this->applyLahanFilters(
Lahan::query()->with(['desaKelurahan.kecamatan', 'komoditas']),
$filters
)
->where(function ($query) {
$query
->whereNotNull('lat')
->orWhereNotNull('long')
->orWhereNotNull('path');
})
->limit(250)
->get()
->map(fn (Lahan $lahan) => [
'id' => $lahan->id,
'name' => $lahan->nama,
'commodity' => $lahan->komoditas?->nama ?? 'Belum ditentukan',
'area' => (float) $lahan->luas_lahan,
'village' => $lahan->desaKelurahan?->nama,
'district' => $lahan->desaKelurahan?->kecamatan?->nama,
'lat' => is_numeric($lahan->lat) ? (float) $lahan->lat : null,
'lng' => is_numeric($lahan->long) ? (float) $lahan->long : null,
'path' => is_array($lahan->path) ? $lahan->path : null,
])
->values()
->all();
}
private function mapLevelData(array $filters): array
{
$lahanSummaryByDesa = $this->applyLahanFilters(Lahan::query(), $filters)
->whereNotNull('desa_kelurahan_id')
->select('desa_kelurahan_id')
->selectRaw('COUNT(*) as total_lahan')
->selectRaw('COALESCE(SUM(luas_lahan), 0) as total_luas')
->groupBy('desa_kelurahan_id')
->get();
$desaIds = $lahanSummaryByDesa->pluck('desa_kelurahan_id')->filter()->unique()->values();
if ($filters['desa'] !== 'all') {
$desaIds->push($filters['desa']);
}
$desaList = DesaKelurahan::query()
->select('id', 'nama', 'path', 'kecamatan_id')
->when($desaIds->isNotEmpty(), fn ($query) => $query->whereIn('id', $desaIds->unique()->values()))
->with([
'kecamatan:id,nama,path,kabupaten_kota_id',
'kecamatan.kabupatenKota:id,nama,path,provinsi_id',
'kecamatan.kabupatenKota.provinsi:id,nama,path',
])
->get()
->keyBy('id');
$geoProvinsi = [];
$geoKabupaten = [];
$geoKecamatan = [];
$geoDesa = [];
$geoLahan = [];
foreach ($lahanSummaryByDesa as $summary) {
$desa = $desaList->get($summary->desa_kelurahan_id);
$kec = $desa?->kecamatan;
$kab = $kec?->kabupatenKota;
$prov = $kab?->provinsi;
$totalLahan = (int) $summary->total_lahan;
$totalLuas = (float) $summary->total_luas;
if ($prov && $prov->path) {
$this->accumulateGeoLevel($geoProvinsi, $prov->id, null, $prov->nama, $prov->path, $totalLahan, $totalLuas);
}
if ($kab && $kab->path) {
$this->accumulateGeoLevel($geoKabupaten, $kab->id, $prov?->id, $kab->nama, $kab->path, $totalLahan, $totalLuas);
}
if ($kec && $kec->path) {
$this->accumulateGeoLevel($geoKecamatan, $kec->id, $kab?->id, $kec->nama, $kec->path, $totalLahan, $totalLuas);
}
if ($desa && $desa->path) {
$this->accumulateGeoLevel($geoDesa, $desa->id, $kec?->id, $desa->nama, $desa->path, $totalLahan, $totalLuas);
}
}
$this->ensureSelectedRegionOnMap($filters, $geoProvinsi, $geoKabupaten, $geoKecamatan, $geoDesa);
$mapLahan = $this->applyLahanFilters(
Lahan::query()->with([
'profile:id,nama',
'petani:id,nama',
'komoditas:id,nama',
'tanaman:id,lahan_id,komoditas_id',
'tanaman.komoditas:id,nama',
]),
$filters
)
->whereNotNull('path')
->orderByDesc('updated_at')
->limit(500)
->get();
foreach ($mapLahan as $lahan) {
if (! $this->hasMapPolygon($lahan->path)) {
continue;
}
$tanaman = $lahan->tanaman
->map(fn ($tanaman) => $tanaman->komoditas?->nama)
->filter()
->unique()
->values();
if ($tanaman->isEmpty() && $lahan->komoditas?->nama) {
$tanaman = collect([$lahan->komoditas->nama]);
}
$geoLahan[] = [
'id' => $lahan->id,
'parent_id' => $lahan->desa_kelurahan_id,
'polygon' => $lahan->path,
'nama_petani' => $lahan->petani?->nama ?? $lahan->profile?->nama ?? 'N/A',
'luas' => (float) $lahan->luas_lahan,
'tanaman' => $tanaman,
];
}
return [
'provinsi' => array_values($geoProvinsi),
'kabupaten' => array_values($geoKabupaten),
'kecamatan' => array_values($geoKecamatan),
'desa' => array_values($geoDesa),
'lahan' => $geoLahan,
];
}
private function accumulateGeoLevel(array &$bucket, int|string $id, int|string|null $parentId, string $name, mixed $polygon, int $totalLahan, float $totalLuas): void
{
if (! isset($bucket[$id])) {
$bucket[$id] = [
'id' => $id,
'parent_id' => $parentId,
'nama' => $name,
'polygon' => $polygon,
'total_lahan' => 0,
'total_luas' => 0,
];
}
$bucket[$id]['total_lahan'] += $totalLahan;
$bucket[$id]['total_luas'] += $totalLuas;
}
private function ensureSelectedRegionOnMap(array $filters, array &$geoProvinsi, array &$geoKabupaten, array &$geoKecamatan, array &$geoDesa): void
{
$kabupaten = $filters['kabupaten'] !== 'all'
? KabupatenKota::query()->with('provinsi:id,nama,path')->find($filters['kabupaten'])
: null;
if ($kabupaten && $kabupaten->path && ! isset($geoKabupaten[$kabupaten->id])) {
$this->accumulateGeoLevel($geoKabupaten, $kabupaten->id, $kabupaten->provinsi_id, $kabupaten->nama, $kabupaten->path, 0, 0);
}
if ($kabupaten?->provinsi && $kabupaten->provinsi->path && ! isset($geoProvinsi[$kabupaten->provinsi->id])) {
$this->accumulateGeoLevel($geoProvinsi, $kabupaten->provinsi->id, null, $kabupaten->provinsi->nama, $kabupaten->provinsi->path, 0, 0);
}
if ($filters['kecamatan'] !== 'all') {
$kecamatan = Kecamatan::query()->find($filters['kecamatan']);
if ($kecamatan && $kecamatan->path && ! isset($geoKecamatan[$kecamatan->id])) {
$this->accumulateGeoLevel($geoKecamatan, $kecamatan->id, $kecamatan->kabupaten_kota_id, $kecamatan->nama, $kecamatan->path, 0, 0);
}
}
if ($filters['desa'] !== 'all') {
$desa = DesaKelurahan::query()->find($filters['desa']);
if ($desa && $desa->path && ! isset($geoDesa[$desa->id])) {
$this->accumulateGeoLevel($geoDesa, $desa->id, $desa->kecamatan_id, $desa->nama, $desa->path, 0, 0);
}
}
}
private function hasMapPolygon(mixed $path): bool
{
if (is_array($path)) {
return ! empty($path);
}
if (! is_string($path) || blank($path) || strtolower($path) === 'null') {
return false;
}
$decoded = json_decode($path, true);
return json_last_error() === JSON_ERROR_NONE && ! empty($decoded) && $decoded !== 'null';
}
private function shadowTreeSummary(array $filters): array
{
$items = $this->applyLahanFilters(Lahan::query(), $filters)
->get(['tanaman_penaung'])
->flatMap(fn (Lahan $lahan) => $this->arrayValues($lahan->tanaman_penaung))
->filter()
->values();
return [
'total' => $items->count(),
'types' => $items->unique(fn ($item) => strtolower((string) $item))->count(),
];
}
private function companionPlantSummary(array $filters): array
{
$items = $this->applyLahanFilters(Lahan::query(), $filters)
->get(['id', 'komoditas_lain'])
->flatMap(fn (Lahan $lahan) => $this->arrayValues($lahan->komoditas_lain))
->filter()
->values();
return [
'total' => $items->count(),
'types' => $items->unique(fn ($item) => strtolower((string) $item))->count(),
];
}
private function plantDiversityAverage(array $filters): float
{
$lahan = $this->applyLahanFilters(Lahan::query(), $filters)
->get(['komoditas_id', 'komoditas_lain', 'tanaman_penaung']);
if ($lahan->isEmpty()) {
return 0;
}
return (float) $lahan->avg(function (Lahan $item) {
$total = $item->komoditas_id ? 1 : 0;
$total += count($this->arrayValues($item->komoditas_lain));
$total += count($this->arrayValues($item->tanaman_penaung));
return $total;
});
}
private function sourceWaterSummary(array $filters): string
{
$chart = $this->sumberAirChart($filters);
if (empty($chart['labels'])) {
return 'Belum ada data';
}
return collect($chart['labels'])
->map(fn ($label, $index) => $label.' ('.number_format((int) ($chart['series'][$index] ?? 0), 0, ',', '.').')')
->take(2)
->implode(', ');
}
private function waterManagementSummary(array $filters): string
{
$rows = $this->applyPraktikBudidayaFilters(PraktikBudidaya::query(), $filters)
->whereNotNull('kondisi_pengelolaan_air')
->pluck('kondisi_pengelolaan_air')
->map(fn ($value) => $this->waterManagementShortLabel((int) $value))
->countBy()
->sortDesc();
return $this->topCountSummary($rows);
}
private function seasonalWaterNeedSummary(array $filters): string
{
$rows = $this->applyPraktikBudidayaFilters(PraktikBudidaya::query(), $filters)
->whereNotNull('kebutuhan_air_permusim')
->pluck('kebutuhan_air_permusim')
->map(fn ($value) => $this->shortEnumLabel((string) $value))
->countBy()
->sortDesc();
return $this->topCountSummary($rows);
}
private function soilConservationSummary(array $filters): string
{
$rows = $this->applyPraktikBudidayaFilters(PraktikBudidaya::query(), $filters)
->get(['kebiasaan_konservasi_tanah'])
->flatMap(fn (PraktikBudidaya $praktik) => $this->arrayValues($praktik->kebiasaan_konservasi_tanah))
->filter()
->countBy()
->sortDesc();
return $this->topCountSummary($rows);
}
private function soilConservationScoreSummary(array $filters): string
{
$average = $this->applyPraktikBudidayaFilters(PraktikBudidaya::query(), $filters)
->whereNotNull('nilai_konservasi_tanah')
->avg('nilai_konservasi_tanah');
if ($average === null) {
return 'Belum ada data';
}
return 'Rata-rata '.number_format((float) $average, 1, ',', '.');
}
private function topCountSummary($rows): string
{
if ($rows->isEmpty()) {
return 'Belum ada data';
}
return $rows
->map(fn ($count, $label) => $label.' ('.number_format((int) $count, 0, ',', '.').')')
->take(2)
->implode(', ');
}
private function waterManagementShortLabel(int $value): string
{
return match (KondisiPengelolaanAir::tryFrom($value)) {
KondisiPengelolaanAir::KURANG_TIDAK_ADA_UPAYA => 'Kurang, tanpa upaya',
KondisiPengelolaanAir::KURANG_TIDAK_SUDAH_BERUPAYA => 'Kurang, ada upaya',
KondisiPengelolaanAir::CUKUP_DARI_HUJAN => 'Cukup dari hujan',
KondisiPengelolaanAir::CUKUP_IRIGASI_PINJAM => 'Cukup, irigasi pinjam',
KondisiPengelolaanAir::CUKUP_IRIGASI_SENDIRI => 'Cukup, irigasi sendiri',
default => 'Belum ditentukan',
};
}
private function arrayValues(mixed $value): array
{
if (is_array($value)) {
return collect($value)
->flatten()
->filter(fn ($item) => filled($item))
->map(fn ($item) => is_scalar($item) ? (string) $item : json_encode($item))
->values()
->all();
}
if (is_string($value) && filled($value) && strtolower($value) !== 'null') {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $this->arrayValues($decoded);
}
return [$value];
}
return [];
}
private function chartFromRows($rows, callable $labelResolver): array
{
return [
@@ -676,6 +1337,13 @@ class IndikatorPertanianController extends Controller
->when($filters['komoditas'] !== 'all', fn ($query) => $query->where('komoditas_id', $filters['komoditas']));
}
private function applySurveyPanenFilters($query, array $filters)
{
return $query->whereHas('petani', function ($petani) use ($filters) {
$this->applyPetaniFilters($petani, $filters);
});
}
private function applyTanamanTableFilters($query, array $filters)
{
return $query
@@ -0,0 +1,210 @@
<?php
namespace App\Http\Controllers\Web\Dashboard;
use App\Http\Controllers\Controller;
use App\Services\AlphaVantageMarket;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Http\Client\Pool;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Http;
use Throwable;
class InternationalMarketController extends Controller
{
public function __invoke()
{
abort_unless(Gate::allows('super_admin') || Gate::allows('admin') || Gate::allows('koordinator') || Gate::allows('enumerator'), 403);
// File cache keeps external quotes independent of the application database.
$cache = Cache::store('file');
$marketData = $cache->remember('market.yahoo.quotes.v3', 300, function () use ($cache) {
$commodities = [
['name' => 'Kopi Arabica', 'symbol' => 'KC=F'],
['name' => 'Kakao (Cocoa)', 'symbol' => 'CC=F'],
];
try {
$responses = Http::pool(function (Pool $pool) use ($commodities) {
return array_map(fn ($commodity) => $pool->as($commodity['symbol'])
->acceptJson()->withHeaders(['User-Agent' => 'Mozilla/5.0'])
->withOptions(['verify' => config('services.yahoo_finance.ca_bundle') ?: true])
->connectTimeout(3)->timeout(8)
->get('https://query1.finance.yahoo.com/v8/finance/chart/'.rawurlencode($commodity['symbol']), [
'interval' => '1d', 'range' => '5d',
]), [...$commodities, ['symbol' => 'IDR=X']]);
});
} catch (Throwable $exception) {
report($exception);
$responses = [];
}
$exchangeRate = $cache->get('market.yahoo.usd_idr');
if (is_array($exchangeRate)) {
$exchangeRate['stale'] = true;
}
$rateResponse = $responses['IDR=X'] ?? null;
if ($rateResponse instanceof \Illuminate\Http\Client\Response && $rateResponse->successful()) {
$meta = $rateResponse->json('chart.result.0.meta');
if (is_array($meta) && ($meta['symbol'] ?? null) === 'IDR=X'
&& ($meta['currency'] ?? null) === 'IDR'
&& is_numeric($meta['regularMarketPrice'] ?? null) && $meta['regularMarketPrice'] > 0
&& is_numeric($meta['regularMarketTime'] ?? null)) {
$exchangeRate = [
'value' => (float) $meta['regularMarketPrice'],
'quoted_at' => gmdate('c', (int) $meta['regularMarketTime']),
'source' => 'Yahoo Finance',
'source_url' => 'https://finance.yahoo.com/quote/IDR%3DX/',
'stale' => false,
];
$cache->put('market.yahoo.usd_idr', $exchangeRate, now()->addDays(7));
}
}
$quotes = array_map(function ($commodity) use ($responses, $cache) {
$symbol = $commodity['symbol'];
$key = 'market.yahoo.last.'.rawurlencode($symbol);
$quote = $commodity + [
'source' => 'Yahoo Finance',
'source_url' => 'https://finance.yahoo.com/quote/'.rawurlencode($symbol).'/',
'price' => null, 'currency' => null, 'unit' => null,
'quoted_at' => null, 'stale' => false,
];
try {
$response = $responses[$symbol] ?? null;
if (!$response instanceof \Illuminate\Http\Client\Response || !$response->successful()) {
throw new \RuntimeException('Quote unavailable');
}
$meta = $response->json('chart.result.0.meta');
if ($response->json('chart.error') || !is_array($meta)
|| ($meta['symbol'] ?? null) !== $symbol
|| !is_numeric($meta['regularMarketPrice'] ?? null)
|| (float) $meta['regularMarketPrice'] <= 0
|| !is_numeric($meta['regularMarketTime'] ?? null)
|| !is_string($meta['currency'] ?? null)) {
throw new \RuntimeException('Invalid quote');
}
$quote['price'] = (float) $meta['regularMarketPrice'];
$quote['currency'] = $meta['currency'];
// Yahoo quotes Arabica in US cents/lb; cocoa and robusta in USD/tonne.
// Apply the unit only when the API currency matches that convention.
$quote['unit'] = match (true) {
$symbol === 'KC=F' && $meta['currency'] === 'USX' => 'sen AS / lb',
$symbol === 'CC=F' && $meta['currency'] === 'USD' => 'USD / ton metrik',
default => $meta['currency'].' (satuan mengikuti sumber)',
};
$quote['quoted_at'] = gmdate('c', (int) $meta['regularMarketTime']);
$cache->put($key, $quote, now()->addDays(7));
return $quote;
} catch (Throwable $exception) {
$previous = $cache->get($key);
if (is_array($previous)) {
$previous['stale'] = true;
return $previous;
}
return $quote;
}
}, $commodities);
return ['quotes' => $quotes, 'exchange_rate' => $exchangeRate];
});
$quotes = $marketData['quotes'];
$exchangeRate = $marketData['exchange_rate'];
foreach ($quotes as &$quote) {
$quote['id'] = 'yahoo:'.$quote['symbol'];
$quote['description'] = 'Estimasi konversi harga kontrak berjangka internasional.';
$quote['period'] = $quote['quoted_at'];
$quote['frequency'] = 'market';
}
unset($quote);
$quotes = array_merge($quotes, $this->imfQuotes($cache), app(AlphaVantageMarket::class)->quotes($cache));
$quotes = array_values(array_filter($quotes, fn ($quote) => is_numeric($quote['price']) && is_finite((float) $quote['price']) && $quote['price'] > 0));
foreach ($quotes as &$quote) {
// All active providers quote either US cents/lb or USD/metric tonne.
$usdPerKg = match ($quote['unit']) {
'sen AS / lb' => $quote['price'] / 100 / 0.45359237,
'USD / ton metrik' => $quote['price'] / 1000,
default => null,
};
$quote['price_idr_per_kg'] = $exchangeRate && $usdPerKg !== null
? round($usdPerKg * $exchangeRate['value'], 2) : null;
unset($quote['usd_per_kg']); // Old Alpha cache entries may contain this field.
}
unset($quote);
return response()->json([
'quotes' => $quotes,
'exchange_rate' => $exchangeRate,
])->header('Cache-Control', 'no-store');
}
private function imfQuotes(Repository $cache): array
{
// IMF observations are monthly averages; fetch at most once a day.
return $cache->remember('market.imf.monthly.v1', 86400, function () use ($cache) {
try {
$response = Http::withHeaders(['Accept' => 'text/csv', 'User-Agent' => 'Mozilla/5.0'])
->withOptions(['verify' => config('services.yahoo_finance.ca_bundle') ?: true])
->connectTimeout(3)->timeout(10)
->get('https://api.imf.org/external/sdmx/2.1/data/IMF.RES,PCPS/G001.PCOFFOTM+PCOFFROB+PCOCO.USD.M', [
'startPeriod' => now()->subYears(2)->format('Y-m'), 'format' => 'csvfile',
])->throw();
$stream = fopen('php://memory', 'r+');
try {
fwrite($stream, $response->body());
rewind($stream);
$headers = fgetcsv($stream, 0, ',', '"', '');
if (!$headers || array_diff(['COUNTRY', 'INDICATOR', 'DATA_TRANSFORMATION', 'FREQUENCY', 'TIME_PERIOD', 'OBS_VALUE', 'SCALE'], $headers)) {
throw new \RuntimeException('Unexpected IMF CSV format');
}
$latest = [];
while (($values = fgetcsv($stream, 0, ',', '"', '')) !== false) {
if (count($values) !== count($headers)) continue;
$row = array_combine($headers, $values);
if ($row['COUNTRY'] !== 'G001' || $row['DATA_TRANSFORMATION'] !== 'USD' || $row['FREQUENCY'] !== 'M'
|| $row['SCALE'] !== '0' || !is_numeric($row['OBS_VALUE']) || $row['OBS_VALUE'] <= 0
|| !preg_match('/^\d{4}-M(0[1-9]|1[0-2])$/', $row['TIME_PERIOD'])) continue;
$indicator = $row['INDICATOR'];
if (!isset($latest[$indicator]) || $row['TIME_PERIOD'] > $latest[$indicator]['TIME_PERIOD']) {
$latest[$indicator] = $row;
}
}
} finally {
fclose($stream);
}
$definitions = [
'PCOFFOTM' => ['Kopi Arabica', 'USX', 'sen AS / lb', 'Biji kopi Arabica (Other Milds). Estimasi konversi harga acuan rata-rata bulanan IMF.'],
'PCOFFROB' => ['Kopi Robusta', 'USX', 'sen AS / lb', 'Biji kopi Robusta. Estimasi konversi harga acuan rata-rata bulanan IMF.'],
'PCOCO' => ['Kakao (Cocoa)', 'USD', 'USD / ton metrik', 'Biji kakao. Estimasi konversi harga acuan rata-rata bulanan IMF.'],
];
$quotes = [];
foreach ($definitions as $indicator => [$name, $currency, $unit, $description]) {
if (!isset($latest[$indicator])) continue;
$row = $latest[$indicator];
$quotes[] = [
'id' => 'imf:'.$indicator, 'symbol' => $indicator, 'name' => $name,
'price' => (float) $row['OBS_VALUE'], 'currency' => $currency, 'unit' => $unit,
'source' => 'IMF · PCPS', 'source_url' => 'https://data.imf.org/Datasets/PCPS',
'description' => $description, 'frequency' => 'monthly',
'period' => str_replace('-M', '-', $row['TIME_PERIOD']), 'stale' => false,
];
}
if (!$quotes) throw new \RuntimeException('No valid IMF observations');
$cache->put('market.imf.last.v1', $quotes, now()->addDays(30));
return $quotes;
} catch (Throwable $exception) {
report($exception);
return array_map(function ($quote) {
$quote['stale'] = true;
return $quote;
}, $cache->get('market.imf.last.v1', []));
}
});
}
}
@@ -17,7 +17,7 @@ class ProfilPetaniController extends Controller
{
public function index()
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
$title = 'Dashboard Profil Petani';
File diff suppressed because it is too large Load Diff
@@ -23,7 +23,7 @@ use BackedEnum;
class SurveyPetaniController extends Controller
{
private const GAP_ENUM_CLASS = 'App\\Enums\\PendidikanPelatihan\\PelatihanGap';
private const GAP_ENUM_CLASS = 'App\\Enums\\PendidikanPelatihan\\PelatihanGAP';
public function index()
{
$colors = $this->colors();
@@ -31,7 +31,7 @@ class SurveyPetaniController extends Controller
// Progres per enumerator
$enumerators = User::query()
->where('role', 'fasilitator')
->where('role', 'enumerator')
->withCount([
'petaniDibuat as done',
])
@@ -489,34 +489,34 @@ class SurveyPetaniController extends Controller
{
return [
[
'color' => '#28a745',
'bg' => 'rgba(40,167,69,.15)',
'tx' => '#28a745',
'color' => '#6FD68F',
'bg' => 'rgba(111,214,143,.24)',
'tx' => '#6FD68F',
],
[
'color' => '#007bff',
'bg' => 'rgba(0,123,255,.15)',
'tx' => '#007bff',
'color' => '#5FC7D6',
'bg' => 'rgba(95,199,214,.24)',
'tx' => '#5FC7D6',
],
[
'color' => '#fd7e14',
'bg' => 'rgba(253,126,20,.15)',
'tx' => '#fd7e14',
'color' => '#FFB46F',
'bg' => 'rgba(255,180,111,.26)',
'tx' => '#FFB46F',
],
[
'color' => '#dc3545',
'bg' => 'rgba(220,53,69,.15)',
'tx' => '#dc3545',
'color' => '#F47C86',
'bg' => 'rgba(244,124,134,.26)',
'tx' => '#F47C86',
],
[
'color' => '#6f42c1',
'bg' => 'rgba(111,66,193,.15)',
'tx' => '#6f42c1',
'color' => '#9D8EF0',
'bg' => 'rgba(157,142,240,.26)',
'tx' => '#9D8EF0',
],
[
'color' => '#20c997',
'bg' => 'rgba(32,201,151,.15)',
'tx' => '#20c997',
'color' => '#65D8BD',
'bg' => 'rgba(101,216,189,.26)',
'tx' => '#65D8BD',
],
];
}
@@ -24,7 +24,7 @@ class PetaniCPMController extends Controller
{
public function index(Request $request): View
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -60,7 +60,7 @@ class PetaniCPMController extends Controller
public function create(Request $request): View
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -94,7 +94,7 @@ class PetaniCPMController extends Controller
*/
public function store(Request $request): RedirectResponse
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -147,7 +147,7 @@ class PetaniCPMController extends Controller
*/
public function show(string $id): View
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -161,7 +161,7 @@ class PetaniCPMController extends Controller
*/
public function edit(Request $request, string $id): View
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -198,7 +198,7 @@ class PetaniCPMController extends Controller
*/
public function update(Request $request, string $id)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -251,7 +251,7 @@ class PetaniCPMController extends Controller
*/
public function destroy(string $id)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -34,7 +34,7 @@ class ValueBasisDataController extends Controller
public function index(Request $request, string $tabelId): View
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Web\Konfigurasi;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
/**
* Mengatur komoditas yang ditangani seorang koordinator.
*/
class KoordinatorKomoditasController extends Controller
{
public function update(Request $request, User $user)
{
abort_unless(Gate::any(['super_admin', 'admin']), 403);
if (! $user->isKoordinator()) {
return response()->json([
'success' => false,
'message' => 'Komoditas hanya bisa diatur untuk user dengan role Koordinator.',
], 422);
}
$validated = $request->validate([
'komoditas_ids' => ['required', 'array', 'min:1'],
'komoditas_ids.*' => ['integer', 'exists:master_komoditas,id'],
], [
'komoditas_ids.required' => 'Pilih minimal satu komoditas.',
'komoditas_ids.min' => 'Pilih minimal satu komoditas.',
'komoditas_ids.*.exists' => 'Komoditas yang dipilih tidak valid.',
]);
self::simpanPenugasan($user, $validated['komoditas_ids']);
return response()->json([
'success' => true,
'message' => 'Komoditas koordinator berhasil diperbarui.',
'data' => $user->komoditasKoordinatorIds(),
]);
}
/**
* Menyamakan penugasan koordinator dengan daftar komoditas yang dipilih.
* Penugasan yang dilepas di-soft delete, penugasan baru dibuat.
*/
public static function simpanPenugasan(User $user, array $komoditasIds): void
{
$komoditasIds = array_values(array_unique(array_map('intval', $komoditasIds)));
DB::transaction(function () use ($user, $komoditasIds) {
$user->koordinatorKomoditas()
->whereNotIn('komoditas_id', $komoditasIds)
->get()
->each->delete();
$sudahAda = $user->komoditasKoordinatorIds();
foreach (array_diff($komoditasIds, $sudahAda) as $komoditasId) {
$user->koordinatorKomoditas()->create([
'komoditas_id' => $komoditasId,
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]);
}
});
}
}
@@ -8,16 +8,29 @@ use App\Models\User;
use App\Models\Profile;
use App\Enums\Role;
use App\Models\Wilayah\Provinsi;
use App\Models\Pertanian\Komoditas;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class UserController extends Controller
{
// Manajemen pengguna hanya untuk super admin & admin (sama dengan menu "Daftar Pengguna").
// Berlaku untuk semua method di controller ini.
public function __construct()
{
$this->middleware(function (Request $request, \Closure $next) {
abort_unless($request->user() && Gate::forUser($request->user())->any(['super_admin', 'admin']), 403);
return $next($request);
});
}
public function index(Request $request)
{
$query = User::orderBy('created_at', 'desc');
$query = User::with('koordinatorKomoditas.komoditas:id,nama')->orderBy('created_at', 'desc');
$tab = $request->get('tab', 'active');
if ($tab === 'pending') {
@@ -45,8 +58,15 @@ class UserController extends Controller
$pendingUsers = User::whereNull('email_verified_at')->count();
$roles = Role::cases();
$komoditasOptions = $this->komoditasOptions();
return view('konfigurasi.user.index', compact('users', 'totalUsers', 'activeUsers', 'pendingUsers', 'roles', 'tab'));
return view('konfigurasi.user.index', compact('users', 'totalUsers', 'activeUsers', 'pendingUsers', 'roles', 'tab', 'komoditasOptions'));
}
// Komoditas utama yang bisa ditugaskan ke koordinator
private function komoditasOptions()
{
return Komoditas::select('id', 'nama')->where('is_utama', true)->orderBy('nama')->get();
}
public function verify(Request $request, $id)
@@ -92,8 +112,9 @@ class UserController extends Controller
{
$roles = Role::cases();
$provinsi = Provinsi::orderBy('nama', 'asc')->get();
$komoditasOptions = $this->komoditasOptions();
return view('konfigurasi.user.add', compact('roles', 'provinsi'));
return view('konfigurasi.user.add', compact('roles', 'provinsi', 'komoditasOptions'));
}
public function store(Request $request)
@@ -104,6 +125,8 @@ class UserController extends Controller
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
'role' => ['required', 'string'],
'komoditas_ids' => ['nullable', 'array', 'required_if:role,' . Role::KOORDINATOR->value],
'komoditas_ids.*' => ['integer', 'exists:master_komoditas,id'],
'telepon' => ['required', 'max:13'],
'desa_kelurahan_id' => ['nullable', 'exists:desa_kelurahan,id'],
'ktp' => ['nullable', 'min:16', 'max:16'],
@@ -116,6 +139,7 @@ class UserController extends Controller
'password.confirmed' => 'Konfirmasi password tidak cocok!',
'password.min' => 'Password minimal 8 karakter!',
'email.unique' => 'Email sudah terdaftar.',
'komoditas_ids.required_if' => 'Pilih minimal satu komoditas untuk koordinator.',
'required' => 'Kolom :attribute wajib diisi.'
]);
@@ -163,6 +187,10 @@ class UserController extends Controller
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]);
if ($user->isKoordinator()) {
KoordinatorKomoditasController::simpanPenugasan($user, $request->input('komoditas_ids', []));
}
});
if ($request->expectsJson()) {
@@ -0,0 +1,177 @@
<?php
namespace App\Http\Controllers\Web\Koperasi;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Koperasi\PengurusKoperasi;
class PengurusKoperasiController extends Controller
{
public function index(Request $request)
{
$query = PengurusKoperasi::query();
if ($request->filled('search')) {
$search = $request->search;
$query->where('nama_lengkap', 'like', "%{$search}%")
->orWhere('nama_koperasi', 'like', "%{$search}%");
}
$perPage = $request->get('per_page', 10);
$pengurus = $query->latest()->paginate($perPage);
$totalPengurus = PengurusKoperasi::count();
$totalKoperasi = PengurusKoperasi::distinct('nama_koperasi')->count('nama_koperasi');
$totalJabatan = PengurusKoperasi::distinct('posisi_dalam_koperasi')->count('posisi_dalam_koperasi');
return view('koperasi.profil.index', compact(
'pengurus',
'totalPengurus',
'totalKoperasi',
'totalJabatan',
));
}
public function create()
{
return view('koperasi.profil.add');
}
public function edit($id)
{
$pengurus = PengurusKoperasi::findOrFail($id);
return view('koperasi.profil.edit', compact('pengurus'));
}
public function update(Request $request, $id)
{
$validatedData = $request->validate([
'nama_lengkap' => 'required|string|max:255',
'posisi_dalam_koperasi' => 'required|string|max:255',
'tahun_bergabung' => 'nullable|integer',
'no_hp' => 'nullable|string|max:20',
'alamat_tinggal' => 'nullable|string',
'nama_koperasi' => 'required|string|max:255',
'jenis_koperasi' => 'required|string|max:255',
'jenis_koperasi_lainnya'=> 'nullable|string|max:255',
'alamat_koperasi' => 'nullable|string',
'skala_operasi' => 'required|string|max:255',
'tahun_berdiri' => 'nullable|integer',
'jumlah_anggota_awal' => 'nullable|integer',
'jumlah_anggota_dewasa' => 'nullable|integer',
'jumlah_anggota_anak' => 'nullable|integer',
'jumlah_pengurus' => 'nullable|integer',
'shu_2025' => 'nullable|numeric',
'shu_2024' => 'nullable|numeric',
'shu_2023' => 'nullable|numeric',
]);
if ($validatedData['jenis_koperasi'] === 'Lainnya' && !empty($validatedData['jenis_koperasi_lainnya'])) {
$validatedData['jenis_koperasi'] = $validatedData['jenis_koperasi_lainnya'];
}
unset($validatedData['jenis_koperasi_lainnya']);
$pengurus = PengurusKoperasi::findOrFail($id);
$pengurus->update($validatedData);
return redirect()->route('koperasi.pengurus.index')
->with('success', 'Profil Koperasi berhasil diperbarui!');
}
public function survey(Request $request, $id)
{
$pengurus = PengurusKoperasi::findOrFail($id);
$step = $request->query('step', 1);
if ($step == 1) {
return view('koperasi.kelembagaan-aktor.index', compact('pengurus'));
} elseif ($step == 2) {
return view('koperasi.rantai-nilai-akses-pasar.index', compact('pengurus'));
} elseif ($step == 3) {
return view('koperasi.sosial-ekonomi.index', compact('pengurus'));
}
abort(404);
}
public function surveyStore(Request $request, $id)
{
$pengurus = PengurusKoperasi::findOrFail($id);
$step = $request->query('step', 1);
$data = $request->except(['_token', 'pengurus_koperasi_id']);
foreach ($data as $key => $value) {
if (is_array($value)) {
$data[$key] = json_encode($value);
}
}
$pengurus->update($data);
if ($step == 1) {
if ($request->has('agregasi_komoditas') && str_contains($data['agregasi_komoditas'], 'Tidak terlibat')) {
return redirect()->route('koperasi.pengurus.survey', ['id' => $id, 'step' => 3])
->with('success', 'Data Kelembagaan tersimpan! (Melewati Rantai Nilai)');
}
return redirect()->route('koperasi.pengurus.survey', ['id' => $id, 'step' => 2])
->with('success', 'Data Kelembagaan berhasil disimpan!');
}
if ($step == 2) {
return redirect()->route('koperasi.pengurus.survey', ['id' => $id, 'step' => 3])
->with('success', 'Data Rantai Nilai berhasil disimpan!');
}
if ($step == 3) {
return redirect()->route('koperasi.pengurus.index')
->with('success', 'Seluruh data survei berhasil diselesaikan dan disimpan!');
}
return redirect()->back();
}
public function store(Request $request)
{
$validatedData = $request->validate([
'nama_lengkap' => 'required|string|max:255',
'posisi_dalam_koperasi' => 'required|string|max:255',
'tahun_bergabung' => 'nullable|integer',
'no_hp' => 'nullable|string|max:20',
'alamat_tinggal' => 'nullable|string',
'nama_koperasi' => 'required|string|max:255',
'jenis_koperasi' => 'required|string|max:255',
'jenis_koperasi_lainnya'=> 'nullable|string|max:255',
'alamat_koperasi' => 'nullable|string',
'skala_operasi' => 'required|string|max:255',
'tahun_berdiri' => 'nullable|integer',
'jumlah_anggota_awal' => 'nullable|integer',
'jumlah_anggota_dewasa' => 'nullable|integer',
'jumlah_anggota_anak' => 'nullable|integer',
'jumlah_pengurus' => 'nullable|integer',
'shu_2025' => 'nullable|numeric',
'shu_2024' => 'nullable|numeric',
'shu_2023' => 'nullable|numeric',
]);
if ($validatedData['jenis_koperasi'] === 'Lainnya' && !empty($validatedData['jenis_koperasi_lainnya'])) {
$validatedData['jenis_koperasi'] = $validatedData['jenis_koperasi_lainnya'];
}
unset($validatedData['jenis_koperasi_lainnya']);
$pengurus = PengurusKoperasi::create($validatedData);
return redirect()->route('koperasi.pengurus.index')
->with('success', 'Profil Koperasi berhasil ditambahkan! Silakan klik tombol hijau untuk mulai survei.');
}
public function destroy($id)
{
$pengurus = PengurusKoperasi::findOrFail($id);
$pengurus->delete();
return redirect()->route('koperasi.pengurus.index')
->with('success', 'Data pengurus koperasi berhasil dihapus!');
}
}
@@ -51,9 +51,13 @@ class DesaKelurahanController extends Controller
return response()->json($desa);
}
public function getByKecamatan($kecamatan_id)
public function getByKecamatan(Request $request, $kecamatan_id)
{
$desa = DesaKelurahan::where('kecamatan_id', $kecamatan_id)
$query = $request->boolean('all_wilayah')
? DesaKelurahan::withoutGlobalScope('program')
: DesaKelurahan::query();
$desa = $query->where('kecamatan_id', $kecamatan_id)
->orderBy('nama', 'asc')
->get();
@@ -51,9 +51,13 @@ class KabupatenKotaController extends Controller
return response()->json($kabupaten);
}
public function getByProvinsi($provinsi_id)
public function getByProvinsi(Request $request, $provinsi_id)
{
$kabupaten = KabupatenKota::where('provinsi_id', $provinsi_id)
$query = $request->boolean('all_wilayah')
? KabupatenKota::withoutGlobalScope('program')
: KabupatenKota::query();
$kabupaten = $query->where('provinsi_id', $provinsi_id)
->orderBy('nama', 'asc')
->get();
@@ -51,9 +51,13 @@ class KecamatanController extends Controller
return response()->json($kecamatan);
}
public function getByKabupaten($kabupaten_id)
public function getByKabupaten(Request $request, $kabupaten_id)
{
$kecamatan = Kecamatan::where('kabupaten_kota_id', $kabupaten_id)
$query = $request->boolean('all_wilayah')
? Kecamatan::withoutGlobalScope('program')
: Kecamatan::query();
$kecamatan = $query->where('kabupaten_kota_id', $kabupaten_id)
->orderBy('nama', 'asc')
->get();
@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Web\Monitoring;
use App\Enums\Role;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Web\Concerns\AksesMonitoringPetani;
use App\Models\Pertanian\Petani;
use App\Models\User;
use App\Services\Produktivitas\KelengkapanPetani;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
/**
* Menu Verifikasi > 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),
]);
}
}
@@ -24,7 +24,7 @@ class AksesPasarController extends Controller
{
$petani = Petani::findOrFail($petaniId);
$aksesPasar = AksesPasar::where('petani_id', $petaniId)->first();
$aksesPasar = AksesPasar::where('petani_id', $petaniId)->latest()->first();
$hargaJualPanen = HargaJualPanen::where('petani_id', $petaniId)->get();
$hargaJualRows = collect([
'Buah segar hasil petik',
@@ -50,18 +50,26 @@ class AksesPasarController extends Controller
}
public function store(Request $request)
{
{
$validated = $this->validateData($request);
DB::beginTransaction();
try {
$aksesPasarData = $this->aksesPasarPayload($validated);
$existingAksesPasar = AksesPasar::where('petani_id', $validated['petani_id'])->first();
if ($existingAksesPasar) {
$aksesPasarData['updated_by'] = Auth::id();
$existingAksesPasar->update($aksesPasarData);
$aksesPasar = $existingAksesPasar;
} else {
$aksesPasarData['uid'] = 'AKP-' . Str::uuid();
$aksesPasarData['created_by'] = Auth::id();
$aksesPasarData['updated_by'] = Auth::id();
$aksesPasar = AksesPasar::create($aksesPasarData);
}
if ($request->has('harga_jual')) {
$this->syncHargaJual($request->petani_id, $validated['harga_jual'] ?? [], Auth::id());
@@ -75,7 +83,7 @@ class AksesPasarController extends Controller
DB::rollBack();
return redirect()->back()->withErrors(['error' => 'Gagal menyimpan data: ' . $e->getMessage()])->withInput();
}
}
}
public function update(Request $request, $petaniId, $id)
{
@@ -118,7 +126,8 @@ class AksesPasarController extends Controller
'skorPengalamanBerhentiValues' => [
SkorPengalaman::PERNAH_BERHENTI_TIDAK_ADA_AKSES->value,
SkorPengalaman::PERNAH_BERHENTI_MUNGKIN_KEMBALI->value
]
],
'sumberInfoLainnyaValue' => 'Lainnya',
];
}
@@ -128,6 +137,17 @@ class AksesPasarController extends Controller
return $request->validate([
'petani_id' => ['required', 'exists:petani,id'],
'tujuan_penjualan' => ['nullable', 'array'],
'tujuan_penjualan.*' => ['string'],
'tujuan_penjualan_lainnya' => [
Rule::requiredIf(fn () => in_array('Lainnya', $request->input('tujuan_penjualan', []), true)),
'nullable', 'string', 'max:255'
],
'sifat_hubungan_bisnis' => ['nullable', 'string', 'max:255'],
'bentuk_penjualan' => ['nullable', 'string', 'max:255'],
'frekuensi_penjualan' => ['nullable', 'string', 'max:255'],
'skor_pengalaman' => ['nullable', 'integer', Rule::in(SkorPengalaman::values())],
'alasan_berhenti' => [
Rule::requiredIf(fn () => in_array((int)$request->input('skor_pengalaman'), [
@@ -144,6 +164,14 @@ class AksesPasarController extends Controller
'nullable', 'string', 'max:255'
],
'tahu_harga_sebelumnya' => ['nullable', 'string', 'max:255'],
'sumber_info_harga' => ['nullable', 'array'],
'sumber_info_harga.*' => ['string'],
'sumber_info_harga_lainnya' => [
Rule::requiredIf(fn () => in_array('Lainnya', $request->input('sumber_info_harga', []), true)),
'nullable', 'string', 'max:255'
],
'praktik_fermentasi' => ['nullable', 'string', Rule::in(PraktikFermentasi::values())],
'proses_fermentasi' => [
Rule::requiredIf(fn () => filled($request->input('praktik_fermentasi')) && $request->input('praktik_fermentasi') !== PraktikFermentasi::BELUM_PERNAH->value),
@@ -168,6 +196,7 @@ class AksesPasarController extends Controller
], [
'petani_id.required' => 'Petani harus dipilih.',
'petani_id.exists' => 'Petani tidak ditemukan.',
'tujuan_penjualan_lainnya.required' => 'Keterangan saluran penjualan lainnya harus diisi.',
'skor_pengalaman.integer' => 'Skor pengalaman harus berupa angka.',
'skor_pengalaman.in' => 'Skor pengalaman tidak valid.',
'alasan_berhenti.required' => 'Alasan berhenti harus diisi.',
@@ -204,6 +233,10 @@ class AksesPasarController extends Controller
{
$payload = collect($validated)->except(['harga_jual'])->all();
if (!in_array('Lainnya', $payload['tujuan_penjualan'] ?? [], true)) {
$payload['tujuan_penjualan_lainnya'] = null;
}
if (!in_array((int)($payload['skor_pengalaman'] ?? 0), [
SkorPengalaman::PERNAH_BERHENTI_TIDAK_ADA_AKSES->value,
SkorPengalaman::PERNAH_BERHENTI_MUNGKIN_KEMBALI->value
@@ -215,6 +248,10 @@ class AksesPasarController extends Controller
$payload['saluran_terdahulu_lainnya'] = null;
}
if (!in_array('Lainnya', $payload['sumber_info_harga'] ?? [], true)) {
$payload['sumber_info_harga_lainnya'] = null;
}
if (($payload['praktik_fermentasi'] ?? null) === PraktikFermentasi::BELUM_PERNAH->value) {
$payload['proses_fermentasi'] = null;
$payload['model_fermentasi_memungkinkan'] = null;
@@ -223,7 +260,6 @@ class AksesPasarController extends Controller
return $payload;
}
private function syncHargaJual($petaniId, array $hargaJualData, $createdBy, $updatedBy = null): void
{
HargaJualPanen::where('petani_id', $petaniId)->delete();
@@ -0,0 +1,186 @@
<?php
namespace App\Http\Controllers\Web\Produktivitas;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use App\Models\Pertanian\KelompokTani;
use App\Models\Pertanian\AsetKelompokTani;
class AsetKelompokTaniController extends Controller
{
public const JENIS_ASET_BAKU = [
'Bibit/alat produksi',
'Pupuk (stok kelompok)',
'Pulper/alat kupas',
'Lantai jemur',
'Kotak/wadah fermentasi',
'Kas/simpanan kelompok',
'Gudang/tempat simpan',
'Timbangan',
];
private function authorizeAccess(): void
{
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
}
private function redirectToSection(string $url, string $section, string $message, ?string $nextSection = null)
{
return redirect($url . '#section-' . ($nextSection ?? $section))->with([
'success' => $message,
'success_section' => $section,
]);
}
private function kegiatanUrl(KelompokTani $kelompokTani): string
{
return route('kelompok-tani.edit', $kelompokTani->id) . '?section=aktivitas-fungsi';
}
private function nextAssetSection(KelompokTani $kelompokTani): string
{
return $kelompokTani->aset()->where('jenis_aset', 'Kas/simpanan kelompok')->where('status_aset', true)->exists()
? 'kas' : 'status-bantuan';
}
public function index(KelompokTani $kelompokTani)
{
$this->authorizeAccess();
return redirect($this->kegiatanUrl($kelompokTani) . '#section-aset-baku');
}
public function update(Request $request, KelompokTani $kelompokTani)
{
$this->authorizeAccess();
$validated = $request->validate([
'aset' => ['required', 'array'],
'aset.*.jenis_aset' => ['required', Rule::in(self::JENIS_ASET_BAKU)],
'aset.*.status_aset' => ['nullable', 'boolean'],
'aset.*.status_pengelolaan_aset' => ['required_if_accepted:aset.*.status_aset', 'nullable', 'string', 'max:100'],
'aset.*.kondisi_aset' => ['required_if_accepted:aset.*.status_aset', 'nullable', 'string', 'max:100'],
...$this->asetLainnyaRules(),
], [
'aset.*.status_pengelolaan_aset.required_if_accepted' => 'Status Pengelolaan wajib diisi jika aset dipilih Ada.',
'aset.*.kondisi_aset.required_if_accepted' => 'Kondisi wajib diisi jika aset dipilih Ada.',
'aset_lainnya.*.jenis_aset.not_in' => 'Jenis aset ini sudah ada di daftar baku di atas.',
]);
DB::transaction(function () use ($kelompokTani, $validated) {
$this->saveAsetBaku($kelompokTani, $validated['aset']);
$this->createAsetLainnya($kelompokTani, $validated['aset_lainnya'] ?? []);
});
return $this->redirectToSection($this->kegiatanUrl($kelompokTani), 'aset-baku', 'Data Operasional berhasil disimpan.', $this->nextAssetSection($kelompokTani));
}
private function saveAsetBaku(KelompokTani $kelompokTani, array $items): void
{
foreach ($items as $item) {
$jenisAset = $item['jenis_aset'];
$ada = (bool) ($item['status_aset'] ?? false);
$existing = $kelompokTani->aset()->where('jenis_aset', $jenisAset)->first()
?? $kelompokTani->aset()->onlyTrashed()->where('jenis_aset', $jenisAset)->latest('id')->first();
if (!$ada) {
if ($existing && !$existing->trashed()) {
$existing->update(['deleted_by' => auth()->id()]);
$existing->delete();
}
continue;
}
if ($existing) {
if ($existing->trashed()) {
$existing->restore();
}
$existing->update([
'status_aset' => true,
'status_pengelolaan_aset' => $item['status_pengelolaan_aset'],
'kondisi_aset' => $item['kondisi_aset'],
'deleted_by' => null,
'updated_by' => auth()->id(),
]);
continue;
}
$kelompokTani->aset()->create([
'uid' => 'AKT-' . strtoupper(Str::random(10)),
'jenis_aset' => $jenisAset,
'status_aset' => true,
'status_pengelolaan_aset' => $item['status_pengelolaan_aset'],
'kondisi_aset' => $item['kondisi_aset'],
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]);
}
}
private function asetLainnyaRules(): array
{
return [
'aset_lainnya' => ['nullable', 'array'],
'aset_lainnya.*.jenis_aset' => ['nullable', 'string', 'max:255', Rule::notIn(self::JENIS_ASET_BAKU)],
'aset_lainnya.*.status_pengelolaan_aset' => ['nullable', 'string', 'max:100'],
'aset_lainnya.*.kondisi_aset' => ['nullable', 'string', 'max:100'],
];
}
public function store(Request $request, KelompokTani $kelompokTani)
{
$this->authorizeAccess();
$validated = $request->validate($this->asetLainnyaRules(), [
'aset_lainnya.*.jenis_aset.not_in' => 'Jenis aset ini sudah ada di daftar baku di atas.',
]);
DB::transaction(fn () => $this->createAsetLainnya($kelompokTani, $validated['aset_lainnya'] ?? []));
return $this->redirectToSection($this->kegiatanUrl($kelompokTani), 'aset-lainnya', 'Data Operasional berhasil ditambahkan.', $this->nextAssetSection($kelompokTani));
}
private function createAsetLainnya(KelompokTani $kelompokTani, array $items): void
{
foreach ($items as $item) {
$jenisAset = trim($item['jenis_aset'] ?? '');
if ($jenisAset === '') {
continue;
}
$kelompokTani->aset()->create([
'uid' => 'AKT-' . strtoupper(Str::random(10)),
'jenis_aset' => $jenisAset,
'status_aset' => true,
'status_pengelolaan_aset' => $item['status_pengelolaan_aset'] ?? '-',
'kondisi_aset' => $item['kondisi_aset'] ?? '-',
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]);
}
}
public function destroy(KelompokTani $kelompokTani, AsetKelompokTani $aset)
{
$this->authorizeAccess();
abort_unless($aset->survey_kelompok_tani_id === $kelompokTani->id, 404);
abort_if(in_array($aset->jenis_aset, self::JENIS_ASET_BAKU), 422, 'Operasional: gunakan pilihan Ada/Tidak pada tabel untuk menghapus aset baku.');
$aset->update(['deleted_by' => auth()->id()]);
$aset->delete();
return $this->redirectToSection($this->kegiatanUrl($kelompokTani), 'aset-lainnya', 'Data Operasional berhasil dihapus.');
}
}
@@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers\Web\Produktivitas;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use App\Models\Pertanian\KelompokTani;
use App\Models\Pertanian\BantuanKelompokTani;
class BantuanKelompokTaniController extends Controller
{
private function authorizeAccess(): void
{
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
}
private function redirectToSection(string $url, string $section, string $message, ?string $nextSection = null)
{
return redirect($url . '#section-' . ($nextSection ?? $section))->with([
'success' => $message,
'success_section' => $section,
]);
}
private function redirectToSectionWithError(string $url, string $section, string $message)
{
return redirect($url . '#kelompok-tani-notifications')->with([
'error' => $message,
'error_section' => $section,
]);
}
public function index(KelompokTani $kelompokTani)
{
$this->authorizeAccess();
return redirect($this->operasionalUrl($kelompokTani) . '#section-status-bantuan');
}
private function operasionalUrl(KelompokTani $kelompokTani): string
{
return route('kelompok-tani.edit', $kelompokTani->id) . '?section=aktivitas-fungsi';
}
private function hasRecordedAid(KelompokTani $kelompokTani): bool
{
return $kelompokTani->bantuan()->whereRaw("TRIM(nama_program) <> ''")->exists();
}
private function bantuanRules(bool $pernah = true, bool $requireFirstRow = false): array
{
$exclude = Rule::excludeIf(!$pernah);
return [
'bantuan' => [$exclude, Rule::requiredIf($requireFirstRow), 'nullable', 'array'],
'bantuan.*' => [$exclude, 'array'],
'bantuan.*.nama_program' => [$exclude, 'required_with:bantuan.*.penyelenggara,bantuan.*.bentuk_bantuan,bantuan.*.tahun', 'nullable', 'string', 'max:255'],
'bantuan.*.penyelenggara' => [$exclude, 'nullable', 'string', 'max:255'],
'bantuan.*.bentuk_bantuan' => [$exclude, 'nullable', 'string', 'max:255'],
'bantuan.*.tahun' => [$exclude, 'nullable', 'integer', 'min:1900', 'max:' . date('Y')],
'bantuan.0.nama_program' => [$exclude, Rule::requiredIf($requireFirstRow), 'required_with:bantuan.0.penyelenggara,bantuan.0.bentuk_bantuan,bantuan.0.tahun', 'nullable', 'string', 'max:255'],
'bantuan.0.penyelenggara' => [$exclude, Rule::requiredIf($requireFirstRow), 'nullable', 'string', 'max:255'],
'bantuan.0.bentuk_bantuan' => [$exclude, Rule::requiredIf($requireFirstRow), 'nullable', 'string', 'max:255'],
'bantuan.0.tahun' => [$exclude, Rule::requiredIf($requireFirstRow), 'nullable', 'integer', 'min:1900', 'max:' . date('Y')],
];
}
private function bantuanMessages(): array
{
return [
'pernah_mendapatkan_bantuan.required' => 'Pengalaman Akses Bantuan/Program wajib diisi.',
'pernah_mendapatkan_bantuan.boolean' => 'Pilih Pernah atau Belum Pernah untuk Pengalaman Akses Bantuan/Program.',
'bantuan.required' => 'Isi minimal satu data bantuan jika memilih Pernah.',
'bantuan.0.nama_program.required' => 'Nama Program/Bantuan pada baris pertama wajib diisi.',
'bantuan.0.penyelenggara.required' => 'Penyelenggara pada baris pertama wajib diisi.',
'bantuan.0.bentuk_bantuan.required' => 'Bentuk Bantuan pada baris pertama wajib diisi.',
'bantuan.0.tahun.required' => 'Tahun pada baris pertama wajib diisi.',
'bantuan.*.nama_program.required_with' => 'Nama Program/Bantuan wajib diisi jika baris bantuan diisi.',
];
}
public function update(Request $request, KelompokTani $kelompokTani)
{
$this->authorizeAccess();
$validated = $request->validate([
'pernah_mendapatkan_bantuan' => ['required', 'boolean'],
...$this->bantuanRules(
$request->boolean('pernah_mendapatkan_bantuan'),
$request->boolean('pernah_mendapatkan_bantuan') && !$this->hasRecordedAid($kelompokTani)
),
], $this->bantuanMessages());
DB::transaction(function () use ($kelompokTani, $validated) {
$kelompokTani->update([
'pernah_mendapatkan_bantuan' => $validated['pernah_mendapatkan_bantuan'],
'updated_by' => auth()->id(),
]);
$this->createBantuan($kelompokTani, $validated['bantuan'] ?? []);
});
return $this->redirectToSection($this->operasionalUrl($kelompokTani), 'status-bantuan', 'Data Operasional berhasil disimpan.');
}
public function store(Request $request, KelompokTani $kelompokTani)
{
$this->authorizeAccess();
if (!$kelompokTani->pernah_mendapatkan_bantuan) {
return $this->redirectToSectionWithError(
$this->operasionalUrl($kelompokTani),
'status-bantuan',
'Operasional: pilih jawaban "Pernah" terlebih dahulu sebelum menambahkan data bantuan/program.'
);
}
$validated = $request->validate($this->bantuanRules(true, !$this->hasRecordedAid($kelompokTani)), $this->bantuanMessages());
DB::transaction(fn () => $this->createBantuan($kelompokTani, $validated['bantuan'] ?? []));
return $this->redirectToSection($this->operasionalUrl($kelompokTani), 'status-bantuan', 'Data Operasional berhasil ditambahkan.');
}
private function createBantuan(KelompokTani $kelompokTani, array $items): void
{
foreach ($items as $item) {
$namaProgram = trim($item['nama_program'] ?? '');
if ($namaProgram === '') {
continue;
}
$kelompokTani->bantuan()->create([
'uid' => 'BKT-' . strtoupper(Str::random(10)),
'nama_program' => $namaProgram,
'penyelenggara' => $item['penyelenggara'] ?? '',
'bentuk_bantuan' => $item['bentuk_bantuan'] ?? '',
'tahun' => $item['tahun'] ?? null,
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]);
}
}
public function destroy(KelompokTani $kelompokTani, BantuanKelompokTani $bantuan)
{
$this->authorizeAccess();
abort_unless($bantuan->survey_kelompok_tani_id === $kelompokTani->id, 404);
if ($kelompokTani->pernah_mendapatkan_bantuan && !$kelompokTani->bantuan()
->where('id', '!=', $bantuan->id)->whereRaw("TRIM(nama_program) <> ''")->exists()) {
return $this->redirectToSectionWithError(
$this->operasionalUrl($kelompokTani),
'status-bantuan',
'Operasional: minimal satu data bantuan harus tersedia saat jawaban masih Pernah.'
);
}
$bantuan->update(['deleted_by' => auth()->id()]);
$bantuan->delete();
return $this->redirectToSection($this->operasionalUrl($kelompokTani), 'status-bantuan', 'Data Operasional berhasil dihapus.');
}
}
@@ -6,7 +6,9 @@ use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use App\Enums\PendidikanPelatihan\PelatihanGAP;
use App\Models\Pertanian\Petani;
use App\Models\Pertanian\SurveyPelatihan;
use App\Models\Pertanian\SurveySertifikasi;
@@ -15,7 +17,7 @@ class PendidikanPelatihanController extends Controller
{
private function authorizeAccess(): void
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
}
@@ -44,15 +46,22 @@ class PendidikanPelatihanController extends Controller
$validatedData = $request->validate([
'riwayat_pelatihan' => ['nullable', 'array'],
'riwayat_pelatihan.*.jenis_pelatihan' => ['nullable', 'string', 'max:255'],
'riwayat_pelatihan.*.jenis_pelatihan' => ['nullable', 'string', Rule::enum(PelatihanGAP::class)],
'riwayat_pelatihan.*.jenis_pelatihan_lainnya' => [
'nullable', 'string', 'max:255',
'required_if:riwayat_pelatihan.*.jenis_pelatihan,' . PelatihanGAP::LAINNYA->value,
],
'riwayat_pelatihan.*.penyelenggara' => ['nullable', 'string', 'max:255'],
'riwayat_pelatihan.*.tahun' => ['nullable', 'integer', 'min:1900', 'max:' . date('Y')],
], [
'riwayat_pelatihan.*.jenis_pelatihan.enum' => 'Jenis pelatihan yang dipilih tidak valid.',
'riwayat_pelatihan.*.jenis_pelatihan_lainnya.required_if' => 'Jenis pelatihan lainnya wajib diisi jika memilih Lainnya.',
'riwayat_pelatihan.*.tahun.integer' => 'Tahun pelatihan harus berupa angka.',
'riwayat_pelatihan.*.tahun.min' => 'Tahun pelatihan tidak valid.',
'riwayat_pelatihan.*.tahun.max' => 'Tahun pelatihan tidak boleh melebihi tahun saat ini.',
], [
'riwayat_pelatihan.*.jenis_pelatihan' => 'jenis pelatihan',
'riwayat_pelatihan.*.jenis_pelatihan_lainnya' => 'jenis pelatihan lainnya',
'riwayat_pelatihan.*.penyelenggara' => 'penyelenggara',
'riwayat_pelatihan.*.tahun' => 'tahun pelatihan',
]);
@@ -66,10 +75,15 @@ class PendidikanPelatihanController extends Controller
continue;
}
// Jika memilih "Lainnya", yang disimpan adalah teks isian bebasnya
$jenisPelatihan = ($item['jenis_pelatihan'] ?? null) === PelatihanGAP::LAINNYA->value
? trim($item['jenis_pelatihan_lainnya'])
: ($item['jenis_pelatihan'] ?? null);
$petani->surveyPelatihan()->create([
'uid' => 'SPL-' . strtoupper(Str::random(10)),
'petani_id' => $petani->id,
'jenis_pelatihan' => $item['jenis_pelatihan'] ?? null,
'jenis_pelatihan' => $jenisPelatihan,
'penyelenggara' => $item['penyelenggara'] ?? null,
'tahun_pelaksanaan' => $item['tahun'] ?? null,
'created_by' => auth()->id(),
@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Web\Produktivitas;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use App\Models\Pertanian\KelompokTani;
use App\Models\Pertanian\PengurusKelompokTani;
class PengurusKelompokTaniController extends Controller
{
private function authorizeAccess(): void
{
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
}
private function redirectToSection(string $url, string $section, string $message)
{
return redirect($url . '#section-' . $section)->with([
'success' => $message,
'success_section' => $section,
]);
}
public function index(KelompokTani $kelompokTani)
{
$this->authorizeAccess();
return redirect(route('kelompok-tani.edit', $kelompokTani->id) . '#section-ringkasan');
}
public function store(Request $request, KelompokTani $kelompokTani)
{
$this->authorizeAccess();
$validated = $request->validate([
'pengurus' => ['nullable', 'array'],
'pengurus.*.nama_pengurus' => ['nullable', 'string', 'max:255'],
'pengurus.*.jabatan' => ['nullable', 'string', 'max:255'],
'pengurus.*.kontak' => ['nullable', 'string', 'max:50'],
'pengurus.*.status' => ['nullable', 'boolean'],
]);
foreach ($validated['pengurus'] ?? [] as $item) {
if (empty($item['nama_pengurus']) && empty($item['jabatan']) && empty($item['kontak'])) {
continue;
}
$kelompokTani->pengurus()->create([
'uid' => 'PKT-' . strtoupper(Str::random(10)),
'nama_pengurus' => $item['nama_pengurus'] ?? null,
'jabatan' => $item['jabatan'] ?? null,
'kontak' => $item['kontak'] ?? null,
'status' => $item['status'] ?? false,
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]);
}
return $this->redirectToSection(route('kelompok-tani.edit', $kelompokTani->id), 'pengurus', 'Data Profil berhasil ditambahkan.');
}
public function destroy(KelompokTani $kelompokTani, PengurusKelompokTani $pengurus)
{
$this->authorizeAccess();
abort_unless($pengurus->survey_kelompok_tani_id === $kelompokTani->id, 404);
$pengurus->update(['deleted_by' => auth()->id()]);
$pengurus->delete();
return $this->redirectToSection(route('kelompok-tani.edit', $kelompokTani->id), 'pengurus', 'Data Profil berhasil dihapus.');
}
}
@@ -12,6 +12,8 @@ use Illuminate\Support\Str;
use Illuminate\Support\Facades\Storage;
use App\Models\Wilayah\Provinsi;
use App\Models\Wilayah\KabupatenKota;
use App\Models\Wilayah\Kecamatan;
use App\Models\Pertanian\Petani;
use App\Models\Wilayah\DesaKelurahan;
use App\Models\Pertanian\Komoditas;
@@ -30,11 +32,36 @@ class PetaniController extends Controller
*/
public function index(Request $request)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
$provinsi = Provinsi::select('id', 'kode', 'nama')->orderBy('kode', 'asc')->get();
$userRole = auth()->user()->role->value;
$showFasilitatorColumn = in_array($userRole, ['super_admin', 'admin', 'koordinator'], true);
$defaultKabupaten = KabupatenKota::select('id', 'nama')
->whereRaw('lower(nama) like ?', ['%ngada%'])
->orderBy('nama')
->first();
$selectedKabupatenId = $request->query('kabupaten_kota_id', $defaultKabupaten?->id);
$allowedSorts = ['nama_asc', 'nama_desc', 'luas_lahan_desc', 'luas_lahan_asc'];
$requestedSort = $request->query('sort_by')
?? match ($request->query('sort_nama')) {
'asc' => 'nama_asc',
'desc' => 'nama_desc',
default => null,
};
$filters = [
'search' => $request->query('search'),
'komoditas_id' => $request->query('komoditas_id'),
'kabupaten_kota_id' => $selectedKabupatenId,
'kecamatan_id' => $request->query('kecamatan_id'),
'desa_kelurahan_id' => $request->query('desa_kelurahan_id'),
'sort_by' => in_array($requestedSort, $allowedSorts, true)
? $requestedSort
: null,
];
$query = Petani::with([
'desaKelurahan:id,nama,kecamatan_id',
@@ -43,29 +70,128 @@ class PetaniController extends Controller
'desaKelurahan.kecamatan.kabupatenKota.provinsi:id,nama',
'komoditas:id,nama',
'skor:id,petani_id,skor_kesiapan_adopsi',
'creator:id,name,role',
])
->orderBy('created_at', 'desc');
->withSum('lahans as total_luas_lahan', 'luas_lahan');
if ($request->search) {
$query->where(function ($query) use ($request) {
$query->where('nama', 'like', '%' . $request->search . '%')
->orWhere('ktp', 'like', '%' . $request->search . '%');
$search = $request->filled('search') ? Str::lower($request->search) : null;
if ($search) {
$query->where(function ($query) use ($search) {
$query->whereRaw('lower(nama) like ?', ["%{$search}%"])
->orWhereRaw('lower(ktp) like ?', ["%{$search}%"]);
});
}
if (auth()->user()->role->value === 'fasilitator') {
if ($request->filled('komoditas_id')) {
$query->where('komoditas_id', $request->komoditas_id);
}
if ($filters['kabupaten_kota_id']) {
$query->whereHas('desaKelurahan.kecamatan', function ($query) use ($filters) {
$query->where('kabupaten_kota_id', $filters['kabupaten_kota_id']);
});
}
if ($request->filled('kecamatan_id')) {
$query->whereHas('desaKelurahan', function ($query) use ($request) {
$query->where('kecamatan_id', $request->kecamatan_id);
});
}
if ($request->filled('desa_kelurahan_id')) {
$query->where('desa_kelurahan_id', $request->desa_kelurahan_id);
}
if ($userRole === 'enumerator') {
$query->where('created_by', auth()->user()->id);
}
// Koordinator hanya melihat petani dengan komoditas yang ditugaskan
$komoditasKoordinatorIds = $userRole === 'koordinator'
? auth()->user()->komoditasKoordinatorIds()
: null;
if ($komoditasKoordinatorIds !== null) {
$query->whereIn('komoditas_id', $komoditasKoordinatorIds);
}
$totalPetani = (clone $query)->count();
$totalPetaniLakiLaki = (clone $query)->where('jenis_kelamin', 1)->count();
$totalPetaniPerempuan = (clone $query)->where('jenis_kelamin', 0)->count();
match ($filters['sort_by']) {
'nama_asc' => $query->orderBy('nama', 'asc')->orderBy('created_at', 'desc'),
'nama_desc' => $query->orderBy('nama', 'desc')->orderBy('created_at', 'desc'),
'luas_lahan_desc' => $query->orderByDesc(
Lahan::selectRaw('COALESCE(SUM(luas_lahan), 0)')
->whereColumn('map_lahan.petani_id', 'petani.id')
->whereNull('map_lahan.deleted_at')
)->orderBy('created_at', 'desc'),
'luas_lahan_asc' => $query->orderBy(
Lahan::selectRaw('COALESCE(SUM(luas_lahan), 0)')
->whereColumn('map_lahan.petani_id', 'petani.id')
->whereNull('map_lahan.deleted_at')
)->orderBy('created_at', 'desc'),
default => $query->orderBy('created_at', 'desc'),
};
$summaryPetaniIds = (clone $query)->pluck('id');
$petani = $query->paginate($request->per_page ?? 10);
$komoditas = Komoditas::select('id', 'nama')->where('is_utama', true)->get();
$lahanQuery = Lahan::whereIn('petani_id', $summaryPetaniIds)
->whereIn('komoditas_id', $komoditas->pluck('id'));
$lahanIds = (clone $lahanQuery)->pluck('id');
$totalLuasLahan = (clone $lahanQuery)->sum('luas_lahan');
$totalTanaman = Tanaman::whereIn('lahan_id', $lahanIds)->count();
$komoditasOptions = Komoditas::select('id', 'nama')->orderBy('nama')->get();
$kabupatenOptions = KabupatenKota::select('id', 'nama')->orderBy('nama')->get();
$selectedKabupaten = $filters['kabupaten_kota_id']
? KabupatenKota::select('id', 'nama')->find($filters['kabupaten_kota_id'])
: null;
$kecamatanOptions = $filters['kabupaten_kota_id']
? Kecamatan::select('id', 'nama')->where('kabupaten_kota_id', $filters['kabupaten_kota_id'])->orderBy('nama')->get()
: collect();
$desaOptions = $request->filled('kecamatan_id')
? DesaKelurahan::select('id', 'nama')->where('kecamatan_id', $request->kecamatan_id)->orderBy('nama')->get()
: collect();
$lahanSummaryQuery = Lahan::query()
->whereHas('petani')
->when($request->filled('komoditas_id'), function ($query) use ($request) {
$query->where('komoditas_id', $request->komoditas_id);
})
->when($filters['kabupaten_kota_id'], function ($query) use ($filters) {
$query->whereHas('desaKelurahan.kecamatan', function ($kecamatan) use ($filters) {
$kecamatan->where('kabupaten_kota_id', $filters['kabupaten_kota_id']);
});
})
->when($request->filled('kecamatan_id'), function ($query) use ($request) {
$query->whereHas('desaKelurahan', function ($desa) use ($request) {
$desa->where('kecamatan_id', $request->kecamatan_id);
});
})
->when($request->filled('desa_kelurahan_id'), function ($query) use ($request) {
$query->where('desa_kelurahan_id', $request->desa_kelurahan_id);
})
->when($request->filled('search'), function ($query) use ($search) {
$query->whereHas('petani', function ($petani) use ($search) {
$petani->whereRaw('lower(nama) like ?', ["%{$search}%"])
->orWhereRaw('lower(ktp) like ?', ["%{$search}%"]);
});
})
->when($userRole === 'enumerator', function ($query) {
$query->whereHas('petani', function ($petani) {
$petani->where('created_by', auth()->id());
});
})
->when($komoditasKoordinatorIds !== null, function ($query) use ($komoditasKoordinatorIds) {
$query->whereHas('petani', function ($petani) use ($komoditasKoordinatorIds) {
$petani->whereIn('komoditas_id', $komoditasKoordinatorIds);
});
});
$totalLahan = (clone $lahanSummaryQuery)->count();
$totalLuasLahan = (clone $lahanSummaryQuery)->sum('luas_lahan');
$totalTanaman = Tanaman::whereIn('lahan_id', (clone $lahanSummaryQuery)->select('id'))
->when($request->filled('komoditas_id'), function ($query) use ($request) {
$query->where('komoditas_id', $request->komoditas_id);
})
->count();
$case = "
CASE
@@ -81,12 +207,30 @@ class PetaniController extends Controller
->groupByRaw($case)
->get();
return view('produktivitas.petani.index', compact('petani', 'provinsi', 'komoditas', 'skor', 'totalLuasLahan', 'totalTanaman'));
return view('produktivitas.petani.index', compact(
'petani',
'provinsi',
'komoditas',
'komoditasOptions',
'kabupatenOptions',
'selectedKabupaten',
'kecamatanOptions',
'desaOptions',
'filters',
'skor',
'totalLahan',
'totalLuasLahan',
'totalTanaman',
'totalPetani',
'totalPetaniLakiLaki',
'totalPetaniPerempuan',
'showFasilitatorColumn'
));
}
public function create()
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -158,7 +302,7 @@ class PetaniController extends Controller
public function store(Request $request)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -190,7 +334,7 @@ class PetaniController extends Controller
public function show(string $id)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -202,7 +346,7 @@ class PetaniController extends Controller
public function edit(string $id, Request $request)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -223,7 +367,7 @@ class PetaniController extends Controller
public function update(Request $request, string $id)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -263,17 +407,43 @@ class PetaniController extends Controller
return redirect()->route('petani.edit', $petani->id)->with('success', 'Data petani berhasil diupdate.');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(string $id)
{
//
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
return response()->json(['message' => 'Anda tidak memiliki akses.'], 403);
}
try {
$petani = Petani::findOrFail($id);
if (auth()->user()->role->value === 'enumerator' && $petani->created_by !== auth()->id()) {
return response()->json(['message' => 'Anda tidak memiliki akses.'], 403);
}
DB::transaction(function () use ($petani) {
$petani->update([
'deleted_by' => auth()->id(),
]);
$petani->delete();
});
return response()->json([
'status' => 'success',
'message' => 'Data petani berhasil dihapus.'
]);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => 'Gagal menghapus data: ' . $e->getMessage()
], 500);
}
}
public function showImage(string $id, string $field)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -32,7 +32,7 @@ class PetaniLahanController extends Controller
{
public function index($petaniId, Request $request)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -52,7 +52,7 @@ class PetaniLahanController extends Controller
public function create($petaniId)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -194,7 +194,7 @@ class PetaniLahanController extends Controller
public function store($petaniId, Request $request)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -292,7 +292,7 @@ class PetaniLahanController extends Controller
public function show(string $petaniId, string $id)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -310,7 +310,7 @@ class PetaniLahanController extends Controller
public function edit(string $petaniId, string $id)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -335,7 +335,7 @@ class PetaniLahanController extends Controller
public function update(Request $request, string $petaniId, string $id)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -462,7 +462,7 @@ class PetaniLahanController extends Controller
public function destroy(string $petaniId, string $id)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -481,7 +481,7 @@ class PetaniLahanController extends Controller
public function summary(string $petaniId)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -42,7 +42,7 @@ class PetaniLahanTanamanController extends Controller
*/
public function show(string $petaniId, string $lahanId, string $id)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -63,7 +63,7 @@ class PetaniLahanTanamanController extends Controller
public function update(Request $request, string $petaniId, string $lahanId, string $id)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -103,7 +103,7 @@ class PetaniLahanTanamanController extends Controller
*/
public function destroy(string $petaniId, string $lahanId, string $id)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Web\Produktivitas;
use App\Enums\StatusDataPetani;
use App\Http\Controllers\Controller;
use App\Models\Pertanian\Petani;
use App\Services\Produktivitas\KelengkapanPetani;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* Enumerator penginput menandai data petani Lengkap, atau membatalkannya kembali ke Draf.
* Perubahan ke Terverifikasi dilakukan koordinator (menu menyusul), bukan di sini.
*/
class PetaniStatusController extends Controller
{
public function update(Request $request, Petani $petani)
{
abort_unless($petani->bisaDitandaiOleh($request->user()), 403);
$validated = $request->validate([
'status' => ['required', Rule::in([StatusDataPetani::LENGKAP->value, StatusDataPetani::DRAFT->value])],
// Enumerator menyetujui pernyataan kelengkapan (tombol konfirmasi di popup) saat menandai lengkap.
'pernyataan' => ['exclude_unless:status,' . StatusDataPetani::LENGKAP->value, 'accepted'],
], [
'pernyataan.accepted' => 'Setujui pernyataan kelengkapan data terlebih dahulu.',
]);
$tujuan = StatusDataPetani::from($validated['status']);
if ($petani->status === StatusDataPetani::TERVERIFIKASI) {
return back()->with('status_petani_error', 'Data petani sudah terverifikasi. Hubungi koordinator untuk mengubahnya.');
}
if ($tujuan === StatusDataPetani::LENGKAP) {
if ($petani->status !== StatusDataPetani::DRAFT) {
return back()->with('status_petani_error', 'Data petani sudah ditandai lengkap.');
}
$wajibKosong = KelengkapanPetani::untuk($petani)->jumlahWajibKosong();
if ($wajibKosong > 0) {
return back()->with('status_petani_error', "Masih ada {$wajibKosong} isian wajib yang kosong.");
}
}
if ($tujuan === StatusDataPetani::DRAFT && $petani->status !== StatusDataPetani::LENGKAP) {
return back()->with('status_petani_error', 'Data petani masih berstatus draf.');
}
$petani->status = $tujuan;
$petani->updated_by = $request->user()->id;
$petani->save();
return back()->with('status_petani_success', $tujuan === StatusDataPetani::LENGKAP
? 'Data petani berhasil ditandai lengkap.'
: 'Tanda lengkap berhasil dibatalkan. Status kembali ke draf.');
}
}
@@ -42,7 +42,7 @@ class PraktikBudidayaController extends Controller
{
public function index($petaniId, Request $request)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -139,7 +139,7 @@ class PraktikBudidayaController extends Controller
public function store($petaniId, Request $request)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -184,6 +184,12 @@ class PraktikBudidayaController extends Controller
$petani = Petani::findOrFail($petaniId);
if (!$petani->isKomoditasVanili()) {
$validatedData['penyerbukan_manual'] = null;
$validatedData['alasan_tidak_penyerbukan'] = null;
$validatedData['tingkat_keberhasilan_penyerbukan'] = null;
}
$pemupukanTidakPernah = (int) ($validatedData['pemberian_pupuk'] ?? 0) === PemberianPupuk::TIDAK_PERNAH->value;
$praktikBudidaya = $petani->praktikBudidaya()->create([
@@ -241,7 +247,7 @@ class PraktikBudidayaController extends Controller
public function edit($petaniId, $praktikBudidayaId)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -334,7 +340,7 @@ class PraktikBudidayaController extends Controller
public function update($petaniId, $praktikBudidayaId, Request $request)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -567,6 +573,12 @@ class PraktikBudidayaController extends Controller
$pemupukanTidakPernah = (int) ($validatedData['pemberian_pupuk'] ?? 0) === PemberianPupuk::TIDAK_PERNAH->value;
if (!$petani->isKomoditasVanili()) {
$validatedData['penyerbukan_manual'] = null;
$validatedData['alasan_tidak_penyerbukan'] = null;
$validatedData['tingkat_keberhasilan_penyerbukan'] = null;
}
$dataPraktikBudidaya = [
'tenaga_kerja' => $validatedData['tenaga_kerja'],
'frekuensi_pemangkasan_utama' => $validatedData['frekuensi_pemangkasan_komoditas_utama'],
@@ -682,7 +694,7 @@ class PraktikBudidayaController extends Controller
'uid' => 'SP-' . strtoupper(Str::random(10)),
'petani_id' => $petani->id,
'created_by' => auth()->id(),
'updated_by_by' => auth()->id()
'updated_by' => auth()->id()
], $dataSurveyPanen));
}
}
@@ -49,20 +49,43 @@ class ProfilPetaniController extends Controller
$skorPetani = SkorPetani::where('petani_id', $id)->first();
$enumerator = Profile::where('user_id', $petani->created_by)->first();
$komoditasUtama = Komoditas::where('id', $petani->komoditas_id)->first();
$lahan = Lahan::where('petani_id', $id )->get();
$lahan = Lahan::query()
->select(['id', 'petani_id', 'nama', 'path', 'luas_lahan', 'komoditas_id', 'desa_kelurahan_id'])
->with([
'komoditas:id,nama',
'desaKelurahan:id,nama,path,kecamatan_id',
'desaKelurahan.kecamatan:id,nama,path',
])
->where('petani_id', $id)
->get();
$desaKelurahan = DesaKelurahan::with('kecamatan')
->where('id', $petani->desa_kelurahan_id)
->first();
$ktpMasked = $petani->ktp ? Str::mask($petani->ktp, '*', 4, -4) : '-';
$profiling = $skorPetani ? $this->buildProfiling($skorPetani) : null;
$assessmentFields = [
'skor_kesiapan_adopsi',
'skor_d2_sistem_praktik_budidaya',
'skor_d4_sosial_ekonomi',
'skor_d6_rantai_nilai_akses_pasar',
'skor_komp_1_d4_k3_motivasi_kapasitas_adopsi',
'skor_komp_2_d4_k2_ekonomi_rumah_tangga',
'skor_komp_3_d2_praktik_budidaya_gap',
'skor_komp_4_d4_k4_keterlibatan_kelembagaan',
'skor_komp_5_d4_k5_k6_akses_kebebasan_keuangan',
'skor_komp_6_d6_k1_k4_k5_rantai_nilai_akses_pasar',
];
$hasAssessment = $skorPetani !== null && collect($assessmentFields)->every(
fn (string $field) => $skorPetani->{$field} !== null
);
$profiling = $hasAssessment ? $this->buildProfiling($skorPetani) : null;
$tanaman = Tanaman::where('petani_id', $petani->id)
->where('komoditas_id', $petani->komoditas_id)
->first();
if ($tanaman) {
if ($tanaman?->updated_at && $tanaman->umur_tanaman !== null) {
$tahunInputTanaman = $tanaman->updated_at->year;
$umurTanaman = (int) $tanaman->umur_tanaman;
$tahunSekarang = now()->year;
@@ -162,8 +185,8 @@ class ProfilPetaniController extends Controller
$d4SosialEkonomi = null;
$terdaftarKelompok = $sosialEkonomi->terdaftar_kelompok_tani;
$peranKelompok = $sosialEkonomi->peran_dalam_kelompok;
$terdaftarKelompok = $sosialEkonomi?->terdaftar_kelompok_tani;
$peranKelompok = $sosialEkonomi?->peran_dalam_kelompok;
if ($sosialEkonomi) {
@@ -207,8 +230,8 @@ class ProfilPetaniController extends Controller
'icon' => 'tabler-minus',
],
default => [
'label' => Str::headline($status),
'short' => Str::headline($status),
'label' => filled($status) ? Str::headline($status) : 'Belum diisi',
'short' => filled($status) ? Str::headline($status) : 'Belum diisi',
'color' => 'secondary',
'icon' => 'tabler-help-circle',
],
@@ -434,12 +457,61 @@ class ProfilPetaniController extends Controller
],
];
// Report missing profile data without treating an unanswered field as zero.
$missingSections = [];
$identityFields = [
'nama' => 'Nama', 'ktp' => 'NIK', 'tanggal_lahir' => 'Tanggal lahir',
'jenis_kelamin' => 'Jenis kelamin', 'pendidikan_terakhir' => 'Pendidikan terakhir',
'status_perkawinan' => 'Status perkawinan',
];
foreach ($identityFields as $field => $label) {
if (blank($petani->{$field})) {
$missingSections[] = $label;
}
}
if (!$desaKelurahan?->nama || !$desaKelurahan?->kecamatan?->nama) {
$missingSections[] = 'Wilayah desa/kecamatan';
}
if (!$komoditasUtama?->nama) {
$missingSections[] = 'Komoditas utama';
}
if ($lahan->isEmpty()) {
$missingSections[] = 'Data lahan';
}
if ($tahunTanam === null) {
$missingSections[] = 'Umur tanaman';
}
if (!$praktikBudidaya || $indikatorOnFarm->contains(fn ($item) => $item['skor'] === null)) {
$missingSections[] = 'Praktik budidaya';
}
if ($surveyPanen?->hasil_komoditas_utama_permusim === null) {
$missingSections[] = 'Survei panen';
}
$socialFields = [
'jumlah_tanggungan', 'skor_kecukupan_penghasilan', 'skor_kemampuan_menabung',
'status_kepemilikan_lahan', 'terdaftar_kelompok_tani', 'status_rekening_tabungan',
'pengalaman_kredit_kebun', 'keterikatan_hutang_panen', 'praktik_ijon',
'kebebasan_memilih_pembeli',
];
if (!$sosialEkonomi || collect($socialFields)->contains(fn ($field) => blank($sosialEkonomi->{$field}))) {
$missingSections[] = 'Sosial ekonomi';
}
if (!$aksesPasar || blank($aksesPasar->saluran_terdahulu)) {
$missingSections[] = 'Akses pasar';
}
if (!$hasAssessment) {
$missingSections[] = 'Penilaian kesiapan adopsi belum lengkap';
}
$data = [
'missingSections' => $missingSections,
'hasAssessment' => $hasAssessment,
'petani' => $petani,
'skorPetani' => $skorPetani,
'enumerator' => $enumerator,
'komoditasUtama' => $komoditasUtama,
'lahan' => $lahan,
'farmerLandMap' => $this->farmerLandMapData($lahan),
'desaKelurahan' => $desaKelurahan,
'ktpMasked' => $ktpMasked,
'profiling' => $profiling,
@@ -447,7 +519,6 @@ class ProfilPetaniController extends Controller
'umurTanaman' => $umurTanaman,
'surveyPanen' => $surveyPanen,
'praktikBudidaya' => $praktikBudidaya,
'praktikBudidaya' => $praktikBudidaya,
'indikatorOnFarm' => $indikatorOnFarm,
'detailOnFarm' => $detailOnFarm,
'sosialEkonomi' => $sosialEkonomi,
@@ -464,6 +535,35 @@ class ProfilPetaniController extends Controller
return view('produktivitas.profil-petani.index', $data);
}
private function farmerLandMapData(\Illuminate\Support\Collection $lands): array
{
$regions = ['desa' => [], 'kecamatan' => []];
$plots = [];
foreach ($lands as $land) {
$plot = [
'id' => $land->id,
'nama' => $land->nama,
'path' => $land->path,
'luas' => $land->luas_lahan,
'komoditas' => $land->komoditas?->nama,
];
foreach (['desa' => $land->desaKelurahan, 'kecamatan' => $land->desaKelurahan?->kecamatan] as $level => $region) {
$plot[$level . '_id'] = $region?->id;
if ($region && !isset($regions[$level][$region->id])) {
$regions[$level][$region->id] = [
'id' => $region->id,
'nama' => $region->nama,
'path' => $region->path,
];
}
}
$plots[] = $plot;
}
return ['lands' => $plots, 'regions' => $regions];
}
private function getScoreColor(?int $score): string
{
if ($score === null) {
@@ -16,7 +16,7 @@ class SkorPetaniController extends Controller
*/
public function index(string $petaniId)
{
if (!Gate::any(['super_admin', 'admin', 'fasilitator'])) {
if (!Gate::any(['super_admin', 'admin', 'koordinator', 'enumerator'])) {
abort(403);
}
@@ -0,0 +1,445 @@
<?php
namespace App\Http\Controllers\Web\Produktivitas;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use App\Models\Pertanian\KelompokTani;
use App\Models\Wilayah\Provinsi;
use App\Models\Wilayah\KabupatenKota;
use App\Models\Wilayah\Kecamatan;
use App\Models\Wilayah\DesaKelurahan;
use App\Models\Pertanian\Komoditas;
class SurveyKelompokTaniController extends Controller
{
private function authorizeAccess(): void
{
if (!Gate::any(['super_admin', 'admin', 'enumerator'])) {
abort(403);
}
}
private function redirectToSection(string $url, string $section, string $message, ?string $nextSection = null)
{
return redirect($url . '#section-' . ($nextSection ?? $section))->with([
'success' => $message,
'success_section' => $section,
]);
}
public function index(Request $request)
{
$validated = $request->validate([
'search' => ['nullable', 'string', 'max:255'],
'kabupaten_kota_id' => ['nullable', 'integer', 'exists:master_kabupaten_kota,id'],
'kecamatan_id' => ['nullable', 'integer', 'exists:master_kecamatan,id'],
'desa_kelurahan_id' => ['nullable', 'integer', 'exists:master_desa_kelurahan,id'],
'komoditas_id' => ['nullable', 'integer', 'exists:master_komoditas,id'],
'jenis_kelompok' => ['nullable', 'string', 'max:50'],
'sort_by' => ['nullable', 'string'],
'per_page' => ['nullable', 'integer', 'in:10,25'],
]);
$defaultKabupaten = KabupatenKota::select('id', 'nama')
->whereRaw('lower(nama) like ?', ['%ngada%'])->orderBy('nama')->first();
$allowedSorts = ['nama_asc', 'nama_desc', 'anggota_desc', 'anggota_asc'];
$filters = [
'search' => $validated['search'] ?? null,
'kabupaten_kota_id' => $validated['kabupaten_kota_id'] ?? $defaultKabupaten?->id,
'kecamatan_id' => $validated['kecamatan_id'] ?? null,
'desa_kelurahan_id' => $validated['desa_kelurahan_id'] ?? null,
'komoditas_id' => $validated['komoditas_id'] ?? null,
'jenis_kelompok' => $validated['jenis_kelompok'] ?? null,
'sort_by' => in_array($validated['sort_by'] ?? null, $allowedSorts, true)
? $validated['sort_by'] : null,
];
$query = KelompokTani::query()
->when(filled($filters['search']), function ($query) use ($filters) {
$search = '%' . Str::lower($filters['search']) . '%';
$query->where(function ($query) use ($search) {
$query->whereRaw('lower(nama_kelompok) like ?', [$search])
->orWhereRaw('lower(nama_responden) like ?', [$search]);
});
})
->when($filters['kabupaten_kota_id'], function ($query, $id) {
$query->whereHas('desaKelurahan.kecamatan', fn ($query) => $query->where('kabupaten_kota_id', $id));
})
->when($filters['kecamatan_id'], function ($query, $id) {
$query->whereHas('desaKelurahan', fn ($query) => $query->where('kecamatan_id', $id));
})
->when($filters['desa_kelurahan_id'], fn ($query, $id) => $query->where('desa_kelurahan_id', $id))
->when($filters['komoditas_id'], function ($query, $id) {
$query->where(function ($query) use ($id) {
$query->whereJsonContains('komoditas_id', (string) $id)
->orWhereJsonContains('komoditas_id', (int) $id);
});
})
->when($filters['jenis_kelompok'], fn ($query, $jenis) => $query->where('jenis_kelompok', $jenis));
$summary = (clone $query)->selectRaw('
COUNT(*) AS total_kelompok,
COUNT(DISTINCT jenis_kelompok) AS total_jenis,
COALESCE(SUM(jumlah_pengurus), 0) AS total_pengurus,
COALESCE(SUM(jumlah_pengurus_aktif), 0) AS pengurus_aktif,
COALESCE(SUM(jumlah_anggota_terdaftar), 0) AS total_anggota,
COALESCE(SUM(jumlah_anggota_aktif), 0) AS anggota_aktif
')->first();
match ($filters['sort_by']) {
'nama_asc' => $query->orderBy('nama_kelompok'),
'nama_desc' => $query->orderByDesc('nama_kelompok'),
'anggota_desc' => $query->orderByRaw('COALESCE(jumlah_anggota_terdaftar, 0) DESC'),
'anggota_asc' => $query->orderByRaw('COALESCE(jumlah_anggota_terdaftar, 0) ASC'),
default => $query->latest(),
};
$perPage = (int) ($validated['per_page'] ?? 10);
$kelompokTani = $query->with('desaKelurahan.kecamatan')->orderByDesc('id')
->paginate($perPage)->appends([...$filters, 'per_page' => $perPage]);
$selectedKabupaten = $filters['kabupaten_kota_id']
? KabupatenKota::select('id', 'nama')->find($filters['kabupaten_kota_id']) : null;
$kecamatanOptions = $filters['kabupaten_kota_id']
? Kecamatan::select('id', 'nama')->where('kabupaten_kota_id', $filters['kabupaten_kota_id'])->orderBy('nama')->get()
: collect();
$desaOptions = $filters['kecamatan_id']
? DesaKelurahan::select('id', 'nama')->where('kecamatan_id', $filters['kecamatan_id'])->orderBy('nama')->get()
: collect();
$komoditasOptions = Komoditas::select('id', 'nama')->orderBy('nama')->get();
$jenisKelompokOptions = KelompokTani::query()->whereNotNull('jenis_kelompok')
->where('jenis_kelompok', '!=', '')->distinct()->orderBy('jenis_kelompok')->pluck('jenis_kelompok');
return view('produktivitas.survey-kelompok-tani.index', compact(
'kelompokTani', 'summary', 'filters', 'selectedKabupaten',
'kecamatanOptions', 'desaOptions', 'komoditasOptions', 'jenisKelompokOptions'
));
}
public function create()
{
$this->authorizeAccess();
$provinsi = Provinsi::orderBy('kode', 'asc')->get();
$komoditas = Komoditas::where('is_utama', true)->get();
return view('produktivitas.survey-kelompok-tani.add', [
...compact('provinsi', 'komoditas'),
...$this->profileLocationOptions(),
]);
}
private function profileLocationOptions(?KelompokTani $kelompokTani = null): array
{
$locationId = static fn ($value) => is_scalar($value) && filter_var($value, FILTER_VALIDATE_INT) !== false ? (int) $value : null;
$provinsiId = $locationId(old('provinsi_id', $kelompokTani?->desaKelurahan?->kecamatan?->kabupatenKota?->provinsi_id));
$kabupatenId = $locationId(old('kabupaten_id', $kelompokTani?->desaKelurahan?->kecamatan?->kabupaten_kota_id));
$kecamatanId = $locationId(old('kecamatan_id', $kelompokTani?->desaKelurahan?->kecamatan_id));
return [
'kabupatenOptions' => filled($provinsiId) ? KabupatenKota::where('provinsi_id', $provinsiId)->orderBy('nama')->get() : collect(),
'kecamatanOptions' => filled($kabupatenId) ? Kecamatan::where('kabupaten_kota_id', $kabupatenId)->orderBy('nama')->get() : collect(),
'desaOptions' => filled($kecamatanId) ? DesaKelurahan::where('kecamatan_id', $kecamatanId)->orderBy('nama')->get() : collect(),
];
}
public function store(Request $request)
{
$this->authorizeAccess();
$validated = $this->validateIdentitas($request);
if (($validated['jenis_kelompok'] ?? null) === 'Lainnya') {
$validated['jenis_kelompok'] = $validated['jenis_kelompok_lainnya'];
}
unset($validated['jenis_kelompok_lainnya']);
$kelompokTani = KelompokTani::create([
...$validated,
'uid' => 'KLP-' . strtoupper(Str::random(10)),
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]);
return $this->redirectToSection(route('kelompok-tani.edit', $kelompokTani->id), 'identitas', 'Data Profil berhasil ditambahkan.', 'pengurus');
}
private function editUrl(KelompokTani $kelompokTani, string $section = 'identitas'): string
{
$url = route('kelompok-tani.edit', $kelompokTani->id);
return $section === 'identitas' ? $url : $url . '?section=' . $section;
}
public function edit(Request $request, KelompokTani $kelompokTani)
{
$this->authorizeAccess();
return match ($request->query('section', 'identitas')) {
'kelembagaan' => view('produktivitas.survey-kelompok-tani.kelembagaan', compact('kelompokTani')),
'aktivitas-fungsi' => view('produktivitas.survey-kelompok-tani.aktivitas-fungsi', [
'kelompokTani' => $kelompokTani->loadMissing(['aset', 'bantuan']),
]),
'sosial-konflik' => view('produktivitas.survey-kelompok-tani.sosial-konflik', compact('kelompokTani')),
default => view('produktivitas.survey-kelompok-tani.edit', [
'kelompokTani' => $kelompokTani->loadMissing('pengurus'),
'provinsi' => Provinsi::orderBy('kode', 'asc')->get(),
'komoditas' => Komoditas::where('is_utama', true)->get(),
...$this->profileLocationOptions($kelompokTani),
]),
};
}
public function update(Request $request, KelompokTani $kelompokTani)
{
$this->authorizeAccess();
return match ($request->input('section', 'identitas')) {
'ringkasan-pengurus' => $this->updateRingkasanPengurus($request, $kelompokTani),
'kelembagaan' => $this->updateKelembagaan($request, $kelompokTani),
'aktivitas-fungsi' => $this->updateAktivitasFungsi($request, $kelompokTani),
'kas' => $this->updateKasSimpanPinjam($request, $kelompokTani),
'sosial-konflik' => $this->updateSosialKonflik($request, $kelompokTani),
'status-bantuan' => app(BantuanKelompokTaniController::class)->update($request, $kelompokTani),
'penilaian' => redirect($this->editUrl($kelompokTani, 'aktivitas-fungsi') . '#section-status-bantuan'),
default => $this->updateIdentitas($request, $kelompokTani),
};
}
public function destroy(string $id)
{
$this->authorizeAccess();
$kelompokTani = KelompokTani::findOrFail($id);
$kelompokTani->update(['deleted_by' => auth()->id()]);
$kelompokTani->delete();
return response()->json(['message' => 'Data kelompok tani berhasil dihapus.']);
}
private function validateIdentitas(Request $request)
{
$validated = $request->validate([
// Data Responden
'nama_responden' => ['required', 'string', 'max:255'],
'jabatan_responden' => ['nullable', 'string', 'max:255'],
'kontak_responden' => ['nullable', 'string', 'max:20'],
// Identitas Kelompok Tani
'nama_kelompok' => ['required', 'string', 'max:255'],
'provinsi_id' => ['bail', 'required', 'integer', 'exists:master_provinsi,id'],
'kabupaten_id' => ['bail', 'required', 'integer', 'exists:master_kabupaten_kota,id'],
'kecamatan_id' => ['bail', 'required', 'integer', 'exists:master_kecamatan,id'],
'desa_kelurahan_id' => ['bail', 'required', 'integer', 'exists:master_desa_kelurahan,id'],
'dusun' => ['nullable', 'string', 'max:255'],
'tahun_berdiri' => ['nullable', 'digits:4', 'integer', 'min:1900', 'max:' . date('Y')],
'jenis_kelompok' => ['required', 'string', 'max:50'],
'jenis_kelompok_lainnya' => ['required_if:jenis_kelompok,Lainnya', 'nullable', 'string', 'max:255'],
'nama_gapoktan' => ['nullable', 'string', 'max:255'],
'komoditas_id' => ['nullable', 'array'],
'komoditas_id.*' => ['exists:master_komoditas,id'],
...$this->keanggotaanRules(),
], [
'nama_responden.required' => 'Nama responden wajib diisi.',
'nama_kelompok.required' => 'Nama kelompok wajib diisi.',
'provinsi_id.required' => 'Provinsi wajib diisi.',
'kabupaten_id.required' => 'Kabupaten/Kota wajib diisi.',
'kecamatan_id.required' => 'Kecamatan wajib diisi.',
'provinsi_id.integer' => 'Pilihan Provinsi tidak valid.',
'kabupaten_id.integer' => 'Pilihan Kabupaten/Kota tidak valid.',
'kecamatan_id.integer' => 'Pilihan Kecamatan tidak valid.',
'desa_kelurahan_id.integer' => 'Pilihan Desa/Kelurahan tidak valid.',
'provinsi_id.exists' => 'Provinsi tidak ditemukan.',
'kabupaten_id.exists' => 'Kabupaten/Kota tidak ditemukan.',
'kecamatan_id.exists' => 'Kecamatan tidak ditemukan.',
'desa_kelurahan_id.required' => 'Desa/Kelurahan wajib dipilih.',
'desa_kelurahan_id.exists' => 'Desa/Kelurahan tidak ditemukan.',
'jenis_kelompok.required' => 'Terdaftar/Dikenal Sebagai wajib diisi.',
'jenis_kelompok_lainnya.required_if' => 'Sebutkan Jenis Kelompok wajib diisi.',
'tahun_berdiri.digits' => 'Tahun berdiri harus 4 digit.',
]);
unset($validated['provinsi_id'], $validated['kabupaten_id'], $validated['kecamatan_id']);
return $validated;
}
private function updateIdentitas(Request $request, KelompokTani $kelompokTani)
{
$validated = $this->validateIdentitas($request);
if (($validated['jenis_kelompok'] ?? null) === 'Lainnya') {
$validated['jenis_kelompok'] = $validated['jenis_kelompok_lainnya'];
}
unset($validated['jenis_kelompok_lainnya']);
$kelompokTani->update([
...$validated,
'updated_by' => auth()->id(),
]);
return $this->redirectToSection(route('kelompok-tani.edit', $kelompokTani->id), 'identitas', 'Data Profil berhasil disimpan.', 'pengurus');
}
private function keanggotaanRules(): array
{
return [
'jumlah_pengurus' => ['nullable', 'integer', 'min:0'],
'jumlah_pengurus_aktif' => ['nullable', 'integer', 'min:0'],
'jumlah_anggota_terdaftar' => ['nullable', 'integer', 'min:0'],
'jumlah_anggota_aktif' => ['nullable', 'integer', 'min:0'],
'jumlah_pengurus_laki_laki' => ['nullable', 'integer', 'min:0'],
'jumlah_pengurus_perempuan' => ['nullable', 'integer', 'min:0'],
'jumlah_anggota_laki_laki' => ['nullable', 'integer', 'min:0'],
'jumlah_anggota_perempuan' => ['nullable', 'integer', 'min:0'],
'jumlah_anggota_pemuda_laki_laki' => ['nullable', 'integer', 'min:0'],
'jumlah_anggota_pemuda_perempuan' => ['nullable', 'integer', 'min:0'],
];
}
private function updateRingkasanPengurus(Request $request, KelompokTani $kelompokTani)
{
$validated = $request->validate($this->keanggotaanRules());
$kelompokTani->update([
...$validated,
'updated_by' => auth()->id(),
]);
return $this->redirectToSection($this->editUrl($kelompokTani), 'identitas', 'Data Profil berhasil disimpan.', 'pengurus');
}
private function updateKelembagaan(Request $request, KelompokTani $kelompokTani)
{
$hasSk = in_array($request->input('status_legalitas'), KelompokTani::STATUS_LEGALITAS_DENGAN_SK, true);
$validated = $request->validate([
// Cakupan Wilayah Kelompok
'cakupan_wilayah' => ['nullable', 'string', 'max:50'],
'nama_wilayah_cakupan' => ['nullable', 'string'],
// Status Legalitas Kelompok
'status_legalitas' => ['nullable', 'string', 'max:50'],
'nomor_sk' => [Rule::excludeIf(!$hasSk), 'nullable', 'string', 'max:100'],
'tanggal_tahun_penerbitan_sk' => [Rule::excludeIf(!$hasSk), 'nullable', 'string', 'max:30'],
]);
$kelompokTani->update([
...$validated,
'updated_by' => auth()->id(),
]);
return $this->redirectToSection($this->editUrl($kelompokTani, 'kelembagaan'), 'kelembagaan', 'Data Kelembagaan berhasil disimpan.');
}
private function resolveLainnya(?array $selected, ?string $lainnyaText): ?array
{
if (empty($selected)) {
return $selected;
}
return array_values(array_map(function ($item) use ($lainnyaText) {
return $item === 'Lainnya' && filled($lainnyaText) ? $lainnyaText : $item;
}, $selected));
}
private function updateAktivitasFungsi(Request $request, KelompokTani $kelompokTani)
{
$validated = $request->validate([
// Frekuensi Pertemuan Rutin
'frekuensi_pertemuan' => ['nullable', 'string', 'max:50'],
'tanggal_pertemuan_terakhir' => ['nullable', 'date'],
// Kegiatan Konkret yang Sudah Berjalan
'kegiatan_terlaksana' => ['nullable', 'array'],
'kegiatan_terlaksana.*' => ['string', 'max:255'],
'kegiatan_terlaksana_lainnya' => ['nullable', 'string', 'max:255'],
'estimasi_panen_minimal' => ['nullable', 'numeric', 'min:0'],
'estimasi_panen_maksimal' => ['nullable', 'numeric', 'min:0', 'gte:estimasi_panen_minimal'],
'satuan_hasil_panen' => ['nullable', 'string', 'max:20'],
'tindakan_hasil_panen' => ['nullable', 'array'],
'tindakan_hasil_panen.*' => ['string', 'max:255'],
// Peran dan Fungsi Kelompok
'fungsi_kelompok_bagi_anggota' => ['nullable', 'array'],
'fungsi_kelompok_bagi_anggota.*' => ['string', 'max:255'],
'fungsi_kelompok_lainnya' => ['nullable', 'string', 'max:255'],
], [
'estimasi_panen_maksimal.gte' => 'Estimasi panen maksimal tidak boleh kurang dari estimasi minimal.',
]);
$validated['kegiatan_terlaksana'] = $this->resolveLainnya(
$validated['kegiatan_terlaksana'] ?? null,
$validated['kegiatan_terlaksana_lainnya'] ?? null
);
unset($validated['kegiatan_terlaksana_lainnya']);
$validated['fungsi_kelompok_bagi_anggota'] = $this->resolveLainnya(
$validated['fungsi_kelompok_bagi_anggota'] ?? null,
$validated['fungsi_kelompok_lainnya'] ?? null
);
unset($validated['fungsi_kelompok_lainnya']);
$kelompokTani->update([
...$validated,
'updated_by' => auth()->id(),
]);
return $this->redirectToSection($this->editUrl($kelompokTani, 'aktivitas-fungsi'), 'aktivitas-fungsi', 'Data Operasional berhasil disimpan.', 'aset-baku');
}
private function updateKasSimpanPinjam(Request $request, KelompokTani $kelompokTani)
{
$validated = $request->validate([
'pembukuan_kas' => ['nullable', 'string', 'max:50'],
'pengelola_kas' => ['nullable', 'string', 'max:255'],
'masalah_pengelolaan_kas_simpan_pinjam' => ['nullable', 'string', 'max:255'],
'deskripsi_masalah_pengelolaan_kas_simpan_pinjam' => ['nullable', 'string'],
]);
$kelompokTani->update([
...$validated,
'updated_by' => auth()->id(),
]);
return $this->redirectToSection($this->editUrl($kelompokTani, 'aktivitas-fungsi'), 'kas', 'Data Operasional berhasil disimpan.', 'status-bantuan');
}
private function updateSosialKonflik(Request $request, KelompokTani $kelompokTani)
{
$pernahKonflik = in_array($request->input('konflik_kelompok'), [
'Pernah, sudah selesai', 'Pernah, masih berlanjut/belum selesai',
], true);
$validated = $request->validate([
// Keterlibatan Perempuan
'partisipasi_perempuan' => ['nullable', 'string', 'max:255'],
// Riwayat Konflik Internal Kelompok
'konflik_kelompok' => ['nullable', 'string', 'max:255'],
'topik_konflik' => [Rule::requiredIf($pernahKonflik), 'nullable', 'string', 'max:255'],
'penyelesaian_konflik' => ['nullable', 'string'],
], [
'topik_konflik.required' => 'Jika Pernah, Soal Apa? wajib diisi jika pernah terjadi konflik internal.',
]);
if (!$pernahKonflik) {
$validated['topik_konflik'] = null;
$validated['penyelesaian_konflik'] = null;
}
$kelompokTani->update([
...$validated,
'updated_by' => auth()->id(),
]);
return $this->redirectToSection($this->editUrl($kelompokTani, 'sosial-konflik'), 'sosial-konflik', 'Data Sosial berhasil disimpan.');
}
}
@@ -15,6 +15,16 @@ class SurveyProduktivitasController extends Controller
{
$petani = Petani::findOrFail($petaniId);
// Baris yang benar-benar kosong dilewati (sama seperti Pendidikan & Pelatihan)
$request->merge([
'produktivitas_komoditas' => collect($request->input('produktivitas_komoditas', []))
->filter(fn ($row) => filled($row['komoditas_id'] ?? null)
|| filled($row['hasil_panen_3_musim'] ?? null)
|| filled($row['satuan'] ?? null))
->values()
->all(),
]);
$validator = Validator::make($request->all(), [
'produktivitas_komoditas' => ['required', 'array', 'min:1'],
'produktivitas_komoditas.*.komoditas_id' => ['required', 'exists:master_komoditas,id'],
@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\Web\Verifikasi;
use App\Enums\StatusDataPetani;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Web\Concerns\AksesMonitoringPetani;
use App\Models\Pertanian\Petani;
use App\Services\Produktivitas\KelengkapanPetani;
use Illuminate\Http\Request;
/**
* Menu Verifikasi > 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.");
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Middleware;
use App\Enums\Role;
use App\Enums\StatusDataPetani;
use App\Models\Pertanian\Petani;
use Closure;
use Illuminate\Http\Request;
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
{
private const ROLE_DIIZINKAN = [
Role::SUPER_ADMIN,
Role::ADMIN,
Role::KOORDINATOR,
Role::ENUMERATOR,
];
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
abort_unless($user && in_array($user->role, self::ROLE_DIIZINKAN, true), 403);
$petani = $request->route('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,
'Petani ini bukan bagian dari komoditas yang Anda koordinasikan.'
);
}
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);
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Models\Bumdes;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class KelembagaanAktorBumdes extends Model
{
use SoftDeletes;
protected $table = 'bumdes_kelembagaan_aktor';
protected $fillable = [
'pengurus_bumdes_id',
'status_badan_hukum',
'nomor_dasar_hukum',
'tahun_dasar_hukum',
'status_keaktifan',
'alasan_tidak_aktif',
'unit_usaha',
'unit_usaha_jasa_lainnya',
'status_unit_komoditas',
'nama_unit_komoditas',
'status_ad_art',
'tahun_ad_art',
'pengurus_inti',
'memiliki_staf',
'jumlah_staf',
'unit_usaha_staf',
'modal_awal',
'tahun_modal_awal',
'pernah_penambahan_modal',
'penambahan_modal',
'aset_produktif',
'aset_produktif_lainnya',
'status_kepemilikan_aset',
'infrastruktur_pascapanen',
'infrastruktur_pascapanen_lainnya',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'tahun_dasar_hukum' => 'integer',
'tahun_ad_art' => 'integer',
'unit_usaha' => 'array',
'pengurus_inti' => 'array',
'memiliki_staf' => 'boolean',
'jumlah_staf' => 'integer',
'modal_awal' => 'decimal:2',
'tahun_modal_awal' => 'integer',
'pernah_penambahan_modal' => 'boolean',
'penambahan_modal' => 'array',
'aset_produktif' => 'array',
'infrastruktur_pascapanen' => 'array',
];
public function pengurusBumdes(): BelongsTo
{
return $this->belongsTo(PengurusBumdes::class, 'pengurus_bumdes_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
}
+6
View File
@@ -9,6 +9,7 @@ use App\Models\Wilayah\DesaKelurahan;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
class PengurusBumdes extends Model
@@ -105,4 +106,9 @@ class PengurusBumdes extends Model
{
return $this->belongsTo(User::class, 'created_by');
}
public function kelembagaanAktor(): HasOne
{
return $this->hasOne(KelembagaanAktorBumdes::class, 'pengurus_bumdes_id');
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Models\Concerns;
// Mengisi kolom deleted_by otomatis saat data di-soft delete (AISTANDAR 5.3).
trait HasDeletedBy
{
protected static function bootHasDeletedBy(): void
{
static::deleting(function ($model) {
if (! $model->isForceDeleting()) {
$model->deleted_by = auth()->id();
$model->saveQuietly();
}
});
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Models\Concerns;
use App\Enums\StatusDataPetani;
use App\Models\Pertanian\Petani;
use Closure;
/**
* Data petani yang sudah ditandai "Lengkap" lalu diubah/ditambah/dihapus
* otomatis kembali ke "Draf", supaya enumerator menandai lengkap lagi setelah selesai.
*
* Dipasang di semua model yang menyimpan data profil petani (web maupun API).
* Model yang tidak punya kolom petani_id meng-override petaniIdUntukStatus().
*/
trait KembalikanStatusPetaniKeDraf
{
// 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', 'catatan',
];
public static function bootKembalikanStatusPetaniKeDraf(): void
{
static::saved(function ($model) {
$kolomBerubah = array_diff(array_keys($model->getChanges()), self::$kolomBukanPerubahanData);
if ($model->wasRecentlyCreated || $kolomBerubah) {
$model->kembalikanStatusPetaniKeDraf();
}
});
static::deleted(fn ($model) => $model->kembalikanStatusPetaniKeDraf());
}
protected function petaniIdUntukStatus(): ?int
{
return $this->petani_id;
}
public function kembalikanStatusPetaniKeDraf(): void
{
$petaniId = $this->petaniIdUntukStatus();
if (! $petaniId) {
return;
}
// Satu simpan bisa memicu ratusan baris (mis. sinkron jenis hama ke semua tanaman).
// Cukup sekali per petani per request: setelah dicek, status pasti bukan "Lengkap" lagi.
self::ingatPerRequest('status_petani.dicek', $petaniId, function () use ($petaniId) {
// Query langsung (tanpa event model) supaya tidak memicu hook lain / berulang.
$diubah = Petani::withoutGlobalScopes()
->whereKey($petaniId)
->where('status', StatusDataPetani::LENGKAP->value)
->update(['status' => StatusDataPetani::DRAFT->value]);
if ($diubah && request()->hasSession()) {
request()->session()->flash('status_petani_info', 'Data petani diubah, status kembali ke Draf. Tandai lengkap lagi setelah selesai.');
}
return true;
});
}
/**
* Simpan hasil $ambil() selama satu request web, per $kunci + $id.
* Di console/queue tidak disimpan (proses berjalan lama, data bisa berubah antar-job).
*/
protected static function ingatPerRequest(string $kunci, int|string|null $id, Closure $ambil): mixed
{
if ($id === null || app()->runningInConsole()) {
return $ambil();
}
$bag = request()->attributes;
$cache = $bag->get($kunci, []);
if (! array_key_exists($id, $cache)) {
$cache[$id] = $ambil();
$bag->set($kunci, $cache);
}
return $cache[$id];
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Models\Konfigurasi;
use App\Models\Concerns\HasDeletedBy;
use App\Models\Pertanian\Komoditas;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class KoordinatorKomoditas extends Model
{
use SoftDeletes, HasDeletedBy;
protected $table = 'koordinator_komoditas';
protected $fillable = [
'user_id',
'komoditas_id',
'created_by',
'updated_by',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function komoditas(): BelongsTo
{
return $this->belongsTo(Komoditas::class, 'komoditas_id');
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Models\Koperasi;
use Illuminate\Database\Eloquent\Model;
class PengurusKoperasi extends Model
{
protected $table = 'pengurus_koperasi';
protected $fillable = [
// Profil Dassar
'nama_lengkap', 'posisi_dalam_koperasi', 'tahun_bergabung',
'no_hp', 'alamat_tinggal',
// Profil Koperasi
'nama_koperasi', 'jenis_koperasi', 'alamat_koperasi',
'skala_operasi', 'tahun_berdiri',
// Anggota & Pengurus
'jumlah_anggota_awal', 'jumlah_anggota_dewasa', 'jumlah_anggota_anak', 'jumlah_pengurus',
// SHU
'shu_2025', 'shu_2024', 'shu_2023',
// Kelembagaan & Manajemen
'sejarah_lembaga', 'fokus_komoditas', 'detail_fokus_komoditas',
'ad_art', 'tahun_ad_art', 'layanan_koperasi', 'layanan_lainnya',
'pelatihan_produksi', 'pelatihan_pascapanen', 'pelatihan_pengolahan',
'pelatihan_keuangan', 'pelatihan_pemasaran', 'pelatihan_lainnya',
'status_pembukuan', 'format_pembukuan', 'pertemuan_rutin', 'pertemuan_lainnya',
'kasus_kredit_macet', 'sanksi_kredit_macet', 'efektivitas_sanksi',
'faktor_manajemen', 'faktor_manajemen_lainnya', 'keterlibatan_rantai_nilai',
'agregasi_komoditas', 'agregasi_komoditas_lainnya', 'fasilitas_koperasi', 'fasilitas_lainnya',
'pengolahan_lanjutan', 'detail_pengolahan_lanjutan', 'cara_penentuan_harga',
'kontrak_offtaker', 'kontrak_komoditas',
// Akses Pasar & Rantai Nilai Eksternal
'sumber_pasokan', 'sumber_pasokan_lainnya', 'bentuk_pembelian',
'volume_beli_sekarang', 'volume_beli_1_tahun', 'volume_beli_2_tahun',
'tujuan_penjualan', 'tujuan_penjualan_lainnya', 'bentuk_penjualan',
'volume_jual_sekarang', 'volume_jual_1_tahun', 'volume_jual_2_tahun',
'sifat_hubungan_bisnis', 'sifat_hubungan_lainnya', 'mekanisme_pembayaran', 'status_perjanjian_tertulis',
// Harga Komoditas
'harga_segar_sekarang', 'harga_segar_1_tahun', 'harga_segar_2_tahun',
'harga_basah_sekarang', 'harga_basah_1_tahun', 'harga_basah_2_tahun',
'harga_kering_sekarang', 'harga_kering_1_tahun', 'harga_kering_2_tahun',
'harga_fermentasi_sekarang', 'harga_fermentasi_1_tahun', 'harga_fermentasi_2_tahun',
'cara_menetapkan_harga_pasar', 'cara_menetapkan_harga_lainnya',
// Praktik Kualitas & Pascapanen
'praktik_sortasi', 'standar_sortasi', 'praktik_pengeringan', 'cara_pengeringan',
'praktik_fermentasi', 'model_fermentasi', 'proses_fermentasi',
'harga_premium_rupiah', 'harga_premium_persen',
// Sosial Ekonomi
'simpanan_terbesar', 'momen_simpanan_terbesar',
'alasan_pinjaman', 'alasan_pinjaman_lainnya', 'pembayaran_selain_uang',
'bentuk_pembayaran_barang', 'bentuk_pembayaran_lainnya', 'perhitungan_harga_pasar', 'alasan_bukan_harga_pasar',
// PENILAIAN ENUMERATOR
'enum_potensi', 'enum_alasan_potensi', 'enum_kebutuhan', 'enum_kebutuhan_lain', 'enum_keterbukaan'
];
}
+13 -2
View File
@@ -2,6 +2,7 @@
namespace App\Models\Pertanian;
use App\Models\Concerns\KembalikanStatusPetaniKeDraf;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
@@ -15,17 +16,25 @@ use App\Enums\AksesPasar\SkorKesadaranPremium;
class AksesPasar extends Model
{
use SoftDeletes;
use SoftDeletes, KembalikanStatusPetaniKeDraf;
protected $table = 'akses_pasar';
protected $fillable = [
'uid',
'petani_id',
'tujuan_penjualan',
'tujuan_penjualan_lainnya',
'sifat_hubungan_bisnis',
'bentuk_penjualan',
'frekuensi_penjualan',
'skor_pengalaman',
'saluran_terdahulu',
'saluran_terdahulu_lainnya',
'alasan_berhenti',
'tahu_harga_sebelumnya',
'sumber_info_harga',
'sumber_info_harga_lainnya',
'praktik_fermentasi',
'proses_fermentasi',
'model_fermentasi_memungkinkan',
@@ -44,7 +53,9 @@ class AksesPasar extends Model
];
protected $casts = [
'tujuan_penjualan' => 'array',
'saluran_terdahulu' => 'array',
'sumber_info_harga' => 'array',
];
protected static function booted(): void
@@ -67,7 +78,7 @@ class AksesPasar extends Model
$skor->update([
'skor_d6_k1_6_pengalaman_menjual_ke_saluran_berbeda' => $aksesPasar->skor_pengalaman,
'skor_d6_k4_1_kesadaran_akses_pasar_premium' => $aksesPasar->skor_kesadaran_premium,
'skor_d6_k5_1_hambatan_mutu' => $aksesPasar->skor_kepercayaan_responden
// skor_d6_k5_1_hambatan_mutu diisi dari SurveyPanen (tingkat hasil panen ditolak).
]);
});
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Models\Pertanian;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AsetKelompokTani extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'survey_aset_kelompok_tani';
protected $fillable = [
'uid',
'survey_kelompok_tani_id',
'jenis_aset',
'status_aset',
'status_pengelolaan_aset',
'kondisi_aset',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'status_aset' => 'boolean',
];
public function kelompokTani(): BelongsTo
{
return $this->belongsTo(KelompokTani::class, 'survey_kelompok_tani_id');
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Models\Pertanian;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BantuanKelompokTani extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'survey_bantuan_kelompok_tani';
protected $fillable = [
'uid',
'survey_kelompok_tani_id',
'nama_program',
'penyelenggara',
'bentuk_bantuan',
'tahun',
'created_by',
'updated_by',
'deleted_by',
];
public function kelompokTani(): BelongsTo
{
return $this->belongsTo(KelompokTani::class, 'survey_kelompok_tani_id');
}
}
+3
View File
@@ -2,12 +2,15 @@
namespace App\Models\Pertanian;
use App\Models\Concerns\KembalikanStatusPetaniKeDraf;
use Illuminate\Database\Eloquent\Model;
use App\Models\User;
use App\Models\Pertanian\Petani;
class HargaJualPanen extends Model
{
use KembalikanStatusPetaniKeDraf;
protected $table = 'harga_jual_panen';
protected $fillable = [
+141
View File
@@ -0,0 +1,141 @@
<?php
namespace App\Models\Pertanian;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Models\Wilayah\DesaKelurahan;
class KelompokTani extends Model
{
use HasFactory, SoftDeletes;
public const STATUS_LEGALITAS_DENGAN_SK = [
'Sudah ada SK dari desa/kecamatan',
'Sudah ada SK dan terdaftar di Dinas/Kabupaten',
];
protected $table = 'survey_kelompok_tani';
protected $fillable = [
'uid',
// Data Responden
'nama_responden',
'jabatan_responden',
'kontak_responden',
// Identitas Kelompok Tani
'nama_kelompok',
'desa_kelurahan_id',
'dusun',
'tahun_berdiri',
'jenis_kelompok',
'nama_gapoktan',
'komoditas_id',
// Pengurus & Anggota Kelompok
'jumlah_pengurus',
'jumlah_pengurus_aktif',
'jumlah_anggota_terdaftar',
'jumlah_anggota_aktif',
'jumlah_pengurus_laki_laki',
'jumlah_pengurus_perempuan',
'jumlah_anggota_laki_laki',
'jumlah_anggota_perempuan',
'jumlah_anggota_pemuda_laki_laki',
'jumlah_anggota_pemuda_perempuan',
// Cakupan Wilayah Kelompok
'cakupan_wilayah',
'nama_wilayah_cakupan',
// Status Legalitas Kelompok
'status_legalitas',
'nomor_sk',
'tanggal_tahun_penerbitan_sk',
// Kualitas Kepemimpinan
'tingkat_keaktifan_ketua',
'status_pembagian_peran',
'status_dokumentasi_rapat',
// Frekuensi Pertemuan Rutin
'frekuensi_pertemuan',
'tanggal_pertemuan_terakhir',
// Kegiatan Konkret yang Sudah Berjalan
'kegiatan_terlaksana',
'estimasi_panen_minimal',
'estimasi_panen_maksimal',
'satuan_hasil_panen',
'tindakan_hasil_panen',
// Peran & Fungsi Kelompok
'fungsi_kelompok_bagi_anggota',
// Kapasitas Pengelolaan Kas/Simpan Pinjam
'pembukuan_kas',
'pengelola_kas',
'masalah_pengelolaan_kas_simpan_pinjam',
'deskripsi_masalah_pengelolaan_kas_simpan_pinjam',
// Keterlibatan Perempuan
'partisipasi_perempuan',
// Konflik Internal
'konflik_kelompok',
'topik_konflik',
'penyelesaian_konflik',
// Pengalaman Bantuan
'pernah_mendapatkan_bantuan',
// Postensi Sebagai Mitra Agregasi/Pengolahan Kolektif
'potensi_kelompok',
'alasan_penilaian_potensi',
// Audit
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'komoditas_id' => 'array',
'tindakan_hasil_panen' => 'array',
'fungsi_kelompok_bagi_anggota' => 'array',
'kegiatan_terlaksana' => 'array',
'pernah_mendapatkan_bantuan' => 'boolean',
'tanggal_pertemuan_terakhir' => 'date',
'estimasi_panen_minimal' => 'decimal:2',
'estimasi_panen_maksimal' => 'decimal:2',
];
public function desaKelurahan()
{
return $this->belongsTo(DesaKelurahan::class, 'desa_kelurahan_id');
}
public function pengurus()
{
return $this->hasMany(PengurusKelompokTani::class, 'survey_kelompok_tani_id');
}
public function aset()
{
return $this->hasMany(AsetKelompokTani::class, 'survey_kelompok_tani_id');
}
public function bantuan()
{
return $this->hasMany(BantuanKelompokTani::class, 'survey_kelompok_tani_id');
}
}
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models\Pertanian;
use App\Models\Concerns\KembalikanStatusPetaniKeDraf;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Models\Wilayah\DesaKelurahan;
@@ -22,7 +23,7 @@ use App\Models\Pertanian\Petani;
class Lahan extends Model
{
use SoftDeletes;
use SoftDeletes, KembalikanStatusPetaniKeDraf;
protected $table = 'map_lahan';
@@ -2,11 +2,14 @@
namespace App\Models\Pertanian;
use App\Models\Concerns\KembalikanStatusPetaniKeDraf;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PendapatanKomoditas extends Model
{
use KembalikanStatusPetaniKeDraf;
protected $table = 'pendapatan_komoditas';
protected $fillable = [
@@ -29,4 +32,11 @@ class PendapatanKomoditas extends Model
{
return $this->belongsTo(SosialEkonomi::class, 'sosial_ekonomi_id');
}
// Untuk trait KembalikanStatusPetaniKeDraf: petani diambil dari data sosial ekonomi.
protected function petaniIdUntukStatus(): ?int
{
return self::ingatPerRequest('status_petani.sosial_ekonomi', $this->sosial_ekonomi_id,
fn () => SosialEkonomi::withoutGlobalScopes()->whereKey($this->sosial_ekonomi_id)->value('petani_id'));
}
}
@@ -2,11 +2,14 @@
namespace App\Models\Pertanian;
use App\Models\Concerns\KembalikanStatusPetaniKeDraf;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PendapatanLuarPertanian extends Model
{
use KembalikanStatusPetaniKeDraf;
protected $table = 'pendapatan_luar_pertanian';
protected $fillable = [
@@ -27,4 +30,11 @@ class PendapatanLuarPertanian extends Model
{
return $this->belongsTo(SosialEkonomi::class, 'sosial_ekonomi_id');
}
// Untuk trait KembalikanStatusPetaniKeDraf: petani diambil dari data sosial ekonomi.
protected function petaniIdUntukStatus(): ?int
{
return self::ingatPerRequest('status_petani.sosial_ekonomi', $this->sosial_ekonomi_id,
fn () => SosialEkonomi::withoutGlobalScopes()->whereKey($this->sosial_ekonomi_id)->value('petani_id'));
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Models\Pertanian;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PengurusKelompokTani extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'survey_pengurus_kelompok_tani';
protected $fillable = [
'uid',
'survey_kelompok_tani_id',
'nama_pengurus',
'jabatan',
'kontak',
'status',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'status' => 'boolean',
];
public function kelompokTani(): BelongsTo
{
return $this->belongsTo(KelompokTani::class, 'survey_kelompok_tani_id');
}
}
+35 -1
View File
@@ -2,8 +2,11 @@
namespace App\Models\Pertanian;
use App\Models\Concerns\KembalikanStatusPetaniKeDraf;
use App\Enums\Pendidikan;
use App\Enums\Pernikahan;
use App\Enums\Role;
use App\Enums\StatusDataPetani;
use App\Models\User;
use App\Models\Wilayah\DesaKelurahan;
use App\Models\Pertanian\Lahan;
@@ -28,7 +31,7 @@ use Illuminate\Support\Str;
class Petani extends Model
{
use SoftDeletes;
use SoftDeletes, KembalikanStatusPetaniKeDraf;
protected $table = 'petani';
@@ -70,6 +73,12 @@ class Petani extends Model
'pendidikan_terakhir' => Pendidikan::class,
'status_perkawinan' => Pernikahan::class,
'status' => StatusDataPetani::class,
];
// Sengaja tidak masuk $fillable: status hanya diubah lewat PetaniStatusController.
protected $attributes = [
'status' => 'draft',
];
public function getUmurAttribute(): ?int
@@ -113,6 +122,20 @@ class Petani extends Model
return $this->belongsTo(Komoditas::class, 'komoditas_id');
}
// Hanya enumerator yang menginput petani ini yang boleh menandai Lengkap / membatalkannya.
public function bisaDitandaiOleh(?User $user): bool
{
return $user
&& $user->role === Role::ENUMERATOR
&& (int) $this->created_by === (int) $user->id;
}
// Pertanyaan penyerbukan manual hanya berlaku untuk petani vanili.
public function isKomoditasVanili(): bool
{
return str_contains(strtolower($this->komoditas?->nama ?? ''), 'vanil');
}
public function skor(): HasOne
{
return $this->hasOne(SkorPetani::class, 'petani_id');
@@ -398,4 +421,15 @@ 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
{
return $this->id;
}
}
+38 -22
View File
@@ -2,6 +2,7 @@
namespace App\Models\Pertanian;
use App\Models\Concerns\KembalikanStatusPetaniKeDraf;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -18,7 +19,7 @@ use App\Models\Pertanian\Lahan;
class PraktikBudidaya extends Model
{
use SoftDeletes;
use SoftDeletes, KembalikanStatusPetaniKeDraf;
protected $table = 'praktik_budidaya';
@@ -101,27 +102,11 @@ class PraktikBudidaya extends Model
$jenisHama = $praktikBudidaya->jenis_hama ?? [];
$jenisPenyakit = $praktikBudidaya->jenis_penyakit ?? [];
if (Schema::hasColumn((new Lahan)->getTable(), 'jenis_hama')
&& Schema::hasColumn((new Lahan)->getTable(), 'jenis_penyakit')) {
Lahan::where('petani_id', $praktikBudidaya->petani_id)->get()->each(function (Lahan $lahan) use ($jenisHama, $jenisPenyakit, $praktikBudidaya) {
$lahan->update([
'jenis_hama' => $jenisHama,
'jenis_penyakit' => $jenisPenyakit,
'updated_by' => $praktikBudidaya->updated_by,
]);
});
}
if (Schema::hasColumn((new Tanaman)->getTable(), 'jenis_hama')
&& Schema::hasColumn((new Tanaman)->getTable(), 'jenis_penyakit')) {
Tanaman::where('petani_id', $praktikBudidaya->petani_id)->get()->each(function (Tanaman $tanaman) use ($jenisHama, $jenisPenyakit, $praktikBudidaya) {
$tanaman->update([
'jenis_hama' => $jenisHama,
'jenis_penyakit' => $jenisPenyakit,
'updated_by' => $praktikBudidaya->updated_by,
]);
});
}
// Salin jenis hama/penyakit ke semua lahan & tanaman petani: satu query per tabel
// (sebelumnya satu query per baris; petani bisa punya ratusan titik tanaman).
// Hanya baris yang isinya berbeda yang diubah, supaya updated_at baris lain tidak ikut berubah.
self::sinkronHamaPenyakit(new Lahan, $praktikBudidaya, $jenisHama, $jenisPenyakit);
self::sinkronHamaPenyakit(new Tanaman, $praktikBudidaya, $jenisHama, $jenisPenyakit);
if ($kebiasaan == 0) {
$skor_konservasi_tanah = 1;
@@ -146,6 +131,37 @@ class PraktikBudidaya extends Model
});
}
/** @var array<string, bool> hasil cek kolom per tabel (cukup sekali per proses) */
private static array $punyaKolomHama = [];
private static function sinkronHamaPenyakit(Model $model, PraktikBudidaya $praktikBudidaya, array $jenisHama, array $jenisPenyakit): void
{
$tabel = $model->getTable();
$ada = self::$punyaKolomHama[$tabel] ??= Schema::hasColumns($tabel, ['jenis_hama', 'jenis_penyakit']);
if (! $ada) {
return;
}
// Format JSON sama dengan cast 'array' di model Lahan/Tanaman.
$hama = json_encode($jenisHama);
$penyakit = json_encode($jenisPenyakit);
// Query builder: tidak memicu event per baris. Status petani sudah dikembalikan ke Draf
// oleh event PraktikBudidaya sendiri (trait KembalikanStatusPetaniKeDraf).
$model->newQuery()
->where('petani_id', $praktikBudidaya->petani_id)
// Kolom jsonb: dibandingkan sebagai jsonb (bukan teks) karena PostgreSQL menormalkan formatnya.
->where(fn ($q) => $q
->whereRaw('jenis_hama::jsonb IS DISTINCT FROM ?::jsonb', [$hama])
->orWhereRaw('jenis_penyakit::jsonb IS DISTINCT FROM ?::jsonb', [$penyakit]))
->update([
'jenis_hama' => $hama,
'jenis_penyakit' => $penyakit,
'updated_by' => $praktikBudidaya->updated_by,
'updated_at' => now(),
]);
}
public function petani(): BelongsTo
{
return $this->belongsTo(Petani::class, 'petani_id');
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models\Pertanian;
use App\Models\Concerns\KembalikanStatusPetaniKeDraf;
use App\Enums\SosialEkonomi\AlasanKreditDitolak;
use App\Enums\SosialEkonomi\BisaBeliPupukBersubsidi;
use App\Enums\SosialEkonomi\JenisAsetProduktif;
@@ -51,7 +52,7 @@ use Illuminate\Support\Str;
class SosialEkonomi extends Model
{
use SoftDeletes;
use SoftDeletes, KembalikanStatusPetaniKeDraf;
protected $table = 'sosial_ekonomi';
+5 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models\Pertanian;
use App\Models\Concerns\KembalikanStatusPetaniKeDraf;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -11,7 +12,7 @@ use App\Models\Pertanian\Petani;
class SurveyPanen extends Model
{
use SoftDeletes;
use SoftDeletes, KembalikanStatusPetaniKeDraf;
protected $table = 'survey_panen';
@@ -64,6 +65,9 @@ class SurveyPanen extends Model
$skor->update([
'skor_d2_k3_1_penentuan_pematangan' => $survey->dasar_penentuan_panen,
'skor_d2_k3_5_sortasi_awal_pemisahan_kualitas' => $survey->praktik_sortasi,
// Hambatan mutu = "Tingkat hasil panen ditolak pembeli" (TingkatPanenReject, 1–5).
// Kolomnya bernama hasil_panen_reject, tetapi isinya tingkat reject.
'skor_d6_k5_1_hambatan_mutu' => $survey->hasil_panen_reject,
]);
});
}
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models\Pertanian;
use App\Models\Concerns\KembalikanStatusPetaniKeDraf;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -11,7 +12,7 @@ use App\Models\Pertanian\Petani;
class SurveyPelatihan extends Model
{
use HasFactory, SoftDeletes;
use HasFactory, SoftDeletes, KembalikanStatusPetaniKeDraf;
protected $table = 'survey_pelatihan';
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models\Pertanian;
use App\Models\Concerns\KembalikanStatusPetaniKeDraf;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SurveyProduktivitas extends Model
{
use SoftDeletes;
use SoftDeletes, KembalikanStatusPetaniKeDraf;
protected $table = 'survey_produktivitas';
+2 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models\Pertanian;
use App\Models\Concerns\KembalikanStatusPetaniKeDraf;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SurveySertifikasi extends Model
{
use HasFactory, SoftDeletes;
use HasFactory, SoftDeletes, KembalikanStatusPetaniKeDraf;
protected $table = 'survey_sertifikasi';
+9 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models\Pertanian;
use App\Models\Concerns\KembalikanStatusPetaniKeDraf;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Models\Profile;
@@ -17,7 +18,7 @@ use App\Enums\KeparahanSerangan;
class Tanaman extends Model
{
use SoftDeletes;
use SoftDeletes, KembalikanStatusPetaniKeDraf;
protected $table = 'map_tanaman';
@@ -80,4 +81,11 @@ class Tanaman extends Model
{
return $this->belongsTo(Lahan::class, 'lahan_id');
}
// Untuk trait KembalikanStatusPetaniKeDraf: petani diambil dari lahannya.
protected function petaniIdUntukStatus(): ?int
{
return $this->petani_id ?? self::ingatPerRequest('status_petani.lahan', $this->lahan_id,
fn () => Lahan::withoutGlobalScopes()->whereKey($this->lahan_id)->value('petani_id'));
}
}
+39
View File
@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Laravel\Sanctum\HasApiTokens;
use App\Models\Konfigurasi\KoordinatorKomoditas;
use App\Models\Pertanian\Petani;
class User extends Authenticatable
@@ -52,4 +53,42 @@ class User extends Authenticatable
{
return $this->hasMany(Petani::class, 'created_by');
}
protected static function booted(): void
{
static::deleting(function (User $user) {
if ($user->isForceDeleting()) {
return;
}
$user->koordinatorKomoditas()->get()->each->delete();
});
}
public function koordinatorKomoditas(): HasMany
{
return $this->hasMany(KoordinatorKomoditas::class, 'user_id');
}
public function isKoordinator(): bool
{
return $this->role === Role::KOORDINATOR;
}
// Daftar id komoditas yang ditugaskan ke koordinator ini.
public function komoditasKoordinatorIds(): array
{
return $this->koordinatorKomoditas()->pluck('komoditas_id')->all();
}
// Koordinator hanya boleh mengakses petani dengan komoditas utama yang ditugaskan.
// Role lain tidak dibatasi di sini (aturannya tetap di masing-masing controller).
public function bolehAksesPetani(Petani $petani): bool
{
if (! $this->isKoordinator()) {
return true;
}
return in_array($petani->komoditas_id, $this->komoditasKoordinatorIds());
}
}
+8
View File
@@ -54,6 +54,14 @@ class AppServiceProvider extends ServiceProvider
return $user->role === Role::ADMIN;
});
Gate::define('koordinator', function (User $user) {
return $user->role === Role::KOORDINATOR;
});
Gate::define('enumerator', function (User $user) {
return $user->role === Role::ENUMERATOR;
});
Gate::define('fasilitator', function (User $user) {
return $user->role === Role::FASILITATOR;
});
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Services;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Support\Facades\Http;
use Throwable;
class AlphaVantageMarket
{
public function quotes(Repository $cache): array
{
if (!$key = config('services.alpha_vantage.api_key')) {
return [];
}
// Retain existing cache keys so cleanup does not spend another API request.
$cacheKey = 'market.additional.v1.alpha:COFFEE';
$lastKey = 'market.additional.last.v1.alpha:COFFEE';
if (is_array($quotes = $cache->get($cacheKey))) {
return $quotes;
}
try {
$response = Http::acceptJson()
->withOptions(['verify' => config('services.yahoo_finance.ca_bundle') ?: true])
->connectTimeout(3)->timeout(6)
->get('https://www.alphavantage.co/query', [
'function' => 'COFFEE', 'interval' => 'monthly', 'apikey' => $key,
])->throw();
$data = $response->json();
if (!is_array($data) || ($data['interval'] ?? null) !== 'monthly'
|| strtolower($data['unit'] ?? '') !== 'cents per pound' || !is_array($data['data'] ?? null)) {
throw new \RuntimeException('Invalid Alpha Vantage response');
}
$latest = null;
foreach ($data['data'] as $row) {
if (!is_array($row) || !is_numeric($row['value'] ?? null) || !is_finite((float) $row['value']) || $row['value'] <= 0
|| !is_string($row['date'] ?? null) || !preg_match('/^\d{4}-(0[1-9]|1[0-2])-01$/', $row['date'])) {
continue;
}
if (!$latest || $row['date'] > $latest['date']) $latest = $row;
}
if (!$latest) throw new \RuntimeException('No valid Alpha Vantage price');
$quote = [
'id' => 'alpha:COFFEE', 'name' => 'Kopi Arabica',
'source' => 'Alpha Vantage (data IMF via FRED)',
'source_url' => 'https://www.alphavantage.co/documentation/#coffee',
'price' => (float) $latest['value'], 'currency' => 'USX', 'unit' => 'sen AS / lb',
'period' => substr($latest['date'], 0, 7), 'frequency' => 'monthly', 'stale' => false,
'description' => 'Biji kopi Arabica (Other Milds). Estimasi konversi harga acuan rata-rata bulanan IMF melalui Alpha Vantage.',
'update_info' => 'Data Alpha Vantage diperiksa setiap hari untuk menjaga kuota API. Harga merupakan rata-rata bulanan dari IMF via FRED.',
];
$cache->put($lastKey, $quote, now()->addDays(30));
$cache->put($cacheKey, [$quote], 86400);
return [$quote];
} catch (Throwable) {
// Request exceptions can contain the API key in their URL; do not log them.
$quotes = [];
if (is_array($last = $cache->get($lastKey))) {
$last['stale'] = true;
$quotes[] = $last;
}
$cache->put($cacheKey, $quotes, 3600);
return $quotes;
}
}
}
@@ -0,0 +1,845 @@
<?php
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)
* dan PERLU (dipakai dashboard Karakteristik, Produksi, Pasar).
*
* Satu-satunya tempat daftar ini ditulis. Dipakai oleh ringkasan kelengkapan
* di atas setiap tab petani, dan nanti oleh fitur verifikasi.
*
* Isian yang tidak berlaku (mis. jenis hama kalau "tidak ada hama",
* data kelompok kalau bukan anggota) tidak dimasukkan.
*/
class KelengkapanPetani
{
public const WAJIB = 'wajib';
public const PERLU = 'perlu';
public const TAB_LABEL = [
'profil' => 'Profil',
'lahan' => 'Lahan & Tanaman',
'praktik-budidaya' => 'Praktik Budidaya',
'sosial-ekonomi' => 'Sosial Ekonomi',
'akses-pasar' => 'Akses Pasar',
];
private const KONSERVASI_TIDAK_ADA = 'Tidak ada yang seperti itu di kebun saya';
/*
* Seluruh isian Profil Petani (total 207 untuk 1 lahan), per tabel.
* Aturan hitung: satu pertanyaan = satu isian; kolom repeater dihitung sekali per kolom;
* teks "Lainnya" tidak dihitung; provinsi/kabupaten/kecamatan ikut terisi bila desa terisi.
*/
private const ISIAN_PROFIL = [ // 15 + 3 wilayah = 18
'nama', 'nomor_telepon', 'alamat', 'kk', 'ktp', 'path_kk', 'path_ktp', 'foto',
'jenis_kelamin', 'tempat_lahir', 'tanggal_lahir', 'pendidikan_terakhir', 'status_perkawinan',
'komoditas_id', 'desa_kelurahan_id',
];
private const ISIAN_LAHAN = [ // 16 + 3 wilayah = 19, per lahan
'desa_kelurahan_id', 'path', 'luas_lahan', 'komoditas_id', 'jenis_komoditas_id', 'varietas_id',
'jumlah_tanaman', 'kondisi_tanaman', 'keparahan_serangan',
'jenis_hama', 'jenis_penyakit', 'umur_tanaman', 'umur_lahan', 'komoditas_lain', 'tanaman_penaung', 'foto',
];
private const ISIAN_PRAKTIK_BUDIDAYA = [ // 39
'tenaga_kerja', 'frekuensi_pemangkasan_utama', 'teknik_pemangkasan', 'frekuensi_pemangkasan_penaung',
'penyerbukan_manual', 'deskripsi_penyerbukan_manual', 'tingkat_keberhasilan_penyerbukan',
'pemberian_pupuk', 'jenis_pupuk', 'frekuensi_pemberian_pupuk', 'estimasi_dosis_pupuk_organik',
'estimasi_dosis_pupuk_anorganik', 'waktu_pemberian_pupuk', 'cara_pemberian_pupuk',
'metode_penanganan_opt', 'jenis_hama', 'jenis_penyakit', 'intensitas_serangan', 'waktu_kemunculan_terparah',
'metode_paling_sering_dilakukan', 'volume_obat_obatan_padat_permusim', 'volume_obat_obatan_cair_permusim',
'frekuensi_sanitasi_lahan', 'penanganan_sisa_buah_ranting', 'penanganan_gulma',
'kondisi_pengelolaan_air', 'sumber_air', 'sistem_irigasi', 'kebutuhan_air_permusim',
'kebiasaan_konservasi_tanah', 'pemahaman_manfaat_konservasi_tanah',
'upah_tenaga_kerja', 'biaya_bibit', 'biaya_pupuk', 'biaya_obat_obatan', 'biaya_sewa_alat_produksi',
'biaya_penyediaan_air', 'biaya_produksi_lainnya', 'total_estimasi_biaya_produksi',
];
private const ISIAN_PANEN = [ // 21
'dasar_penentuan_panen', 'frekuensi_panen_pertahun', 'bulan_panen_utama', 'hasil_komoditas_utama_permusim',
'hasil_komoditas_lainnya_permusim', 'disimpan_untuk_sendiri', 'hasil_panen_disimpan', 'tujuan_disimpan',
'praktik_sortasi', 'bentuk_sortasi', 'hasil_panen_reject', 'jumlah_panen_reject', 'tindakan_panen_reject',
'upah_tenaga_panen', 'biaya_pengangkutan_panen', 'biaya_wadah_panen', 'biaya_sewa_alat_panen',
'biaya_panen_lainnya', 'total_estimasi_biaya_panen', 'estimasi_kehilangan_akibat_opt',
'estimasi_kehilangan_akibat_hama_penyakit',
];
private const ISIAN_PRODUKTIVITAS = ['komoditas_id', 'kuantitas', 'satuan']; // 3 (repeater)
private const ISIAN_PELATIHAN = ['jenis_pelatihan', 'penyelenggara', 'tahun_pelaksanaan']; // 3 (repeater)
private const ISIAN_SERTIFIKASI = ['jenis_sertifikasi', 'lembaga_penerbit', 'tahun_pelaksanaan', 'is_aktif']; // 4 (repeater)
private const ISIAN_SOSIAL_EKONOMI = [ // 70 + 4 bantuan NGO + 4 pendapatan komoditas + 2 pendapatan luar = 80
'jumlah_tanggungan', 'anak_masih_sekolah', 'yang_ikut_bekerja', 'skor_periode_paceklik', 'bulan_paceklik',
'sumber_penghasilan_paceklik', 'total_estimasi_biaya_kebun_per_musim', 'skor_kemampuan_menabung',
'estimasi_tabungan_per_bulan', 'skor_kecukupan_penghasilan', 'alasan_kecukupan_penghasilan',
'status_kepemilikan_lahan', 'skor_kejelasan_status_lahan', 'ada_sertifikat_dokumen', 'ada_sengketa',
'keterangan_sengketa', 'pihak_membantu_konflik', 'pihak_membantu_konflik_nama', 'tahun_bantuan_konflik',
'status_konflik', 'kemauan_mencoba_praktik_baru', 'alasan_mencoba_praktik_baru',
'hambatan_mengubah_cara_bertani', 'pemahaman_mutu_harga', 'contoh_pemahaman_mutu_harga',
'pengetahuan_harga_hasil_olah', 'pernah_jual_harga_lebih_tinggi', 'penyebab_harga_lebih_tinggi',
'usaha_mutu_tambahan_harga', 'alasan_usaha_mutu_tambahan_harga', 'panutan_petani_sukses',
'nama_lokasi_panutan', 'kemauan_ikut_program', 'harapan_program', 'orientasi_rencana_jangka_panjang',
'rencana_jangka_panjang', 'terdaftar_kelompok_tani', 'nama_kelompok_tani', 'lama_bergabung_kelompok_tahun',
'kedekatan_sesama_anggota', 'keaktifan_pertemuan_kelompok', 'topik_biasa_dibahas', 'peran_dalam_kelompok',
'manfaat_kelompok_tani', 'status_kartu_tani', 'bisa_beli_pupuk_bersubsidi', 'manfaat_lain_kartu_tani',
'status_rekening_tabungan', 'nama_bank', 'pengalaman_kredit_kebun', 'alasan_kredit_ditolak',
'kondisi_aset_produktif', 'jenis_aset_produktif', 'keterangan_kondisi_aset',
'keterlibatan_program_keuangan_formal', 'status_program_keuangan_formal', 'pernah_bantuan_ngo_perusahaan',
'keterikatan_hutang_panen', 'pinjaman_tokoh_adat_panen', 'keterangan_pinjaman_tokoh_adat',
'total_hutang_rupiah', 'total_hutang_setara_kg_panen', 'total_hutang_tidak_bersedia', 'praktik_ijon',
'tekanan_jual_segera', 'penyebab_tekanan_jual_segera', 'kebebasan_memilih_pembeli',
'alasan_tidak_bebas_memilih_pembeli', 'minat_pembeli_harga_stabil', 'alasan_minat_pembeli_harga_stabil',
];
private const ISIAN_BANTUAN_NGO = ['nama_lembaga', 'bentuk_bantuan', 'frekuensi', 'tahun_terakhir'];
private const ISIAN_PENDAPATAN_KOMODITAS = ['kategori', 'komoditas', 'pendapatan', 'periode'];
private const ISIAN_PENDAPATAN_LUAR = ['jenis_pekerjaan', 'estimasi_per_bulan'];
private const ISIAN_AKSES_PASAR = [ // 16 + 4 harga jual = 20
'tujuan_penjualan', 'sifat_hubungan_bisnis', 'bentuk_penjualan', 'frekuensi_penjualan', 'skor_pengalaman',
'saluran_terdahulu', 'alasan_berhenti', 'tahu_harga_sebelumnya', 'sumber_info_harga', 'praktik_fermentasi',
'proses_fermentasi', 'model_fermentasi_memungkinkan', 'skor_kesadaran_premium', 'sumber_info_premium',
'tingkat_kepercayaan_pembeli_baru', 'alasan_kepercayaan_pembeli_baru',
];
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(self::RELASI);
}
public static function untuk(Petani $petani): self
{
self::$cache ??= new \WeakMap();
return self::$cache[$petani] ??= new self($petani);
}
/** Jumlah isian WAJIB (skor) yang masih kosong di semua tab. */
public function jumlahWajibKosong(): int
{
return $this->semuaKosong()->flatten(1)->where('prioritas', self::WAJIB)->count();
}
/**
* Hitungan seluruh isian: terisi, total (207 untuk 1 lahan; +19 per lahan tambahan),
* tidak_berlaku (pertanyaan lanjutan yang tidak perlu dijawab), berlaku, persen.
*/
public function hitungIsian(): array
{
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,
'lainnya' => ['terisi' => 0, 'total' => 0, 'tidak_berlaku' => 0],
];
$p = $this->petani;
// 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->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));
}
// Praktik Budidaya, Panen, Produktivitas
$pb = $p->praktikBudidaya;
$tidakBerlakuPb = [];
if (! $p->isKomoditasVanili()) {
array_push($tidakBerlakuPb, 'penyerbukan_manual', 'deskripsi_penyerbukan_manual', 'tingkat_keberhasilan_penyerbukan');
}
if ((int) $pb?->pemberian_pupuk === PemberianPupuk::TIDAK_PERNAH->value) {
array_push($tidakBerlakuPb, 'jenis_pupuk', 'frekuensi_pemberian_pupuk', 'estimasi_dosis_pupuk_organik',
'estimasi_dosis_pupuk_anorganik', 'waktu_pemberian_pupuk', 'cara_pemberian_pupuk');
}
if ((int) $pb?->metode_penanganan_opt === MetodePenangananHamaPenyakit::TIDAK_ADA->value) {
array_push($tidakBerlakuPb, 'jenis_hama', 'jenis_penyakit');
}
$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);
// Sosial Ekonomi (data kelompok tidak berlaku kalau bukan anggota)
$se = $p->sosialEkonomi;
$tidakBerlakuSe = self::nilaiEnum($se?->terdaftar_kelompok_tani) === TerdaftarKelompokTani::TIDAK->value
? ['nama_kelompok_tani', 'lama_bergabung_kelompok_tahun', 'kedekatan_sesama_anggota',
'keaktifan_pertemuan_kelompok', 'topik_biasa_dibahas', 'peran_dalam_kelompok', 'manfaat_kelompok_tani']
: [];
$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, self::PRIORITAS_PENDAPATAN_KOMODITAS);
$this->hitungRepeater($hasil, $se?->pendapatanLuarPertanian ?? collect(), self::ISIAN_PENDAPATAN_LUAR);
// Akses Pasar (detail fermentasi tidak berlaku kalau belum pernah)
$ap = $p->aksesPasar;
$tidakBerlakuAp = self::nilaiEnum($ap?->praktik_fermentasi) === PraktikFermentasi::BELUM_PERNAH->value
? ['proses_fermentasi', 'model_fermentasi_memungkinkan']
: [];
$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;
return $hasil;
}
private static function nilaiEnum(mixed $nilai): mixed
{
return $nilai instanceof \BackedEnum ? $nilai->value : $nilai;
}
/**
* $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) {
$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
{
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, array $prioritas = []): void
{
foreach ($kolom as $k) {
$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),
* grup (judul pengelompokan, mis. nama lahan), lahan_id, url (opsional, halaman lain).
*/
public function kosong(string $tab): Collection
{
return $this->semuaKosong()->get($tab, collect());
}
/** Isian kosong semua tab, dikelompokkan per tab (untuk tab Kesiapan Adopsi). */
public function semuaKosong(): Collection
{
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(),
])->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), ['nilai' => $nilai, ...$tambahan]);
}
/** Untuk isian yang status terisinya dihitung sendiri (mis. repeater). */
private function itemCek(string $label, string $prioritas, string $input, bool $terisi, array $tambahan = []): array
{
return [
'label' => $label,
'prioritas' => $prioritas,
'input' => $input,
'terisi' => $terisi,
'nilai' => null,
'grup' => null,
'lahan_id' => null,
'url' => null,
...$tambahan,
];
}
private static function terisi(mixed $nilai): bool
{
if ($nilai instanceof Collection) {
return $nilai->isNotEmpty();
}
return ! (is_null($nilai) || $nilai === '' || $nilai === []);
}
private function profil(): array
{
$p = $this->petani;
return [
$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, [
'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'),
]),
];
}
/** Diisi per lahan; setiap item membawa link ke halaman edit lahannya. */
private function lahan(): array
{
$daftarLahan = $this->petani->lahans->sortBy('id');
if ($daftarLahan->isEmpty()) {
return [$this->itemCek('Belum ada lahan', self::PERLU, '', false, [
'url' => route('petani.lahan-tanaman.create', $this->petani->id),
])];
}
return $daftarLahan->values()->flatMap(function (Lahan $l, int $i) {
$tambahan = [
'grup' => $l->nama ?: 'Lahan ' . ($i + 1),
'lahan_id' => $l->id,
'url' => route('petani.lahan-tanaman.edit', [$this->petani->id, $l->id]),
];
// Jenis hama/penyakit lahan terisi otomatis dari Praktik Budidaya; kolom lama serangan_hama/penyakit_tanaman ikut dicek untuk data lama.
$adaSerangan = self::terisi($l->jenis_hama) || self::terisi($l->jenis_penyakit)
|| self::terisi($l->serangan_hama) || self::terisi($l->penyakit_tanaman);
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, '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),
$adaSerangan
? $this->item('Keparahan serangan', self::PERLU, 'keparahan_serangan', $l->keparahan_serangan, $tambahan)
: null,
]);
})->all();
}
private function praktikBudidaya(): array
{
$pb = $this->petani->praktikBudidaya;
$panen = $this->petani->surveyPanen;
$adaHama = $pb && self::terisi($pb->metode_penanganan_opt)
&& (int) $pb->metode_penanganan_opt !== MetodePenangananHamaPenyakit::TIDAK_ADA->value;
$konservasi = (array) ($pb?->kebiasaan_konservasi_tanah ?? []);
$adaKonservasi = collect($konservasi)->contains(fn ($v) => $v !== self::KONSERVASI_TIDAK_ADA);
return array_filter([
$this->item('Frekuensi pemangkasan', self::WAJIB, 'frekuensi_pemangkasan_komoditas_utama', $pb?->frekuensi_pemangkasan_utama),
$this->item('Pemberian pupuk', self::WAJIB, 'pemberian_pupuk', $pb?->pemberian_pupuk),
$this->item('Metode penanganan hama & penyakit', self::WAJIB, 'metode_penanganan_hama_penyakit', $pb?->metode_penanganan_opt),
$adaHama ? $this->item('Jenis hama', self::PERLU, 'jenis_hama', $pb->jenis_hama) : null,
$adaHama ? $this->item('Jenis penyakit', self::PERLU, 'jenis_penyakit', $pb->jenis_penyakit) : null,
$this->item('Frekuensi sanitasi lahan', self::WAJIB, 'frekuensi_sanitasi_lahan', $pb?->frekuensi_sanitasi_lahan),
$this->item('Penanganan gulma', self::WAJIB, 'penanganan_gulma', $pb?->penanganan_gulma),
$this->item('Kondisi pengelolaan air & irigasi', self::WAJIB, 'kondisi_pengelolaan_air_irigasi', $pb?->kondisi_pengelolaan_air),
$this->item('Sumber air', self::PERLU, 'sumber_air', $pb?->sumber_air),
$this->item('Kebiasaan konservasi tanah', self::WAJIB, 'kebiasaan_konservasi_tanah', $konservasi),
$adaKonservasi
? $this->item('Pemahaman manfaat konservasi', self::WAJIB, 'pemahaman_manfaat_konservasi', $pb->pemahaman_manfaat_konservasi_tanah)
: null,
$this->item('Total estimasi biaya produksi', self::WAJIB, 'total_estimasi_biaya_produksi', $pb?->total_estimasi_biaya_produksi),
$this->item('Dasar penentuan panen', self::WAJIB, 'dasar_penentuan_panen', $panen?->dasar_penentuan_panen),
$this->item('Hasil komoditas utama per musim', self::PERLU, 'hasil_komoditas_utama_permusim', $panen?->hasil_komoditas_utama_permusim),
$this->item('Praktik sortasi', self::WAJIB, 'praktik_sortasi', $panen?->praktik_sortasi),
$this->item('Tingkat hasil panen ditolak pembeli', self::WAJIB, 'tingkat_reject', $panen?->hasil_panen_reject),
]);
}
private function sosialEkonomi(): array
{
$se = $this->petani->sosialEkonomi;
$anggotaKelompok = $se?->terdaftar_kelompok_tani === TerdaftarKelompokTani::YA
|| $se?->terdaftar_kelompok_tani === TerdaftarKelompokTani::YA->value;
$adaPendapatan = (bool) $se?->pendapatanKomoditas->contains(fn ($row) => (int) $row->pendapatan > 0);
return array_filter([
// Utama
$this->item('Jumlah tanggungan', self::PERLU, 'jumlah_tanggungan', $se?->jumlah_tanggungan),
$this->itemCek('Pendapatan per komoditas', self::WAJIB, 'pendapatan_komoditas', $adaPendapatan),
$this->item('Periode paceklik', self::WAJIB, 'skor_periode_paceklik', $se?->skor_periode_paceklik),
$this->item('Kemampuan menabung', self::WAJIB, 'skor_kemampuan_menabung', $se?->skor_kemampuan_menabung),
$this->item('Kecukupan penghasilan', self::WAJIB, 'skor_kecukupan_penghasilan', $se?->skor_kecukupan_penghasilan),
$this->item('Kejelasan status lahan', self::WAJIB, 'skor_kejelasan_status_lahan', $se?->skor_kejelasan_status_lahan),
// Sosial budaya
$this->item('Kemauan mencoba praktik baru', self::WAJIB, 'kemauan_mencoba_praktik_baru', $se?->kemauan_mencoba_praktik_baru),
$this->item('Pemahaman mutu & harga', self::WAJIB, 'pemahaman_mutu_harga', $se?->pemahaman_mutu_harga),
$this->item('Panutan petani sukses', self::WAJIB, 'panutan_petani_sukses', $se?->panutan_petani_sukses),
$this->item('Kemauan ikut program', self::WAJIB, 'kemauan_ikut_program', $se?->kemauan_ikut_program),
$this->item('Orientasi rencana jangka panjang', self::WAJIB, 'orientasi_rencana_jangka_panjang', $se?->orientasi_rencana_jangka_panjang),
// Keanggotaan kelompok: pertanyaan lanjutan hanya untuk anggota
$this->item('Terdaftar di kelompok tani', self::WAJIB, 'terdaftar_kelompok_tani', $se?->terdaftar_kelompok_tani),
$anggotaKelompok ? $this->item('Keaktifan pertemuan kelompok', self::WAJIB, 'keaktifan_pertemuan_kelompok', $se->keaktifan_pertemuan_kelompok) : null,
$anggotaKelompok ? $this->item('Peran dalam kelompok', self::WAJIB, 'peran_dalam_kelompok', $se->peran_dalam_kelompok) : null,
$anggotaKelompok ? $this->item('Manfaat kelompok tani', self::WAJIB, 'manfaat_kelompok_tani', $se->manfaat_kelompok_tani) : null,
// Akses keuangan
$this->item('Status rekening tabungan', self::WAJIB, 'status_rekening_tabungan', $se?->status_rekening_tabungan),
$this->item('Pengalaman kredit kebun', self::WAJIB, 'pengalaman_kredit_kebun', $se?->pengalaman_kredit_kebun),
$this->item('Kondisi aset produktif', self::WAJIB, 'kondisi_aset_produktif', $se?->kondisi_aset_produktif),
$this->item('Keterlibatan program keuangan formal', self::WAJIB, 'keterlibatan_program_keuangan_formal', $se?->keterlibatan_program_keuangan_formal),
// Keterikatan hutang
$this->item('Keterikatan hutang panen', self::WAJIB, 'keterikatan_hutang_panen', $se?->keterikatan_hutang_panen),
$this->item('Praktik ijon', self::WAJIB, 'praktik_ijon', $se?->praktik_ijon),
$this->item('Kebebasan memilih pembeli', self::WAJIB, 'kebebasan_memilih_pembeli', $se?->kebebasan_memilih_pembeli),
]);
}
private function aksesPasar(): array
{
$ap = $this->petani->aksesPasar;
$harga = $this->petani->hargaJualPanen;
return [
$this->item('Tujuan penjualan', self::PERLU, 'tujuan_penjualan', $ap?->tujuan_penjualan),
$this->item('Sifat hubungan dengan pembeli', self::PERLU, 'sifat_hubungan_bisnis', $ap?->sifat_hubungan_bisnis),
$this->item('Frekuensi penjualan', self::PERLU, 'frekuensi_penjualan', $ap?->frekuensi_penjualan),
$this->item('Pengalaman menjual ke saluran lain', self::WAJIB, 'skor_pengalaman', $ap?->skor_pengalaman),
$this->item('Praktik fermentasi', self::PERLU, 'praktik_fermentasi', $ap?->praktik_fermentasi),
$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),
['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),
['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),
['jawaban' => fn () => self::jawabanHarga($harga, 'harga_dua_tahun_lalu')]),
];
}
}
+4
View File
@@ -15,6 +15,10 @@ return Application::configure(basePath: dirname(__DIR__))
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->append(\App\Http\Middleware\ResolveProgram::class);
$middleware->alias([
'akses.petani' => \App\Http\Middleware\BatasiAksesPetani::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
+6
View File
@@ -14,6 +14,12 @@ return [
|
*/
'alpha_vantage' => ['api_key' => env('ALPHA_VANTAGE_API_KEY')],
'yahoo_finance' => [
'ca_bundle' => env('YAHOO_FINANCE_CA_BUNDLE'),
],
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
@@ -0,0 +1,142 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('survey_kelompok_tani', function (Blueprint $table) {
$table->id();
$table->string('uid')->unique();
// Data Responden
$table->string('nama_responden')->nullable();
$table->string('jabatan_responden')->nullable();
$table->string('kontak_responden')->nullable();
// Identitas Kelompok Tani
$table->string('nama_kelompok')->nullable();
$table->foreignId('desa_kelurahan_id')->nullable()->constrained('master_desa_kelurahan');
$table->string('dusun')->nullable();
$table->year('tahun_berdiri')->nullable();
$table->string('jenis_kelompok', 50)->nullable();
$table->string('nama_gapoktan')->nullable();
$table->jsonb('komoditas_id')->nullable();
// Pengurus & Anggota Kelompok
$table->unsignedInteger('jumlah_pengurus')->nullable();
$table->unsignedInteger('jumlah_pengurus_aktif')->nullable();
$table->unsignedInteger('jumlah_anggota_terdaftar')->nullable();
$table->unsignedInteger('jumlah_anggota_aktif')->nullable();
$table->unsignedInteger('jumlah_pengurus_laki_laki')->nullable();
$table->unsignedInteger('jumlah_pengurus_perempuan')->nullable();
$table->unsignedInteger('jumlah_anggota_laki_laki')->nullable();
$table->unsignedInteger('jumlah_anggota_perempuan')->nullable();
$table->unsignedInteger('jumlah_anggota_pemuda_laki_laki')->nullable();
$table->unsignedInteger('jumlah_anggota_pemuda_perempuan')->nullable();
// Cakupan Wilayah Kelompok
$table->string('cakupan_wilayah')->nullable();
$table->text('nama_wilayah_cakupan')->nullable();
// Status Legalitas Kelompok
$table->string('status_legalitas', 50)->nullable();
$table->string('nomor_sk', 100)->nullable();
$table->string('tanggal_tahun_penerbitan_sk', 30)->nullable();
// Kualitas Kepemimpinan
$table->string('tingkat_keaktifan_ketua', 30)->nullable();
$table->string('status_pembagian_peran', 30)->nullable();
$table->string('status_dokumentasi_rapat', 30)->nullable();
// Frekuensi Pertemuan Rutin
$table->string('frekuensi_pertemuan', 50)->nullable();
$table->date('tanggal_pertemuan_terakhir')->nullable();
// Kegiatan Kongkret yang Sudah Berjalan
$table->jsonb('kegiatan_terlaksana')->nullable();
$table->decimal('estimasi_panen_minimal', 10, 2)->nullable();
$table->decimal('estimasi_panen_maksimal', 10, 2)->nullable();
$table->string('satuan_hasil_panen')->nullable();
$table->jsonb('tindakan_hasil_panen')->nullable();
// Peran & Fungsi Kelompok
$table->jsonb('fungsi_kelompok_bagi_anggota')->nullable();
// Kapasitas Pengelolaan Kas/Simpan-Pinjam Kelompok
$table->string('pembukuan_kas', 50)->nullable();
$table->string('pengelola_kas')->nullable();
$table->string('masalah_pengelolaan_kas_simpan_pinjam')->nullable();
$table->text('deskripsi_masalah_pengelolaan_kas_simpan_pinjam')->nullable();
// Keterlibatan Perempuan
$table->string('partisipasi_perempuan')->nullable();
// Riwayat konflik Internal Kelompok
$table->string('konflik_kelompok')->nullable();
$table->string('topik_konflik')->nullable();
$table->text('penyelesaian_konflik')->nullable();
// Pengalaman Akses Bantuan
$table->boolean('pernah_mendapatkan_bantuan')->nullable();
// Postensi Sebagai Mitra Agregasi/Pengolahan Kolektif
$table->string('potensi_kelompok')->nullable();
$table->text('alasan_penilaian_potensi')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
Schema::create('survey_pengurus_kelompok_tani', function (Blueprint $table) {
$table->id();
$table->string('uid')->unique();
$table->foreignId('survey_kelompok_tani_id')->nullable()->constrained('survey_kelompok_tani')->cascadeOnUpdate()->cascadeOnDelete();
$table->string('nama_pengurus');
$table->string('jabatan');
$table->string('kontak');
$table->boolean('status');
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
Schema::create('survey_aset_kelompok_tani', function (Blueprint $table) {
$table->id();
$table->string('uid')->unique();
$table->foreignId('survey_kelompok_tani_id')->nullable()->constrained('survey_kelompok_tani')->cascadeOnUpdate()->cascadeOnDelete();
$table->string('jenis_aset');
$table->boolean('status_aset');
$table->string('status_pengelolaan_aset');
$table->string('kondisi_aset');
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
Schema::create('survey_bantuan_kelompok_tani', function (Blueprint $table) {
$table->id();
$table->string('uid')->unique();
$table->foreignId('survey_kelompok_tani_id')->nullable()->constrained('survey_kelompok_tani')->cascadeOnUpdate()->cascadeOnDelete();
$table->string('nama_program');
$table->string('penyelenggara');
$table->string('bentuk_bantuan');
$table->year('tahun');
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('survey_bantuan_kelompok_tani');
Schema::dropIfExists('survey_aset_kelompok_tani');
Schema::dropIfExists('survey_pengurus_kelompok_tani');
Schema::dropIfExists('survey_kelompok_tani');
}
};
@@ -0,0 +1,144 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('pengurus_koperasi', function (Blueprint $table) {
$table->id();
// --- PROFIL DASAR ---
$table->string('nama_lengkap');
$table->string('posisi_dalam_koperasi');
$table->integer('tahun_bergabung')->nullable();
$table->string('no_hp', 20)->nullable();
$table->text('alamat_tinggal')->nullable();
$table->string('nama_koperasi');
$table->text('alamat_koperasi')->nullable();
$table->string('jenis_koperasi');
$table->string('skala_operasi');
$table->integer('tahun_berdiri')->nullable();
// --- ANGGOTA & PENGURUS ---
$table->integer('jumlah_anggota_awal')->nullable();
$table->integer('jumlah_anggota_dewasa')->nullable();
$table->integer('jumlah_anggota_anak')->nullable();
$table->integer('jumlah_pengurus')->nullable();
// --- SHU ---
$table->bigInteger('shu_2025')->nullable();
$table->bigInteger('shu_2024')->nullable();
$table->bigInteger('shu_2023')->nullable();
// --- KELEMBAGAAN & MANAJEMEN ---
$table->text('sejarah_lembaga')->nullable();
$table->string('fokus_komoditas')->nullable();
$table->string('detail_fokus_komoditas')->nullable();
$table->string('ad_art')->nullable();
$table->integer('tahun_ad_art')->nullable();
$table->text('layanan_koperasi')->nullable();
$table->string('layanan_lainnya')->nullable();
// Pelatihan
$table->string('pelatihan_produksi')->nullable();
$table->string('pelatihan_pascapanen')->nullable();
$table->string('pelatihan_pengolahan')->nullable();
$table->string('pelatihan_keuangan')->nullable();
$table->string('pelatihan_pemasaran')->nullable();
$table->string('pelatihan_lainnya')->nullable();
// Operasional
$table->string('status_pembukuan')->nullable();
$table->string('format_pembukuan')->nullable();
$table->string('pertemuan_rutin')->nullable();
$table->string('pertemuan_lainnya')->nullable();
$table->string('kasus_kredit_macet')->nullable();
$table->string('sanksi_kredit_macet')->nullable();
$table->string('efektivitas_sanksi')->nullable();
$table->text('faktor_manajemen')->nullable();
$table->string('faktor_manajemen_lainnya')->nullable();
// Rantai Nilai & Fasilitas Internal
$table->text('keterlibatan_rantai_nilai')->nullable();
$table->text('agregasi_komoditas')->nullable();
$table->string('agregasi_komoditas_lainnya')->nullable();
$table->text('fasilitas_koperasi')->nullable();
$table->string('fasilitas_lainnya')->nullable();
$table->string('pengolahan_lanjutan')->nullable();
$table->string('detail_pengolahan_lanjutan')->nullable();
$table->text('cara_penentuan_harga')->nullable();
$table->string('kontrak_offtaker')->nullable();
$table->string('kontrak_komoditas')->nullable();
// --- AKSES PASAR & RANTAI NILAI EKSTERNAL ---
$table->text('sumber_pasokan')->nullable();
$table->string('sumber_pasokan_lainnya')->nullable();
$table->text('bentuk_pembelian')->nullable();
$table->integer('volume_beli_sekarang')->nullable();
$table->integer('volume_beli_1_tahun')->nullable();
$table->integer('volume_beli_2_tahun')->nullable();
$table->text('tujuan_penjualan')->nullable();
$table->string('tujuan_penjualan_lainnya')->nullable();
$table->text('bentuk_penjualan')->nullable();
$table->integer('volume_jual_sekarang')->nullable();
$table->integer('volume_jual_1_tahun')->nullable();
$table->integer('volume_jual_2_tahun')->nullable();
$table->text('sifat_hubungan_bisnis')->nullable();
$table->string('sifat_hubungan_lainnya')->nullable();
$table->string('mekanisme_pembayaran')->nullable();
$table->string('status_perjanjian_tertulis')->nullable();
// Harga Komoditas
$table->bigInteger('harga_segar_sekarang')->nullable();
$table->bigInteger('harga_segar_1_tahun')->nullable();
$table->bigInteger('harga_segar_2_tahun')->nullable();
$table->bigInteger('harga_basah_sekarang')->nullable();
$table->bigInteger('harga_basah_1_tahun')->nullable();
$table->bigInteger('harga_basah_2_tahun')->nullable();
$table->bigInteger('harga_kering_sekarang')->nullable();
$table->bigInteger('harga_kering_1_tahun')->nullable();
$table->bigInteger('harga_kering_2_tahun')->nullable();
$table->bigInteger('harga_fermentasi_sekarang')->nullable();
$table->bigInteger('harga_fermentasi_1_tahun')->nullable();
$table->bigInteger('harga_fermentasi_2_tahun')->nullable();
$table->text('cara_menetapkan_harga_pasar')->nullable();
$table->string('cara_menetapkan_harga_lainnya')->nullable();
// Praktik Kualitas & Pascapanen
$table->string('praktik_sortasi')->nullable();
$table->string('standar_sortasi')->nullable();
$table->string('praktik_pengeringan')->nullable();
$table->string('cara_pengeringan')->nullable();
$table->string('praktik_fermentasi')->nullable();
$table->text('model_fermentasi')->nullable();
$table->text('proses_fermentasi')->nullable();
$table->bigInteger('harga_premium_rupiah')->nullable();
$table->integer('harga_premium_persen')->nullable();
// --- SOSIAL EKONOMI ---
$table->bigInteger('simpanan_terbesar')->nullable();
$table->string('momen_simpanan_terbesar')->nullable();
$table->text('alasan_pinjaman')->nullable();
$table->string('alasan_pinjaman_lainnya')->nullable();
$table->string('pembayaran_selain_uang')->nullable();
$table->text('bentuk_pembayaran_barang')->nullable();
$table->string('bentuk_pembayaran_lainnya')->nullable();
$table->string('perhitungan_harga_pasar')->nullable();
$table->string('alasan_bukan_harga_pasar')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('pengurus_koperasi');
}
};
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('bumdes_kelembagaan_aktor', function (Blueprint $table) {
$table->id();
$table->foreignId('pengurus_bumdes_id')->unique()->constrained('bumdes_pengurus')->onDelete('cascade');
$table->string('status_badan_hukum')->nullable();
$table->string('nomor_dasar_hukum')->nullable();
$table->unsignedSmallInteger('tahun_dasar_hukum')->nullable();
$table->string('status_keaktifan')->nullable();
$table->text('alasan_tidak_aktif')->nullable();
$table->json('unit_usaha')->nullable();
$table->string('unit_usaha_jasa_lainnya')->nullable();
$table->string('status_unit_komoditas')->nullable();
$table->string('nama_unit_komoditas')->nullable();
$table->string('status_ad_art')->nullable();
$table->unsignedSmallInteger('tahun_ad_art')->nullable();
$table->json('pengurus_inti')->nullable();
$table->boolean('memiliki_staf')->nullable();
$table->unsignedInteger('jumlah_staf')->nullable();
$table->text('unit_usaha_staf')->nullable();
$table->decimal('modal_awal', 18, 2)->nullable();
$table->unsignedSmallInteger('tahun_modal_awal')->nullable();
$table->boolean('pernah_penambahan_modal')->nullable();
$table->json('penambahan_modal')->nullable();
$table->json('aset_produktif')->nullable();
$table->string('aset_produktif_lainnya')->nullable();
$table->string('status_kepemilikan_aset')->nullable();
$table->json('infrastruktur_pascapanen')->nullable();
$table->string('infrastruktur_pascapanen_lainnya')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('bumdes_kelembagaan_aktor');
}
};
@@ -0,0 +1,479 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('riwayat_petani', function (Blueprint $table) {
$table->id();
$table->string('periode_pencatatan')->comment('Penanda waktu snapshot, misal: Q1 2026');
$table->string('uid')->nullable();
$table->foreignId('petani_id')->constrained('petani')->onDelete('cascade');
$table->string('nama');
$table->boolean('jenis_kelamin');
$table->string('tempat_lahir')->nullable();
$table->date('tanggal_lahir');
$table->string('pendidikan_terakhir')->nullable();
$table->string('alamat')->nullable();
$table->foreignId('desa_kelurahan_id')->nullable()->constrained('master_desa_kelurahan');
$table->string('nomor_telepon')->nullable();
$table->string('foto', 500)->nullable();
$table->string('status_perkawinan')->nullable();
$table->string('kk')->nullable();
$table->string('ktp')->nullable();
$table->string('path_kk', 500)->nullable();
$table->string('file_kk')->nullable();
$table->string('path_ktp', 500)->nullable();
$table->string('file_ktp')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
Schema::create('riwayat_map_lahan', function (Blueprint $table) {
$table->id();
$table->string('periode_pencatatan')->comment('Penanda waktu snapshot, misal: Q1 2026');
$table->string('uid')->nullable();
$table->string('nama');
$table->string('status_kepemilikan');
$table->foreignId('profile_id')->constrained('profiles');
$table->foreignId('keluarga_pengelola_id')->nullable()->constrained('keluarga')->onDelete('set null');
$table->foreignId('adat_pemilik_id')->nullable()->constrained('adat')->onDelete('set null');
$table->foreignId('desa_kelurahan_id')->nullable()->constrained('master_desa_kelurahan');
$table->string('lat')->nullable();
$table->string('long')->nullable();
$table->jsonb('path')->nullable();
$table->boolean('sertifikat')->default(false);
$table->boolean('konflik')->default(false);
$table->text('deskripsi')->nullable();
$table->float('luas_lahan', 8)->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
Schema::create('riwayat_sertifikat_lahan', function (Blueprint $table) {
$table->id();
$table->string('periode_pencatatan');
$table->string('uid')->nullable();
$table->string('nomor_sertifikat');
$table->date('tanggal_terbit');
$table->date('tanggal_kadaluarsa')->nullable();
$table->string('lembaga_penerbit')->nullable();
$table->foreignId('lahan_id')->constrained('map_lahan')->onDelete('cascade');
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
Schema::create('riwayat_map_tanaman', function (Blueprint $table) {
$table->id();
$table->string('periode_pencatatan');
$table->string('uid')->nullable();
$table->string('nama');
$table->foreignId('komoditas_id')->constrained('master_komoditas')->onDelete('set null');
$table->foreignId('jenis_komoditas_id')->constrained('jenis_komoditas')->onDelete('set null');
$table->foreignId('varietas_id')->constrained('master_varietas')->onDelete('set null');
$table->foreignId('profile_id')->constrained('profiles')->onDelete('set null');
$table->foreignId('lahan_id')->constrained('map_lahan')->onDelete('set null');
$table->float('latitude')->nullable();
$table->float('longitude')->nullable();
$table->string('kondisi_tanaman')->nullable();
$table->string('metode_pengumpulan')->nullable();
$table->text('deskripsi')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
Schema::create('riwayat_praktik_budidaya', function (Blueprint $table) {
$table->id();
$table->string('periode_pencatatan');
$table->string('uid')->nullable();
$table->foreignID('petani_id')->constrained('petani')->onDelete('cascade');
$table->string('tenaga_kerja')->nullable();
$table->integer('frekuensi_pemangkasan_utama')->nullable();
$table->jsonb('teknik_pemangkasan')->nullable();
$table->string('frekuensi_pemangkasan_penaung')->nullable();
$table->string('penyerbukan_manual')->nullable();
$table->string('deskripsi_penyerbukan_manual')->nullable();
$table->string('tingkat_keberhasilan_penyerbukan')->nullable();
$table->integer('pemberian_pupuk')->nullable();
$table->string('jenis_pupuk')->nullable();
$table->string('frekuensi_pemberian_pupuk')->nullable();
$table->decimal('estimasi_dosis_pupuk_organik', 10, 2)->nullable();
$table->decimal('estimasi_dosis_pupuk_anorganik', 10, 2)->nullable();
$table->string('waktu_pemberian_pupuk')->nullable();
$table->string('cara_pemberian_pupuk')->nullable();
$table->integer('metode_penanganan_opt')->nullable();
$table->jsonb('jenis_hama')->nullable();
$table->jsonb('jenis_penyakit')->nullable();
$table->string('intensitas_serangan')->nullable();
$table->string('waktu_kemunculan_terparah')->nullable();
$table->string('metode_paling_sering_dilakukan')->nullable();
$table->decimal('volume_obat_obatan_padat_permusim', 10, 2)->nullable();
$table->decimal('volume_obat_obatan_cair_permusim', 10, 2)->nullable();
$table->integer('frekuensi_sanitasi_lahan')->nullable();
$table->string('penanganan_sisa_buah_ranting')->nullable();
$table->integer('penanganan_gulma')->nullable();
$table->integer('kondisi_pengelolaan_air')->nullable();
$table->jsonb('sumber_air')->nullable();
$table->string('sistem_irigasi')->nullable();
$table->string('kebutuhan_air_permusim')->nullable();
$table->jsonb('kebiasaan_konservasi_tanah')->nullable();
$table->string('pemahaman_manfaat_konservasi_tanah')->nullable();
$table->integer('nilai_konservasi_tanah')->nullable();
$table->decimal('upah_tenaga_kerja', 13, 2)->nullable();
$table->decimal('biaya_bibit', 13, 2)->nullable();
$table->decimal('biaya_pupuk', 13, 2)->nullable();
$table->decimal('biaya_obat_obatan', 13, 2)->nullable();
$table->decimal('biaya_sewa_alat_produksi', 13, 2)->nullable();
$table->decimal('biaya_penyediaan_air', 13, 2)->nullable();
$table->decimal('biaya_produksi_lainnya', 13, 2)->nullable();
$table->decimal('total_estimasi_biaya_produksi', 13, 2)->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
Schema::create('riwayat_survey_panen', function (Blueprint $table) {
$table->id();
$table->string('periode_pencatatan');
$table->string('uid')->nullable();
$table->foreignId('petani_id')->nullable()->constrained('petani')->onDelete('set null');
$table->integer('dasar_penentuan_panen')->nullable();
$table->integer('frekuensi_panen_pertahun')->nullable();
$table->string('bulan_panen_utama')->nullable();
$table->decimal('hasil_komoditas_utama_permusim', 10, 2)->nullable();
$table->decimal('hasil_komoditas_lainnya_permusim', 10, 2)->nullable();
$table->boolean('disimpan_untuk_sendiri')->nullable();
$table->decimal('hasil_panen_disimpan', 10, 2)->nullable();
$table->string('tujuan_disimpan')->nullable();
$table->integer('praktik_sortasi')->nullable();
$table->string('bentuk_sortasi')->nullable();
$table->integer('hasil_panen_reject')->nullable();
$table->string('jumlah_panen_reject')->nullable();
$table->string('tindakan_panen_reject')->nullable();
$table->decimal('upah_tenaga_panen', 13, 2)->nullable();
$table->decimal('biaya_pengangkutan_panen', 13, 2)->nullable();
$table->decimal('biaya_wadah_Panen', 13, 2)->nullable();
$table->decimal('biaya_sewa_alat_panen', 13, 2)->nullable();
$table->decimal('biaya_panen_lainnya', 13, 2)->nullable();
$table->decimal('total_estimasi_biaya_panen', 13, 2)->nullable();
$table->string('estimasi_kehilangan_akibat_opt')->nullable();
$table->string('estimasi_kehilangan_akibat_hama_penyakit')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
Schema::create('riwayat_survey_produktivitas', function (Blueprint $table) {
$table->id();
$table->string('periode_pencatatan');
$table->string('uid')->nullable();
$table->foreignId('petani_id')->nullable()->constrained('petani')->onDelete('set null');
$table->foreignId('komoditas_id')->nullable()->constrained('master_komoditas')->onDelete('set null');
$table->decimal('kuantitas')->nullable();
$table->string('satuan')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
Schema::create('riwayat_sosial_ekonomi', function (Blueprint $table) {
$table->id();
$table->string('periode_pencatatan');
$table->string('uid')->nullable();
$table->foreignId('petani_id')->nullable()->constrained('petani')->onDelete('cascade');
$table->integer('jumlah_tanggungan')->nullable();
$table->integer('anak_masih_sekolah')->nullable();
$table->integer('yang_ikut_bekerja')->nullable();
$table->tinyInteger('skor_periode_paceklik')->nullable();
$table->string('bulan_paceklik')->nullable();
$table->text('sumber_penghasilan_paceklik')->nullable();
$table->unsignedBigInteger('total_estimasi_biaya_kebun_per_musim')->nullable();
$table->tinyInteger('skor_kemampuan_menabung')->nullable();
$table->unsignedBigInteger('estimasi_tabungan_per_bulan')->nullable();
$table->tinyInteger('skor_kecukupan_penghasilan')->nullable();
$table->text('alasan_kecukupan_penghasilan')->nullable();
$table->string('status_kepemilikan_lahan')->nullable();
$table->tinyInteger('skor_kejelasan_status_lahan')->nullable();
$table->boolean('ada_sertifikat_dokumen')->nullable();
$table->boolean('ada_sengketa')->nullable();
$table->text('keterangan_sengketa')->nullable();
$table->string('pihak_membantu_konflik')->nullable();
$table->string('pihak_membantu_konflik_nama')->nullable();
$table->year('tahun_bantuan_konflik')->nullable();
$table->string('status_konflik')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
Schema::create('riwayat_pendapatan_komoditas', function (Blueprint $table) {
$table->id();
$table->string('periode_pencatatan');
$table->string('uid')->nullable();
$table->foreignId('sosial_ekonomi_id')->constrained('sosial_ekonomi')->onDelete('cascade');
$table->string('kategori')->comment('utama / lainnya');
$table->string('komoditas')->nullable();
$table->unsignedBigInteger('pendapatan')->nullable();
$table->string('periode')->nullable()->comment('musim / tahun');
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
});
Schema::create('riwayat_pendapatan_luar_pertanian', function (Blueprint $table) {
$table->id();
$table->string('periode_pencatatan');
$table->string('uid')->nullable();
$table->foreignId('sosial_ekonomi_id')->constrained('sosial_ekonomi')->onDelete('cascade');
$table->string('jenis_pekerjaan')->nullable();
$table->unsignedBigInteger('estimasi_per_bulan')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
});
Schema::create('riwayat_akses_pasar', function (Blueprint $table) {
$table->id();
$table->string('periode_pencatatan');
$table->string('uid')->nullable();
$table->foreignId('petani_id')->nullable()->constrained('petani')->onDelete('cascade');
$table->tinyInteger('skor_pengalaman')->nullable();
$table->json('saluran_terdahulu')->nullable();
$table->string('saluran_terdahulu_lainnya')->nullable();
$table->string('alasan_berhenti')->nullable();
$table->string('praktik_fermentasi')->nullable();
$table->text('proses_fermentasi')->nullable();
$table->string('model_fermentasi_memungkinkan')->nullable();
$table->tinyInteger('skor_kesadaran_premium')->nullable();
$table->string('sumber_info_premium')->nullable();
$table->time('waktu_mulai_wawancara')->nullable();
$table->time('waktu_selesai_wawancara')->nullable();
$table->tinyInteger('skor_kepercayaan_responden')->nullable();
$table->string('kehadiran_orang_lain')->nullable();
$table->tinyInteger('skor_pengaruh_orang_lain')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
Schema::create('riwayat_harga_jual_panen', function (Blueprint $table) {
$table->id();
$table->string('periode_pencatatan');
$table->string('uid')->nullable();
$table->foreignId('petani_id')->constrained('petani')->onDelete('cascade');
$table->string('bentuk_produk');
$table->integer('harga_panen_terakhir')->nullable();
$table->integer('harga_tahun_lalu')->nullable();
$table->integer('harga_dua_tahun_lalu')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
});
Schema::create('riwayat_survey_pelatihan', function (Blueprint $table) {
$table->id();
$table->string('periode_pencatatan');
$table->string('uid')->nullable();
$table->foreignId('petani_id')->nullable()->constrained('petani')->onDelete('set null');
$table->string('jenis_pelatihan');
$table->string('penyelenggara');
$table->integer('tahun_pelaksanaan');
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
Schema::create('riwayat_survey_sertifikasi', function (Blueprint $table) {
$table->id();
$table->string('periode_pencatatan');
$table->string('uid')->nullable();
$table->foreignId('petani_id')->nullable()->constrained('petani')->onDelete('set null');
$table->string('jenis_sertifikasi');
$table->string('lembaga_penerbit');
$table->integer('tahun_pelaksanaan');
$table->boolean('is_aktif')->default(false);
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
Schema::create('riwayat_petani_skor', function (Blueprint $table) {
$table->id();
$table->string('periode_pencatatan');
$table->string('uid')->nullable(); // Unique dihilangkan
$table->foreignId('petani_id')->nullable()->constrained('petani')->onDelete('cascade');
$table->float('skor_kesiapan_adopsi', 10)->nullable();
$table->float('skor_d2_sistem_praktik_budidaya', 10)->nullable();
$table->float('skor_d4_sosial_ekonomi', 10)->nullable();
$table->float('skor_d6_rantai_nilai_akses_pasar', 10)->nullable();
$table->float('skor_komp_1_d4_k3_motivasi_kapasitas_adopsi', 10)->nullable();
$table->float('skor_komp_2_d4_k2_ekonomi_rumah_tangga', 10)->nullable();
$table->float('skor_komp_3_d2_praktik_budidaya_gap', 10)->nullable();
$table->float('skor_komp_4_d4_k4_keterlibatan_kelembagaan', 10)->nullable();
$table->float('skor_komp_5_d4_k5_k6_akses_kebebasan_keuangan', 10)->nullable();
$table->float('skor_komp_6_d6_k1_k4_k5_rantai_nilai_akses_pasar', 10)->nullable();
$table->float('skor_d4_k3_1_praktik_baru', 10)->nullable();
$table->float('skor_d4_k3_4_pemahaman_mutu_harga', 10)->nullable();
$table->float('skor_d4_k3_5_role_model', 10)->nullable();
$table->float('skor_d4_k3_6_kemampuan_keterlibatan_program', 10)->nullable();
$table->float('skor_d4_k3_7_orientasi_jangka_panjang', 10)->nullable();
$table->float('skor_d4_k2_5_stabilitas_penghasilan', 10)->nullable();
$table->float('skor_d4_k2_8_kemampuan_menabung', 10)->nullable();
$table->float('skor_d4_k2_9_kecukupan_penghasilan', 10)->nullable();
$table->float('skor_d4_k2_3_k2_7_rasio_biaya_produksi_pendapatan', 10)->nullable();
$table->float('skor_d4_k2_11_hak_kelola_lahan', 10)->nullable();
$table->float('skor_d2_k2_3_praktik_pemangkasan', 10)->nullable();
$table->float('skor_d2_k2_4_praktik_pemupukan', 10)->nullable();
$table->float('skor_d2_k2_5_pengendalian_opt', 10)->nullable();
$table->float('skor_d2_k2_6_sanitasi_kebun', 10)->nullable();
$table->float('skor_d2_k2_6b_pengelolaan_gulma', 10)->nullable();
$table->float('skor_d2_k2_7_penglolaan_air_irigasi', 10)->nullable();
$table->float('skor_d2_k2_8_praktik_konservasi_tanah', 10)->nullable();
$table->float('skor_d2_k3_1_penentuan_pematangan', 10)->nullable();
$table->float('skor_d2_k3_5_sortasi_awal_pemisahan_kualitas', 10)->nullable();
$table->float('skor_d4_k4_2_keaktifan_pertemuan', 10)->nullable();
$table->float('skor_d4_k4_3_peran_dalam_kelompok', 10)->nullable();
$table->float('skor_d4_k4_4_manfaat_dirasakan', 10)->nullable();
$table->float('skor_d4_k5_1_kepemilikan_rekening', 10)->nullable();
$table->float('skor_d4_k5_2_pengalaman_kredit_formal', 10)->nullable();
$table->float('skor_d4_k5_3_kepemilikan_aset_produktif', 10)->nullable();
$table->float('skor_d4_k5_4_akses_program_bantuan', 10)->nullable();
$table->float('skor_d4_k6_3_keberadaan_hutang_terkait_panen', 10)->nullable();
$table->float('skor_d4_k6_5a_praktik_ijon', 10)->nullable();
$table->float('skor_d4_k6_5b_kebebasan_memilih_pembeli', 10)->nullable();
$table->float('skor_d6_k1_6_pengalaman_menjual_ke_saluran_berbeda', 10)->nullable();
$table->float('skor_d6_k4_1_kesadaran_akses_pasar_premium', 10)->nullable();
$table->float('skor_d6_k5_1_hambatan_mutu', 10)->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('updated_by')->nullable()->constrained('users')->onDelete('set null');
$table->foreignId('deleted_by')->nullable()->constrained('users')->onDelete('set null');
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('riwayat_petani_skor');
Schema::dropIfExists('riwayat_survey_sertifikasi');
Schema::dropIfExists('riwayat_survey_pelatihan');
Schema::dropIfExists('riwayat_harga_jual_panen');
Schema::dropIfExists('riwayat_akses_pasar');
Schema::dropIfExists('riwayat_pendapatan_luar_pertanian');
Schema::dropIfExists('riwayat_pendapatan_komoditas');
Schema::dropIfExists('riwayat_sosial_ekonomi');
Schema::dropIfExists('riwayat_survey_produktivitas');
Schema::dropIfExists('riwayat_survey_panen');
Schema::dropIfExists('riwayat_praktik_budidaya');
Schema::dropIfExists('riwayat_map_tanaman');
Schema::dropIfExists('riwayat_sertifikat_lahan');
Schema::dropIfExists('riwayat_map_lahan');
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('akses_pasar', function (Blueprint $table) {
$table->text('tujuan_penjualan')->nullable();
$table->string('tujuan_penjualan_lainnya')->nullable();
$table->text('sifat_hubungan_bisnis')->nullable();
$table->text('bentuk_penjualan')->nullable();
$table->string('frekuensi_penjualan')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('akses_pasar', function (Blueprint $table) {
$table->dropColumn([
'tujuan_penjualan',
'tujuan_penjualan_lainnya',
'sifat_hubungan_bisnis',
'bentuk_penjualan',
'frekuensi_penjualan'
]);
});
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('akses_pasar', function (Blueprint $table) {
$table->string('tahu_harga_sebelumnya')->nullable();
$table->text('sumber_info_harga')->nullable();
$table->string('sumber_info_harga_lainnya')->nullable();
});
}
public function down(): void
{
Schema::table('akses_pasar', function (Blueprint $table) {
$table->dropColumn([
'tahu_harga_sebelumnya',
'sumber_info_harga',
'sumber_info_harga_lainnya',
]);
});
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Penugasan user koordinator ke komoditas yang dia tangani.
*/
public function up(): void
{
Schema::create('koordinator_komoditas', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users');
$table->foreignId('komoditas_id')->constrained('master_komoditas');
$table->foreignId('created_by')->nullable()->constrained('users');
$table->foreignId('updated_by')->nullable()->constrained('users');
$table->timestamps();
$table->softDeletes();
$table->foreignId('deleted_by')->nullable()->constrained('users');
// Bukan unique index (sesuai AISTANDAR 5.2), cukup index untuk pencarian
$table->index(['user_id', 'komoditas_id']);
});
}
public function down(): void
{
Schema::dropIfExists('koordinator_komoditas');
}
};
@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Status kelengkapan data petani (App\Enums\StatusDataPetani):
* draft → lengkap → terverifikasi. Data lama otomatis "draft".
*/
public function up(): void
{
Schema::table('petani', function (Blueprint $table) {
$table->string('status', 20)->default('draft')->index();
});
}
public function down(): void
{
Schema::table('petani', function (Blueprint $table) {
$table->dropIndex(['status']);
$table->dropColumn('status');
});
}
};
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Catatan bebas pada data petani (boleh kosong).
*/
public function up(): void
{
Schema::table('petani', function (Blueprint $table) {
$table->text('catatan')->nullable();
});
}
public function down(): void
{
Schema::table('petani', function (Blueprint $table) {
$table->dropColumn('catatan');
});
}
};
+151
View File
@@ -103,3 +103,154 @@
text-align: center;
margin-top: 1rem;
}
/* Sidebar icon tiles: keep the icon mask on the inner element. */
#layout-menu .menu-inner > .menu-item > .menu-link > .sidebar-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 30px;
width: 30px;
height: 30px;
border-radius: 9px;
color: var(--sidebar-icon-color, var(--bs-secondary));
background: var(--sidebar-icon-bg, rgba(var(--bs-secondary-rgb), 0.1));
transition: background-color 180ms ease, color 180ms ease, transform 180ms ease;
}
#layout-menu .sidebar-icon > .ti {
width: 21px;
height: 21px;
font-size: 21px;
}
#layout-menu .sidebar-icon:has(.tabler-dashboard),
#layout-menu .sidebar-icon:has(.tabler-database) {
--sidebar-icon-color: var(--bs-primary);
--sidebar-icon-bg: rgba(var(--bs-primary-rgb), 0.12);
}
#layout-menu .sidebar-icon:has(.tabler-user-circle),
#layout-menu .sidebar-icon:has(.tabler-id-badge-2),
#layout-menu .sidebar-icon:has(.tabler-plant) {
--sidebar-icon-color: var(--bs-success);
--sidebar-icon-bg: rgba(var(--bs-success-rgb), 0.12);
}
#layout-menu .sidebar-icon:has(.tabler-users-group),
#layout-menu .sidebar-icon:has(.tabler-sitemap) {
--sidebar-icon-color: var(--bs-info);
--sidebar-icon-bg: rgba(var(--bs-info-rgb), 0.12);
}
#layout-menu .sidebar-icon:has(.tabler-building-community),
#layout-menu .sidebar-icon:has(.tabler-heart-handshake) {
--sidebar-icon-color: var(--bs-warning);
--sidebar-icon-bg: rgba(var(--bs-warning-rgb), 0.12);
}
#layout-menu .sidebar-icon:has(.tabler-map-2),
#layout-menu .sidebar-icon:has(.tabler-user-shield),
#layout-menu .sidebar-icon:has(.tabler-route) {
--sidebar-icon-color: var(--bs-primary);
--sidebar-icon-bg: rgba(var(--bs-primary-rgb), 0.12);
}
#layout-menu .menu-item > .menu-link:hover > .sidebar-icon,
#layout-menu .menu-item > .menu-link:focus-visible > .sidebar-icon {
transform: translateY(-1px);
}
#layout-menu .menu-inner > .menu-item.active > .menu-link > .sidebar-icon {
color: inherit;
background: rgba(255, 255, 255, 0.18);
}
@media (prefers-reduced-motion: reduce) {
#layout-menu .menu-inner > .menu-item > .menu-link > .sidebar-icon {
transition: none;
transform: none;
}
}
/* Submenu icons replace the template's decorative bullets. */
#layout-menu .menu-sub > .menu-item > .menu-link::before { display: none; }
#layout-menu .menu-sub .sidebar-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 22px;
width: 22px;
height: 22px;
color: inherit;
}
#layout-menu .menu-sub .sidebar-icon > .ti { width: 19px; height: 19px; font-size: 19px; }
/* Profil Kelompok Tani */
.kelompok-tani-page {
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
}
.kelompok-tani-page :is(.form-control, .form-select, .form-check-label, .select2-container) {
font-family: var(--bs-body-font-family);
}
.kelompok-tani-page select.is-invalid + .select2-container .select2-selection {
border-color: var(--bs-form-invalid-border-color);
}
.kelompok-tani-page .kelompok-tani-summary-name {
font-size: .9rem;
}
.kelompok-tani-page .kelompok-tani-summary-note {
font-size: .75rem;
line-height: 1.3;
}
.kelompok-tani-page .kelompok-tani-help {
margin-top: -.75rem;
}
.kelompok-tani-page #section-kas[hidden],
.kelompok-tani-page #section-bantuan[hidden] {
display: none !important;
}
.kelompok-tani-page #kelompok-tani-notifications,
.kelompok-tani-page [id^="section-"] {
scroll-margin-top: 7rem;
}
.kelompok-tani-page .select2-selection {
position: relative;
}
.kelompok-tani-page .select2-selection::after {
content: '';
position: absolute;
top: 50%;
inset-inline-end: 1rem;
width: .5rem;
height: .5rem;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
color: var(--bs-body-color);
transform: translateY(-75%) rotate(45deg);
pointer-events: none;
}
.kelompok-tani-page .select2-selection__arrow {
display: none;
}
.kelompok-tani-page .select2-container .select2-selection--multiple .select2-selection__rendered {
padding-inline-end: 2.5rem;
}
.kelompok-tani-page .select2-container--disabled .select2-selection::after {
color: var(--bs-secondary-color);
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 421 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 328 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

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