This commit is contained in:
2026-10-16 15:11:49 +07:00
25 changed files with 1161 additions and 828 deletions
@@ -223,7 +223,6 @@ class SummaryController extends Controller
$healthSourceName = $tanaman->isNotEmpty() ? 'tanaman' : 'lahan';
$landMetrics = $this->karakteristikLandMetrics($request);
$totalPetani = $landMetrics['total_petani'];
$totalLuasLahan = $landMetrics['total_luas_lahan'];
$avgLuasPerPetani = $landMetrics['ratarata_luas_lahan'];
$actualProductivity = $this->actualProductivityByCommodityChart($request);
@@ -233,14 +232,6 @@ class SummaryController extends Controller
$aksesPasar = $this->filteredAksesPasar($request);
$kpiCards = [
[
'value' => number_format($totalPetani, 0, ',', '.'),
'unit' => 'orang',
'label' => 'Jumlah Petani',
'description' => 'Petani sesuai filter yang dipilih',
'icon' => 'tabler-users',
'variant' => 'primary',
],
[
'value' => number_format($totalLuasLahan, 2, ',', '.'),
'unit' => 'ha',
@@ -294,7 +285,7 @@ class SummaryController extends Controller
: $value,
),
'praktikSortasiPanen' => $this->enumOptionChart($surveyPanen, 'praktik_sortasi', PraktikSortasi::class),
'praktikFermentasiPascapanen' => $this->fermentationPracticeChart($aksesPasar),
'praktikFermentasiPascapanen' => $this->fermentationPracticeByCommodityChart($aksesPasar),
'produktivitasAktualPerKomoditas' => $actualProductivity,
],
];
@@ -312,7 +303,6 @@ class SummaryController extends Controller
'desa' => (string) $request->input('desaKelurahan_id', ''),
],
'levels' => $levels,
'cultivationLevels' => $this->productionCultivationMapLevelData($request, $levels),
'information' => $this->productionMapInformationData($request),
];
}
@@ -511,6 +501,7 @@ class SummaryController extends Controller
'map_lahan.path',
'map_lahan.luas_lahan',
'map_lahan.jumlah_tanaman',
'map_lahan.petani_id',
'map_lahan.umur_tanaman',
'map_lahan.serangan_hama',
'map_lahan.penyakit_tanaman',
@@ -537,58 +528,10 @@ class SummaryController extends Controller
])
->groupBy('lahan_id');
$geoLahan = $mapLahan
->filter(fn ($lahan) => $this->hasMapPolygon($lahan->path))
->map(function ($lahan) use ($tanamanByLahan) {
$tanamanRows = $tanamanByLahan->get($lahan->id, collect());
$komoditas = $this->productionCommodityDetails($lahan, $tanamanRows);
$opt = $this->productionOptDetails($lahan, $tanamanRows);
return [
'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,
'komoditas' => $komoditas,
'opt' => $opt,
];
})
->values()
->all();
return [
'provinsi' => array_values($geoProvinsi),
'kabupaten' => array_values($geoKabupaten),
'kecamatan' => array_values($geoKecamatan),
'desa' => array_values($geoDesa),
'lahan' => $geoLahan,
];
}
private function productionCultivationMapLevelData(Request $request, array $baseLevels): array
{
$mapLahan = (clone $this->filteredLahanQuery($request))
->whereNotNull('map_lahan.path')
->leftJoin('profiles', 'profiles.id', '=', 'map_lahan.profile_id')
->leftJoin('petani', 'petani.id', '=', 'map_lahan.petani_id')
->tap(fn ($query) => $this->whereActiveMapOwner($query))
->orderByDesc('map_lahan.updated_at')
->limit(500)
->get([
'map_lahan.id',
'map_lahan.desa_kelurahan_id',
'map_lahan.path',
'map_lahan.luas_lahan',
'map_lahan.petani_id',
'profiles.nama as profile_nama',
'petani.nama as petani_nama',
]);
$petaniIds = $mapLahan->pluck('petani_id')->filter()->unique()->values();
$practiceByPetani = DB::table('praktik_budidaya')
->whereNull('praktik_budidaya.deleted_at')
->when($petaniIds->isNotEmpty(), fn ($query) => $query->whereIn('praktik_budidaya.petani_id', $petaniIds))
->when($petaniIds->isEmpty(), fn ($query) => $query->whereRaw('1 = 0'))
->whereIn('praktik_budidaya.petani_id', $petaniIds)
->orderByDesc('praktik_budidaya.updated_at')
->get([
'praktik_budidaya.petani_id',
@@ -601,7 +544,10 @@ class SummaryController extends Controller
$geoLahan = $mapLahan
->filter(fn ($lahan) => $this->hasMapPolygon($lahan->path))
->map(function ($lahan) use ($practiceByPetani) {
->map(function ($lahan) use ($tanamanByLahan, $practiceByPetani) {
$tanamanRows = $tanamanByLahan->get($lahan->id, collect());
$komoditas = $this->productionCommodityDetails($lahan, $tanamanRows);
$opt = $this->productionOptDetails($lahan, $tanamanRows);
$practice = $practiceByPetani->get($lahan->petani_id);
return [
@@ -610,6 +556,8 @@ class SummaryController extends Controller
'polygon' => $lahan->path,
'nama_petani' => $lahan->petani_nama ?? $lahan->profile_nama ?? 'N/A',
'luas' => (float) $lahan->luas_lahan,
'komoditas' => $komoditas,
'opt' => $opt,
'pemangkasan' => $this->enumLabel(FrekuensiPemangkasanUtama::class, $practice->frekuensi_pemangkasan_utama ?? null, 'Belum ada data'),
'pemupukan' => $this->enumLabel(PemberianPupuk::class, $practice->pemberian_pupuk ?? null, 'Belum ada data'),
'praktik_baik' => $this->arrayValues($practice->kebiasaan_konservasi_tanah ?? null),
@@ -619,10 +567,10 @@ class SummaryController extends Controller
->all();
return [
'provinsi' => $baseLevels['provinsi'] ?? [],
'kabupaten' => $baseLevels['kabupaten'] ?? [],
'kecamatan' => $baseLevels['kecamatan'] ?? [],
'desa' => $baseLevels['desa'] ?? [],
'provinsi' => array_values($geoProvinsi),
'kabupaten' => array_values($geoKabupaten),
'kecamatan' => array_values($geoKecamatan),
'desa' => array_values($geoDesa),
'lahan' => $geoLahan,
];
}
@@ -826,7 +774,12 @@ class SummaryController extends Controller
return DB::table('akses_pasar')
->whereNull('akses_pasar.deleted_at')
->whereIn('akses_pasar.petani_id', $petaniIds)
->get(['praktik_fermentasi']);
->leftJoin('petani as akses_petani', 'akses_petani.id', '=', 'akses_pasar.petani_id')
->leftJoin('master_komoditas as akses_komoditas', 'akses_komoditas.id', '=', 'akses_petani.komoditas_id')
->get([
'akses_pasar.praktik_fermentasi',
'akses_komoditas.nama as komoditas_nama',
]);
}
private function filteredSurveyProduktivitas(Request $request)
@@ -1254,10 +1207,10 @@ class SummaryController extends Controller
];
}
private function fermentationPracticeChart($rows): array
private function fermentationPracticeByCommodityChart($rows): array
{
$labels = ['Ya, rutin', 'Pernah mencoba', 'Belum pernah'];
$counts = collect($labels)->mapWithKeys(fn ($label) => [$label => 0]);
$matrix = [];
foreach ($rows as $row) {
$label = $this->fermentationPracticeLabel($row->praktik_fermentasi ?? null);
@@ -1266,12 +1219,21 @@ class SummaryController extends Controller
continue;
}
$counts[$label] = (int) $counts[$label] + 1;
$commodity = $row->komoditas_nama ?: 'Belum ditentukan';
$matrix[$commodity] ??= array_fill_keys($labels, 0);
$matrix[$commodity][$label]++;
}
ksort($matrix, SORT_NATURAL | SORT_FLAG_CASE);
return [
'labels' => $labels,
'series' => collect($labels)->map(fn ($label) => (int) ($counts[$label] ?? 0))->values()->all(),
'labels' => array_keys($matrix),
'series' => collect($labels)
->map(fn ($label) => [
'name' => $label,
'data' => collect($matrix)->map(fn ($counts) => (int) $counts[$label])->values()->all(),
])
->all(),
];
}
@@ -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) {
+23
View File
@@ -81,6 +81,29 @@ window.config = {
enableMenuLocalStorage: true // Enable menu state with local storage support
};
// Choose the label color with the higher contrast against each chart slice.
window.config.chartTextColor = function (color) {
let hex = String(color || '').trim().replace(/^#/, '');
if (/^[0-9a-f]{3}$/i.test(hex)) {
hex = hex.split('').map(value => value + value).join('');
}
if (!/^[0-9a-f]{6}$/i.test(hex)) return '#212529';
const luminance = function (value) {
const channels = value.match(/../g).map(channel => {
const srgb = parseInt(channel, 16) / 255;
return srgb <= 0.04045 ? srgb / 12.92 : Math.pow((srgb + 0.055) / 1.055, 2.4);
});
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
};
const background = luminance(hex);
const dark = luminance('212529');
const darkContrast = (Math.max(background, dark) + 0.05) / (Math.min(background, dark) + 0.05);
const lightContrast = 1.05 / (background + 0.05);
return darkContrast >= lightContrast ? '#212529' : '#ffffff';
};
window.assetsPath = document.documentElement.getAttribute('data-assets-path');
window.templateName = document.documentElement.getAttribute('data-template');
@@ -2011,7 +2011,11 @@
series: data.series,
colors: colors,
stroke: { width: 0 },
dataLabels: { enabled: false },
dataLabels: {
enabled: false,
style: { colors: colors.map(config.chartTextColor) },
dropShadow: { enabled: false },
},
legend: {
show: true,
position: 'bottom',
@@ -2051,7 +2055,7 @@
<div
style="
background: ${color};
color: #fff;
color: ${config.chartTextColor(color)};
padding: 8px 12px;
border-radius: 5px;
font-size: 13px;
@@ -1713,6 +1713,7 @@ document.addEventListener('DOMContentLoaded', function () {
const totalPetani = lakiLaki + perempuan;
if (genderChartEl) {
const genderColors = [pastelSemantic.info || '#5FC7D6', pastelSemantic.pink || '#F47C86'];
const genderChartConfig = {
chart: {
height: 365,
@@ -1732,7 +1733,7 @@ document.addEventListener('DOMContentLoaded', function () {
},
labels: ['Laki-laki', 'Perempuan'],
series: [lakiLaki, perempuan],
colors: [pastelSemantic.info || '#5FC7D6', pastelSemantic.pink || '#F47C86'],
colors: genderColors,
stroke: { width: 0 },
dataLabels: {
enabled: true,
@@ -1748,15 +1749,9 @@ document.addEventListener('DOMContentLoaded', function () {
fontSize: '13px',
fontFamily: fontFamily,
fontWeight: 600,
colors: ['#fff']
colors: genderColors.map(config.chartTextColor)
},
dropShadow: {
enabled: true,
top: 1,
left: 1,
blur: 2,
opacity: 0.45
}
dropShadow: { enabled: false }
},
legend: {
show: true,
@@ -2120,6 +2115,7 @@ document.addEventListener('DOMContentLoaded', function () {
const farmerGroupFilters = ['terdaftar', 'tidak_terdaftar', 'belum_terisi'];
const totalFarmerGroup = farmerGroupData.reduce((total, value) => total + value, 0);
const farmerGroupColors = [pastelSemantic.success || '#6FD68F', pastelSemantic.warning || '#FFB46F', pastelSemantic.secondary || '#9EA6B0'];
const farmerGroupChartConfig = {
chart: {
height: 365,
@@ -2139,7 +2135,7 @@ document.addEventListener('DOMContentLoaded', function () {
},
labels: ['Terdaftar', 'Tidak Terdaftar', 'Belum Terisi'],
series: farmerGroupData,
colors: [pastelSemantic.success || '#6FD68F', pastelSemantic.warning || '#FFB46F', pastelSemantic.secondary || '#9EA6B0'],
colors: farmerGroupColors,
stroke: { width: 0 },
dataLabels: {
enabled: true,
@@ -2155,15 +2151,9 @@ document.addEventListener('DOMContentLoaded', function () {
fontSize: '13px',
fontFamily: fontFamily,
fontWeight: 600,
colors: ['#fff']
colors: farmerGroupColors.map(config.chartTextColor)
},
dropShadow: {
enabled: true,
top: 1,
left: 1,
blur: 2,
opacity: 0.45
}
dropShadow: { enabled: false }
},
legend: {
show: true,
@@ -2219,6 +2209,14 @@ document.addEventListener('DOMContentLoaded', function () {
const commoditySeries = @json($data['komoditas_utama']['series'] ?? []);
const totalCommodityFarmers = commoditySeries.reduce((total, value) => total + Number(value), 0);
const commodityColors = [
pastelSemantic.primary || '#9D8EF0',
pastelSemantic.warning || '#FFB46F',
pastelSemantic.success || '#6FD68F',
pastelSemantic.info || '#5FC7D6',
pastelSemantic.danger || '#F47C86',
pastelSemantic.secondary || '#9EA6B0'
];
const commodityChartConfig = {
chart: {
height: 365,
@@ -2237,14 +2235,7 @@ document.addEventListener('DOMContentLoaded', function () {
},
labels: commodityLabels,
series: commoditySeries.map(Number),
colors: [
pastelSemantic.primary || '#9D8EF0',
pastelSemantic.warning || '#FFB46F',
pastelSemantic.success || '#6FD68F',
pastelSemantic.info || '#5FC7D6',
pastelSemantic.danger || '#F47C86',
pastelSemantic.secondary || '#9EA6B0'
],
colors: commodityColors,
stroke: { width: 0 },
dataLabels: {
enabled: true,
@@ -2260,15 +2251,9 @@ document.addEventListener('DOMContentLoaded', function () {
fontSize: '13px',
fontFamily: fontFamily,
fontWeight: 600,
colors: ['#fff']
colors: commodityColors.map(config.chartTextColor)
},
dropShadow: {
enabled: true,
top: 1,
left: 1,
blur: 2,
opacity: 0.45
}
dropShadow: { enabled: false }
},
legend: {
show: true,
@@ -2293,7 +2278,7 @@ document.addEventListener('DOMContentLoaded', function () {
const percentage = totalCommodityFarmers > 0 ? ((value / totalCommodityFarmers) * 100).toFixed(1) : '0.0';
const color = w.globals.colors[seriesIndex];
return `
<div style="background:${color}; color:${headingColor}; padding:8px 12px; border-radius:5px; font-size:13px; font-weight:500; font-family:${fontFamily}; box-shadow:0 4px 12px rgba(0,0,0,.15);">
<div style="background:${color}; color:${config.chartTextColor(color)}; padding:8px 12px; border-radius:5px; font-size:13px; font-weight:500; font-family:${fontFamily}; box-shadow:0 4px 12px rgba(0,0,0,.15);">
${label}: <strong>${value} petani (${percentage}%)</strong>
</div>
`;
@@ -2433,6 +2418,12 @@ document.addEventListener('DOMContentLoaded', function () {
const landOwnershipStatusSeries = (@json($data['status_kepemilikan_lahan']['series'] ?? [])).map(Number);
const totalLandOwnershipStatusFarmers = landOwnershipStatusSeries.reduce((total, value) => total + value, 0);
const landOwnershipStatusColors = [
pastelSemantic.success || '#6FD68F',
pastelSemantic.warning || '#FFB46F',
pastelSemantic.info || '#5FC7D6',
pastelSemantic.secondary || '#9EA6B0'
];
const landOwnershipStatusChartConfig = {
chart: {
height: 365,
@@ -2452,12 +2443,7 @@ document.addEventListener('DOMContentLoaded', function () {
},
labels: landOwnershipStatusLabels,
series: landOwnershipStatusSeries,
colors: [
pastelSemantic.success || '#6FD68F',
pastelSemantic.warning || '#FFB46F',
pastelSemantic.info || '#5FC7D6',
pastelSemantic.secondary || '#9EA6B0'
],
colors: landOwnershipStatusColors,
stroke: { width: 0 },
dataLabels: {
enabled: true,
@@ -2473,15 +2459,9 @@ document.addEventListener('DOMContentLoaded', function () {
fontSize: '13px',
fontFamily: fontFamily,
fontWeight: 600,
colors: ['#fff']
colors: landOwnershipStatusColors.map(config.chartTextColor)
},
dropShadow: {
enabled: true,
top: 1,
left: 1,
blur: 2,
opacity: 0.45
}
dropShadow: { enabled: false }
},
legend: {
show: true,
@@ -2918,6 +2898,8 @@ document.addEventListener('DOMContentLoaded', function () {
},
dataLabels: {
enabled: false,
style: { colors: segmentPalette.map(config.chartTextColor) },
dropShadow: { enabled: false },
},
legend: {
show: false,
+43 -320
View File
@@ -758,7 +758,7 @@
<div class="card-body d-flex justify-content-between align-items-center gap-3">
<div>
<h5>Peta Sebaran</h5>
<p class="text-body-secondary mb-0">Lahan, tanaman, dan OPT berdasarkan filter wilayah.</p>
<p class="text-body-secondary mb-0">Lahan, tanaman, OPT, dan praktik budidaya berdasarkan filter wilayah.</p>
</div>
<span class="btn btn-icon btn-label-secondary btn-sm chart-group-icon-toggle" aria-hidden="true">
<i class="icon-base ti tabler-chevron-down"></i>
@@ -773,8 +773,8 @@
<div class="card-body">
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-2">
<div>
<h6 class="mb-0">Peta Sebaran Lahan & Tanaman</h6>
<small class="text-body-secondary">Lahan dan tanaman berdasarkan filter wilayah, komoditas, dan segmen</small>
<h6 class="mb-0">Peta Sebaran Lahan, Tanaman & Praktik Budidaya</h6>
<small class="text-body-secondary">Lahan, tanaman, OPT, pemangkasan, pemupukan, dan praktik baik berdasarkan filter wilayah, komoditas, dan segmen</small>
</div>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary active produksi-map-layer-btn" data-map-layer="satellite">Satellite</button>
@@ -795,34 +795,6 @@
</div>
</div>
</div>
<div class="col-12">
<div class="card h-100 produksi-chart-card">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-2">
<div>
<h6 class="mb-0">Peta Praktik Budidaya</h6>
<small class="text-body-secondary">Intensitas pemangkasan, pemupukan, dan kebiasaan praktik baik</small>
</div>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary active produksi-cultivation-map-layer-btn" data-map-layer="satellite">Satellite</button>
<button type="button" class="btn btn-outline-secondary produksi-cultivation-map-layer-btn" data-map-layer="topo">Topographic</button>
<button type="button" class="btn btn-outline-secondary produksi-cultivation-map-layer-btn" data-map-layer="parcels">Parcels</button>
</div>
</div>
<div id="produksi-budidaya-map" class="produksi-map">
<button type="button" id="produksi-budidaya-map-back" class="btn btn-sm btn-dark shadow produksi-map-back">
<i class="icon-base ti tabler-arrow-left me-1"></i>
Kembali ke Atas
</button>
<div id="produksi-budidaya-map-empty" class="produksi-map-empty">Belum ada poligon lahan untuk filter ini.</div>
<div class="produksi-map-information-overlay" tabindex="0" role="region" aria-label="Informasi peta praktik budidaya">
<div class="produksi-map-information" id="produksi-budidaya-map-information"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1044,8 +1016,8 @@
<div class="card h-100 produksi-chart-card">
<div class="card-header d-flex align-items-start justify-content-between gap-2">
<div class="card-title mb-0">
<h5 class="m-0 me-2">Praktik Fermentasi atau Pengolahan Pascapanen</h5>
<small class="text-body-secondary">Apakah sudah ada praktik fermentasi atau pengolahan pascapanen yang dilakukan</small>
<h5 class="m-0 me-2">Praktik Fermentasi atau Pengolahan Pascapanen per Komoditas</h5>
<small class="text-body-secondary">Jumlah petani per komoditas berdasarkan praktik fermentasi atau pengolahan pascapanen.</small>
</div>
</div>
<div class="card-body">
@@ -1079,11 +1051,7 @@ document.addEventListener('DOMContentLoaded', function () {
const mapData = @json($mapData);
const dashboardFilters = @json($dashboardFilters);
const mapInformationUrl = @json(route('dashboard.produktivitas.map-information'));
const initialMapInformation = mapData.information || {};
const mapInformation = {
production: { ...initialMapInformation },
cultivation: { ...initialMapInformation },
};
const mapInformation = { ...(mapData.information || {}) };
const wilayah = @json($wilayah);
const headingColor = config.colors.headingColor;
const legendColor = config.colors.bodyColor;
@@ -1094,9 +1062,8 @@ document.addEventListener('DOMContentLoaded', function () {
const labelColor = legendColor;
const borderColor = pastelChart.grid || 'rgba(128, 128, 128, .18)';
let productionMapRendered = false;
let cultivationMapRendered = false;
let actualProductivityRendered = false;
const mapInformationRequestIds = { production: 0, cultivation: 0 };
let mapInformationRequestId = 0;
function hasData(data) {
return data && Array.isArray(data.series) && data.series.some(function (value) {
@@ -1114,7 +1081,7 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
function renderProductionMapInformation(elementId, information, includeOpt = true) {
function renderProductionMapInformation(elementId, information) {
const element = document.getElementById(elementId);
if (!element) return;
@@ -1193,17 +1160,15 @@ document.addEventListener('DOMContentLoaded', function () {
<span class="produksi-map-information-section-title">Jumlah Petani / Komoditas</span>
<div class="produksi-map-information-bars">${renderBars(commodityRows, 'Belum ada data komoditas.', 'petani')}</div>
</section>
${includeOpt ? `
<section>
<span class="produksi-map-information-section-title">OPT (Organisme Pengganggu Tanaman)</span>
<div class="produksi-map-information-bars">${renderBars(optRows, 'Belum ada data OPT.', 'lahan')}</div>
</section>
` : ''}
`;
}
async function loadProductionMapInformation(mapType, level = null, id = null) {
const requestId = ++mapInformationRequestIds[mapType];
async function loadProductionMapInformation(level = null, id = null) {
const requestId = ++mapInformationRequestId;
const params = new URLSearchParams();
Object.entries(dashboardFilters || {}).forEach(function ([key, value]) {
@@ -1225,14 +1190,10 @@ document.addEventListener('DOMContentLoaded', function () {
if (!response.ok) return;
const information = await response.json();
if (requestId !== mapInformationRequestIds[mapType]) return;
if (requestId !== mapInformationRequestId) return;
Object.assign(mapInformation[mapType], information);
if (mapType === 'production') {
renderProductionMapInformation('produksi-map-information', mapInformation.production);
} else {
renderProductionMapInformation('produksi-budidaya-map-information', mapInformation.cultivation, false);
}
Object.assign(mapInformation, information);
renderProductionMapInformation('produksi-map-information', mapInformation);
} catch (error) {
console.error('Gagal memuat informasi wilayah pada peta.', error);
}
@@ -1290,13 +1251,13 @@ document.addEventListener('DOMContentLoaded', function () {
}, 0);
}
function produksiChartTooltip(color, label, value, unit = '', total = null, digits = 0) {
function produksiChartTooltip(color, label, value, unit = '', total = null, digits = 0, textColor = headingColor) {
const numericValue = Number(value || 0);
const unitText = unit ? ` ${escapeHtml(unit)}` : '';
const percentageText = total === null ? '' : ` (${chartPercentage(numericValue, total)}%)`;
return `
<div class="produksi-apex-tooltip" style="background:${escapeHtml(color)}; color:${escapeHtml(headingColor)}; font-family:${fontFamily};">
<div class="produksi-apex-tooltip" style="background:${escapeHtml(color)}; color:${escapeHtml(textColor)}; font-family:${fontFamily};">
${escapeHtml(label)}: <strong>${formatNumber(numericValue, digits)}${unitText}${percentageText}</strong>
</div>
`;
@@ -1425,6 +1386,9 @@ document.addEventListener('DOMContentLoaded', function () {
const opt = Array.isArray(item.opt) && item.opt.length
? item.opt.map(value => `<span class="produksi-opt-chip">${escapeHtml(value)}</span>`).join('')
: '<span class="text-body-secondary small">Belum ada data OPT</span>';
const praktikBaik = Array.isArray(item.praktik_baik) && item.praktik_baik.length
? item.praktik_baik.map(value => `<li>${escapeHtml(value)}</li>`).join('')
: '<li>Belum ada data praktik baik</li>';
return `
<div class="produksi-lahan-popup">
@@ -1449,6 +1413,19 @@ document.addEventListener('DOMContentLoaded', function () {
<span class="produksi-popup-label">OPT</span>
<div class="produksi-opt-list">${opt}</div>
</div>
<div class="mt-3">
<span class="produksi-popup-label">Praktik Budidaya</span>
</div>
<div class="mb-3">
<span class="produksi-popup-label">Intensitas Pemangkasan</span>
<span class="produksi-popup-value">${escapeHtml(item.pemangkasan || 'Belum ada data')}</span>
</div>
<div class="mb-3">
<span class="produksi-popup-label">Intensitas Pemupukan</span>
<span class="produksi-popup-value">${escapeHtml(item.pemupukan || 'Belum ada data')}</span>
</div>
<span class="produksi-popup-label">Kebiasaan praktik baik</span>
<ul class="produksi-praktik-list">${praktikBaik}</ul>
</div>
</div>
`;
@@ -1513,7 +1490,7 @@ document.addEventListener('DOMContentLoaded', function () {
renderProductionMapLevel(previous.level, previous.parentId);
productionMapState.informationLevel = previous.informationLevel;
productionMapState.informationId = previous.informationId;
loadProductionMapInformation('production', previous.informationLevel, previous.informationId);
loadProductionMapInformation(previous.informationLevel, previous.informationId);
if (previous.bounds?.isValid()) {
productionMapState.map.fitBounds(previous.bounds, { padding: [30, 30] });
@@ -1585,7 +1562,8 @@ document.addEventListener('DOMContentLoaded', function () {
layer.bindPopup(landPopup(item), {
className: 'produksi-map-popup',
closeButton: true,
maxWidth: 320,
maxWidth: 340,
maxHeight: 360,
});
} else {
layer.bindTooltip(regionTooltip(item), {
@@ -1606,7 +1584,7 @@ document.addEventListener('DOMContentLoaded', function () {
});
productionMapState.informationLevel = nextLevel;
productionMapState.informationId = item.id;
loadProductionMapInformation('production', nextLevel, item.id);
loadProductionMapInformation(nextLevel, item.id);
renderProductionMapLevel(nextProductionMapLevel(nextLevel), item.id, event.target.getBounds());
});
}
@@ -1641,234 +1619,6 @@ document.addEventListener('DOMContentLoaded', function () {
}, 150);
}
function initialCultivationMapLevel() {
const levels = mapData.cultivationLevels || {};
const filters = mapData.filters || {};
if (filters.desa && (levels.desa || []).length) return 'desa';
if (filters.kecamatan && (levels.kecamatan || []).length) return 'kecamatan';
if (filters.kabupaten && (levels.kabupaten || []).length) return 'kabupaten';
if ((levels.kabupaten || []).length) return 'kabupaten';
if ((levels.kecamatan || []).length) return 'kecamatan';
if ((levels.desa || []).length) return 'desa';
if ((levels.provinsi || []).length) return 'provinsi';
return 'lahan';
}
function cultivationPopup(item) {
const praktikBaik = Array.isArray(item.praktik_baik) && item.praktik_baik.length
? item.praktik_baik.map(value => `<li>${escapeHtml(value)}</li>`).join('')
: '<li>Belum ada data praktik baik</li>';
return `
<div class="produksi-lahan-popup">
<div class="produksi-lahan-popup-header">
<span class="produksi-popup-label">Pemilik Lahan</span>
<h6>${escapeHtml(item.nama_petani || 'N/A')}</h6>
</div>
<div class="produksi-lahan-popup-body">
<div class="row g-2 mb-3">
<div class="col-6">
<span class="produksi-popup-label">Luas Lahan</span>
<span class="produksi-popup-value">${formatNumber(item.luas || 0, 2)} Ha</span>
</div>
<div class="col-6">
<span class="produksi-popup-label">ID Lahan</span>
<span class="produksi-popup-value">TR-${escapeHtml(item.id)}</span>
</div>
</div>
<div class="mb-3">
<span class="produksi-popup-label">Intensitas Pemangkasan</span>
<span class="produksi-popup-value">${escapeHtml(item.pemangkasan || 'Belum ada data')}</span>
</div>
<div class="mb-3">
<span class="produksi-popup-label">Intensitas Pemupukan</span>
<span class="produksi-popup-value">${escapeHtml(item.pemupukan || 'Belum ada data')}</span>
</div>
<span class="produksi-popup-label">Kebiasaan praktik baik</span>
<ul class="produksi-praktik-list">${praktikBaik}</ul>
</div>
</div>
`;
}
const cultivationMapState = {
activeLayer: 'satellite',
backButton: null,
empty: null,
group: null,
history: [],
informationId: null,
informationLevel: null,
layers: {},
map: null,
};
function initCultivationMap() {
const element = document.getElementById('produksi-budidaya-map');
if (!element || typeof L === 'undefined') {
return false;
}
if (cultivationMapState.map) {
return true;
}
cultivationMapState.layers = {
satellite: L.tileLayer('https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', { maxZoom: 22 }),
topo: L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png'),
parcels: L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'),
};
cultivationMapState.map = L.map('produksi-budidaya-map', {
center: [-8.67, 121.0],
zoom: 10,
layers: [cultivationMapState.layers.satellite],
zoomControl: false,
});
cultivationMapState.group = L.featureGroup().addTo(cultivationMapState.map);
cultivationMapState.backButton = document.getElementById('produksi-budidaya-map-back');
cultivationMapState.empty = document.getElementById('produksi-budidaya-map-empty');
enableMapInformationInteraction(element);
L.control.zoom({ position: 'topleft' }).addTo(cultivationMapState.map);
cultivationMapState.backButton?.addEventListener('click', function () {
const previous = cultivationMapState.history.pop();
if (previous) {
renderCultivationMapLevel(previous.level, previous.parentId);
cultivationMapState.informationLevel = previous.informationLevel;
cultivationMapState.informationId = previous.informationId;
loadProductionMapInformation('cultivation', previous.informationLevel, previous.informationId);
if (previous.bounds?.isValid()) {
cultivationMapState.map.fitBounds(previous.bounds, { padding: [30, 30] });
}
}
});
return true;
}
function setCultivationMapLayer(type, button) {
if (!cultivationMapState.map || !cultivationMapState.layers[type]) {
return;
}
Object.values(cultivationMapState.layers).forEach(function (layer) {
cultivationMapState.map.removeLayer(layer);
});
cultivationMapState.layers[type].addTo(cultivationMapState.map);
cultivationMapState.activeLayer = type;
document.querySelectorAll('.produksi-cultivation-map-layer-btn').forEach(function (item) {
item.classList.toggle('active', item === button);
});
}
function renderCultivationMapLevel(level = null, parentId = null, fallbackBounds = null) {
if (!initCultivationMap()) {
return;
}
const levels = mapData.cultivationLevels || {};
const nextLevel = level || initialCultivationMapLevel();
let dataToRender = levels[nextLevel] || [];
if (parentId !== null) {
dataToRender = dataToRender.filter(item => String(item.parent_id) === String(parentId));
}
cultivationMapState.group.clearLayers();
if (nextLevel === 'lahan' && parentId !== null) {
const parentDesa = (levels.desa || []).find(item => String(item.id) === String(parentId));
const desaLayer = parentDesa
? makeLayerFromPolygon(parentDesa.polygon, {
color: '#F47C86',
dashArray: '5, 8',
fillOpacity: 0,
interactive: false,
weight: 2,
})
: null;
if (desaLayer) {
cultivationMapState.group.addLayer(desaLayer);
}
}
dataToRender.forEach(function (item) {
const layer = makeLayerFromPolygon(item.polygon, nextLevel === 'lahan'
? { color: '#5FC7D6', fillColor: '#5FC7D6', fillOpacity: .58, weight: 2 }
: { color: '#9D8EF0', fillColor: '#9D8EF0', fillOpacity: .3, weight: 2 });
if (!layer) {
return;
}
if (nextLevel === 'lahan') {
layer.bindPopup(cultivationPopup(item), {
className: 'produksi-map-popup',
closeButton: true,
maxWidth: 340,
});
} else {
layer.bindTooltip(regionTooltip(item), {
className: 'produksi-map-tooltip',
direction: 'top',
offset: [0, -10],
sticky: true,
});
layer.on('click', function (event) {
L.DomEvent.stopPropagation(event);
cultivationMapState.history.push({
level: nextLevel,
parentId,
bounds: cultivationMapState.group.getBounds(),
informationLevel: cultivationMapState.informationLevel,
informationId: cultivationMapState.informationId,
});
cultivationMapState.informationLevel = nextLevel;
cultivationMapState.informationId = item.id;
loadProductionMapInformation('cultivation', nextLevel, item.id);
renderCultivationMapLevel(nextProductionMapLevel(nextLevel), item.id, event.target.getBounds());
});
}
cultivationMapState.group.addLayer(layer);
});
if (cultivationMapState.backButton) {
cultivationMapState.backButton.style.display = cultivationMapState.history.length > 0 ? 'block' : 'none';
}
const layersCount = cultivationMapState.group.getLayers().length;
if (cultivationMapState.empty) {
cultivationMapState.empty.style.display = layersCount > 0 ? 'none' : 'flex';
}
if (layersCount > 0) {
const bounds = cultivationMapState.group.getBounds();
if (bounds.isValid()) {
cultivationMapState.map.fitBounds(bounds, { padding: [30, 30], maxZoom: 18 });
}
} else if (fallbackBounds?.isValid()) {
cultivationMapState.map.fitBounds(fallbackBounds, { padding: [30, 30], maxZoom: 18 });
} else {
cultivationMapState.map.setView([-8.67, 121.0], 10);
}
setTimeout(function () {
cultivationMapState.map.invalidateSize();
}, 150);
}
function renderDonut(selector, data, options = {}) {
const element = document.querySelector(selector);
if (!element) return;
@@ -1886,6 +1636,7 @@ document.addEventListener('DOMContentLoaded', function () {
stroke: { width: 0 },
dataLabels: {
enabled: true,
dropShadow: { enabled: false },
formatter: function (value) {
return value.toFixed(0) + '%';
},
@@ -1893,7 +1644,7 @@ document.addEventListener('DOMContentLoaded', function () {
fontSize: '13px',
fontFamily: fontFamily,
fontWeight: 600,
colors: ['#fff'],
colors: chartColors.map(config.chartTextColor),
},
},
legend: {
@@ -1915,7 +1666,7 @@ document.addEventListener('DOMContentLoaded', function () {
const label = w.globals.labels[seriesIndex] || 'Data';
const color = w.globals.colors?.[seriesIndex] || chartColors[seriesIndex % chartColors.length] || '#9D8EF0';
return produksiChartTooltip(color, label, value, options.unit || 'data', total);
return produksiChartTooltip(color, label, value, options.unit || 'data', total, 0, config.chartTextColor(color));
},
},
plotOptions: {
@@ -1992,6 +1743,7 @@ document.addEventListener('DOMContentLoaded', function () {
legend: { show: false },
dataLabels: {
enabled: true,
dropShadow: { enabled: false },
formatter: function (value) {
return value >= 8 ? value.toFixed(0) + '%' : '';
},
@@ -1999,7 +1751,7 @@ document.addEventListener('DOMContentLoaded', function () {
fontSize: '11px',
fontFamily: fontFamily,
fontWeight: 600,
colors: ['#fff'],
colors: colors.map(config.chartTextColor),
},
},
tooltip: {
@@ -2008,7 +1760,7 @@ document.addEventListener('DOMContentLoaded', function () {
const label = w.globals.labels[seriesIndex] || 'Data';
const color = w.globals.colors?.[seriesIndex] || colors[seriesIndex % colors.length] || '#9D8EF0';
return produksiChartTooltip(color, label, value, options.unit || 'data', total);
return produksiChartTooltip(color, label, value, options.unit || 'data', total, 0, config.chartTextColor(color));
},
},
plotOptions: { pie: { expandOnClick: true } },
@@ -2231,7 +1983,7 @@ document.addEventListener('DOMContentLoaded', function () {
renderGroupedBar('#chart-sumber-air-kebun', chartData.sumberAirKebun, { unit: 'petani', yTitle: 'Jumlah Petani', height: 330 });
renderGroupedBar('#chart-kebiasaan-praktik-baik', chartData.kebiasaanPraktikBaik, { unit: 'petani', yTitle: 'Jumlah Petani', height: 330 });
renderPieWithLegend('#chart-praktik-sortasi-panen', chartData.praktikSortasiPanen, { unit: 'petani' });
renderBar('#chart-praktik-fermentasi-pascapanen', chartData.praktikFermentasiPascapanen, { height: 330, color: pastelSemantic.primary || '#9D8EF0', unit: 'petani' });
renderGroupedBar('#chart-praktik-fermentasi-pascapanen', chartData.praktikFermentasiPascapanen, { unit: 'petani', yTitle: 'Jumlah Petani', height: 330 });
renderActualProductivityIfVisible();
}
@@ -2261,37 +2013,15 @@ document.addEventListener('DOMContentLoaded', function () {
productionMapRendered = true;
}
function renderCultivationMapIfOpen() {
if (!document.getElementById('peta-sebaran-panel')?.classList.contains('show')) {
return;
}
if (!cultivationMapRendered) {
renderCultivationMapLevel();
cultivationMapRendered = true;
return;
}
cultivationMapState.map?.invalidateSize();
cultivationMapRendered = true;
}
renderProductionMapInformation('produksi-map-information', mapInformation.production);
renderProductionMapInformation('produksi-budidaya-map-information', mapInformation.cultivation, false);
renderProductionMapInformation('produksi-map-information', mapInformation);
if (typeof L === 'undefined') {
const mapEmpty = document.getElementById('produksi-map-empty');
const cultivationMapEmpty = document.getElementById('produksi-budidaya-map-empty');
if (mapEmpty) {
mapEmpty.textContent = 'Leaflet belum dimuat.';
mapEmpty.style.display = 'flex';
}
if (cultivationMapEmpty) {
cultivationMapEmpty.textContent = 'Leaflet belum dimuat.';
cultivationMapEmpty.style.display = 'flex';
}
}
document.querySelectorAll('.produksi-map-layer-btn').forEach(function (button) {
@@ -2300,12 +2030,6 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
document.querySelectorAll('.produksi-cultivation-map-layer-btn').forEach(function (button) {
button.addEventListener('click', function () {
setCultivationMapLayer(button.dataset.mapLayer, button);
});
});
$('#filter-provinsi, #filter-kabkota, #filter-kecamatan, #filter-desa, #filter-komoditas, #filter-segmen').select2({ width: '100%' });
$('#filter-provinsi').on('change', function () {
@@ -2388,7 +2112,6 @@ document.addEventListener('DOMContentLoaded', function () {
if (panel.id === 'peta-sebaran-panel' && typeof L !== 'undefined') {
renderProductionMapIfOpen();
renderCultivationMapIfOpen();
}
window.dispatchEvent(new Event('resize'));
+1 -1
View File
@@ -90,6 +90,6 @@
<script src="{{ asset('assets/vendor/js/template-customizer.js') }}"></script>
<script src="{{ asset('assets/js/config.js') }}"></script>
<script src="{{ asset('assets/js/config.js') }}?v={{ filemtime(public_path('assets/js/config.js')) }}"></script>
@stack('scripts-head')
</head>
@@ -234,7 +234,7 @@
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}" class="btn btn-secondary me-2 wave-effect wave-light">Kembali</a>
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2 wave-effect wave-light">Kembali</a>
<button type="submit" class="btn btn-success data-submit wave-effect wave-light"><i class="ti tabler-device-floppy me-2"></i> Simpan Profil</button>
</div>
@@ -365,6 +365,7 @@
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-device-floppy me-1_5 icon-sm"></i>Simpan Akses Pasar
</button>
@@ -13,6 +13,7 @@
@push('scripts')
<script src="https://cdnjs.cloudflare.com/ajax/libs/croppie/2.6.5/croppie.min.js"></script>
@endpush
@section('content')
<div class="container-xxl flex-grow-1 container-p-y">
@@ -43,13 +44,18 @@
<div class="col-md-3">
<div class="user-avatar-section">
<div class=" d-flex align-items-center flex-column">
<button type="button" class="border-0 bg-transparent p-0 mb-3 rounded-3"
data-bs-toggle="modal" data-bs-target="#fotoProfilModal"
aria-label="Lihat foto profil petani" title="Lihat foto profil petani">
<img id="fotoPreview"
class="mb-3 shadow-sm rounded-3"
class="shadow-sm rounded-3"
src="{{ $petani->foto ? route('petani.show-image', ['id' => $petani->id, 'field' => 'foto']) : asset('assets/img/avatars/3.jpg') }}"
alt="Foto profil petani"
data-bs-toggle="tooltip" data-bs-placement="top"
data-bs-original-title="Klik untuk mengganti foto profil petani"
style="width: 120px !important; height: 120px !important; cursor:pointer; object-fit:cover; display:block; margin: 0 auto;" />
</button>
<button type="button" id="btnGantiFoto" class="btn btn-sm btn-outline-primary mb-3">
Ganti Foto
</button>
<button type="button" id="btnHapusFoto" class="btn btn-sm btn-outline-danger mb-4" style="display: {{ $petani->foto ? 'block' : 'none' }};">
Hapus Foto
@@ -113,7 +119,7 @@
<select class="form-control @error('pendidikan_terakhir') is-invalid @enderror" id="edit-petani-pendidikan-terakhir" name="pendidikan_terakhir">
<option value="">Pilih Pendidikan Terakhir</option>
@foreach($pendidikan as $value)
<option value="{{ $value['value'] }}" {{ old('pendidikan_terakhir', $petani->pendidikan_terakhir->value) == $value['value'] ? 'selected' : '' }}>{{ $value['label'] }}</option>
<option value="{{ $value['value'] }}" {{ old('pendidikan_terakhir', $petani->pendidikan_terakhir?->value) == $value['value'] ? 'selected' : '' }}>{{ $value['label'] }}</option>
@endforeach
</select>
@error('pendidikan_terakhir')<div class="error invalid-feedback">{{ $message }}</div>@enderror
@@ -123,7 +129,7 @@
<select class="form-control @error('status_perkawinan') is-invalid @enderror" id="edit-petani-status-pernikahan" name="status_perkawinan">
<option value="">Pilih Status Pernikahan</option>
@foreach($pernikahan as $value)
<option value="{{ $value['value'] }}" {{ old('status_perkawinan', $petani->status_perkawinan->value) == $value['value'] ? 'selected' : '' }}>{{ $value['label'] }}</option>
<option value="{{ $value['value'] }}" {{ old('status_perkawinan', $petani->status_perkawinan?->value) == $value['value'] ? 'selected' : '' }}>{{ $value['label'] }}</option>
@endforeach
</select>
@error('status_perkawinan')<div class="error invalid-feedback">{{ $message }}</div>@enderror
@@ -159,7 +165,7 @@
<select class="form-control @error('provinsi_id') is-invalid @enderror" id="edit-petani-provinsi" name="provinsi_id">
<option value="">Pilih Provinsi</option>
@foreach($provinsi as $p)
<option value="{{ $p->id }}" {{ old('provinsi_id', $petani->desaKelurahan->kecamatan->kabupatenKota->provinsi_id) == $p->id ? 'selected' : '' }}>{{ $p->nama }}</option>
<option value="{{ $p->id }}" {{ old('provinsi_id', $petani->desaKelurahan?->kecamatan?->kabupatenKota?->provinsi_id) == $p->id ? 'selected' : '' }}>{{ $p->nama }}</option>
@endforeach
</select>
@error('provinsi_id')<div class="error invalid-feedback">{{ $message }}</div>@enderror
@@ -168,7 +174,9 @@
<label class="form-label" for="edit-petani-kabupaten">Kabupaten/Kota<span class="text-danger">*</span></label>
<select class="form-control @error('kabupaten_id') is-invalid @enderror" id="edit-petani-kabupaten" name="kabupaten_id" >
<option value="">Pilih Kabupaten/Kota</option>
@if($petani->desaKelurahan?->kecamatan?->kabupatenKota)
<option value="{{ $petani->desaKelurahan->kecamatan->kabupatenKota->id }}" selected>{{ $petani->desaKelurahan->kecamatan->kabupatenKota->nama }}</option>
@endif
</select>
@error('kabupaten_id')<div class="error invalid-feedback">{{ $message }}</div>@enderror
</div>
@@ -176,7 +184,9 @@
<label class="form-label" for="edit-petani-kecamatan">Kecamatan<span class="text-danger">*</span></label>
<select class="form-control @error('kecamatan_id') is-invalid @enderror" id="edit-petani-kecamatan" name="kecamatan_id" >
<option value="">Pilih Kecamatan</option>
@if($petani->desaKelurahan?->kecamatan)
<option value="{{ $petani->desaKelurahan->kecamatan->id }}" selected>{{ $petani->desaKelurahan->kecamatan->nama }}</option>
@endif
</select>
@error('kecamatan_id')<div class="error invalid-feedback">{{ $message }}</div>@enderror
</div>
@@ -184,7 +194,9 @@
<label class="form-label" for="edit-petani-desa">Desa/Kelurahan<span class="text-danger">*</span></label>
<select class="form-control @error('desa_kelurahan_id') is-invalid @enderror" id="edit-petani-desa" name="desa_kelurahan_id" >
<option value="">Pilih Desa/Kelurahan</option>
@if($petani->desaKelurahan)
<option value="{{ $petani->desaKelurahan->id }}" selected>{{ $petani->desaKelurahan->nama }}</option>
@endif
</select>
@error('desa_kelurahan_id')<div class="error invalid-feedback">{{ $message }}</div>@enderror
</div>
@@ -242,7 +254,7 @@
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}" class="btn btn-secondary me-2 wave-effect wave-light">Kembali</a>
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2 wave-effect wave-light">Kembali</a>
<button type="submit" class="btn btn-success data-submit wave-effect wave-light"><i class="ti tabler-device-floppy me-2"></i> Simpan Profil</button>
</div>
@@ -253,6 +265,20 @@
</div>
</div>
<div class="modal fade" id="fotoProfilModal" tabindex="-1" aria-labelledby="fotoProfilModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="fotoProfilModalLabel">Foto Profil Petani</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Tutup"></button>
</div>
<div class="modal-body text-center">
<img id="fotoProfilBesar" class="img-fluid rounded-3" alt="Foto profil petani" style="max-height: 75vh; object-fit: contain;" />
</div>
</div>
</div>
</div>
<div class="modal fade" id="cropModal" tabindex="-1" aria-labelledby="cropModalLabel" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
@@ -344,7 +370,11 @@
let croppieInstance = null;
$('#fotoPreview').on('click', function () {
$('#fotoProfilModal').on('show.bs.modal', function () {
$('#fotoProfilBesar').attr('src', $('#fotoPreview').attr('src'));
});
$('#btnGantiFoto').on('click', function () {
$('#foto').val('');
$('#foto').click();
});
@@ -58,7 +58,7 @@
<hr class="my-2"> <h6 class="mb-1" style="font-size: 0.85rem;">Domisili</h6>
<p class="mb-0 text-muted" style="font-size: 0.75rem;">Alamat: {{ isset($petani) ? $petani->alamat : 'Alamat' }}</p>
<p class="mb-0 text-muted" style="font-size: 0.75rem; line-height: 1.3;">{{ isset($petani) ? $petani->desaKelurahan->nama : 'Desa/Kelurahan' }}, {{ $petani ? $petani->desaKelurahan->kecamatan->nama : 'Kecamatan' }}, {{ $petani ? $petani->desaKelurahan->kecamatan->kabupatenKota->nama : 'Kabupaten/Kota' }}, {{ $petani ? $petani->desaKelurahan->kecamatan->kabupatenKota->provinsi->nama : 'Provinsi' }}</p>
<p class="mb-0 text-muted" style="font-size: 0.75rem; line-height: 1.3;">{{ isset($petani) ? $petani->desaKelurahan?->nama ?? 'Belum diisi' : 'Desa/Kelurahan' }}, {{ $petani ? $petani->desaKelurahan?->kecamatan?->nama ?? 'Belum diisi' : 'Kecamatan' }}, {{ $petani ? $petani->desaKelurahan?->kecamatan?->kabupatenKota?->nama ?? 'Belum diisi' : 'Kabupaten/Kota' }}, {{ $petani ? $petani->desaKelurahan?->kecamatan?->kabupatenKota?->provinsi?->nama ?? 'Belum diisi' : 'Provinsi' }}</p>
</div>
</div>
</div>
@@ -116,7 +116,7 @@
</h5>
<p class="mb-0" style="font-size: 0.75rem;">
@if(!is_null($skorLahanTanaman))
<span class="me-1">{{ number_format($totalTanaman, 0, ',', '.') }} tanaman {{ $petani?->komoditas->nama }}</span>
<span class="me-1">{{ number_format($totalTanaman, 0, ',', '.') }} tanaman {{ $petani?->komoditas?->nama ?? 'Belum diisi' }}</span>
@else
<span class="me-1">Belum terisi</span>
@endif
@@ -2,6 +2,14 @@
@push('style')
<style>
#data-petani {
scroll-margin-top: 7rem;
}
.petani-row {
cursor: pointer;
}
.indicator-filter-row {
padding-bottom: 1.5rem;
padding-top: 1.5rem;
@@ -84,8 +92,8 @@
@section('content')
<div class="container-fluid flex-grow-1 container-p-y">
<div class="d-flex flex-row gap-3 mb-4">
<div class="w-25">
<div class="row g-3 mb-4">
<div class="col-12 col-md-4">
<x-summary-card
title="Petani"
count="{{ number_format($totalPetani ?? 0, 0, ',', '.') }}"
@@ -94,7 +102,7 @@
color="primary"
/>
</div>
<div class="w-25">
<div class="col-12 col-md-4">
<x-summary-card
title="Lahan & Tanaman"
count="{{ number_format($totalLuasLahan ?? 0, 2, ',', '.') }} ha"
@@ -103,7 +111,7 @@
color="success"
/>
</div>
<div class="w-25">
<div class="col-12 col-md-4">
<x-summary-card
title="Segmentasi"
count="{{ number_format((int) ($skor->firstWhere('segmen', 'Penggerak')->total ?? 0), 0, ',', '.') }} Penggerak"
@@ -265,7 +273,7 @@
</div>
</div>
<div class="card">
<div class="card" id="data-petani">
<div class="card-header border-bottom">
<h5 class="card-title mb-0">Data Petani</h5>
</div>
@@ -315,10 +323,10 @@
</thead>
<tbody>
@forelse($petani as $index => $item)
<tr>
<tr class="petani-row">
<td>{{ $loop->iteration }}</td>
<td>
<span class="text-heading d-block">{{ $item->nama }}</span>
<a href="{{ route('petani.edit', $item->id) }}" class="text-heading d-block">{{ $item->nama }}</a>
<small class="text-muted">
{{ $item->jenis_kelamin ? 'Laki-laki' : 'Perempuan' }}
@if(! is_null($item->umur))
@@ -423,6 +431,14 @@
});
});
$(document).on('click', '.petani-row', function (event) {
if ($(event.target).closest('a, button, input, select, textarea, label').length) {
return;
}
window.location.href = this.querySelector('.edit-record').href;
});
$(document).on('click', '.delete-record', function () {
const id = $(this).data('id');
const name = $(this).data('nama');
@@ -517,7 +517,7 @@
<div class="row mt-3">
<div class="col-12 text-end">
<a href="{{ route('petani.index') }}" class="btn btn-secondary me-2 wave-effect wave-light">Kembali</a>
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2 wave-effect wave-light">Kembali</a>
<button type="submit" class="btn btn-success data-submit wave-effect wave-light"><i class="ti tabler-device-floppy me-2"></i> Simpan Lahan</button>
</div>
</div>
@@ -406,8 +406,8 @@
<div class="row mt-3">
<div class="col-12 text-end">
<a href="{{ route('petani.index') }}" class="btn btn-secondary me-2 wave-effect wave-light">Kembali</a>
<button id="delete-lahan"type="button" class="btn btn-danger data-submit wave-effect wave-light"><i class="ti tabler-trash me-2"></i> Hapus Lahan</button>
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2 wave-effect wave-light">Kembali</a>
<button type="submit" class="btn btn-success data-submit wave-effect wave-light"><i class="ti tabler-device-floppy me-2"></i> Simpan Lahan</button>
</div>
</div>
@@ -86,6 +86,7 @@
</div>
<div class="mb-3">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-primary">Simpan Perubahan</button>
</div>
</form>
@@ -77,6 +77,7 @@
</table>
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan
@@ -167,11 +167,14 @@
Tambah Pelatihan
</button>
<div class="d-flex flex-wrap justify-content-end gap-2">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Riwayat Pelatihan
</button>
</div>
</div>
</form>
</div>
</div>
@@ -347,11 +350,14 @@
Tambah Sertifikasi
</button>
<div class="d-flex flex-wrap justify-content-end gap-2">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Sertifikasi Keahlian
</button>
</div>
</div>
</form>
</div>
</div>
@@ -499,6 +499,7 @@
</div>
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
Simpan
</button>
@@ -530,6 +530,7 @@
</div>
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan
@@ -1039,6 +1040,7 @@
</div>
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan
@@ -1352,6 +1354,7 @@
</div>
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan
@@ -1542,6 +1545,7 @@
</div>
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan
@@ -1678,10 +1682,13 @@
Tambah Komoditas
</button>
<div class="d-flex flex-wrap justify-content-end gap-2">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary">Kembali</a>
<button type="submit" class="btn btn-success">
Simpan Produktivitas
Simpan
</button>
</div>
</div>
</form>
</div>
</div>
@@ -1725,6 +1732,7 @@
</div>
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan
@@ -399,6 +399,7 @@
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan
@@ -567,6 +568,7 @@
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan
@@ -696,6 +698,7 @@
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan
@@ -915,6 +918,7 @@
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan
@@ -1043,6 +1047,7 @@
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan
@@ -398,6 +398,7 @@
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan
@@ -566,6 +567,7 @@
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan
@@ -694,6 +696,7 @@
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan
@@ -912,6 +915,7 @@
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan
@@ -1039,6 +1043,7 @@
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-secondary me-2">Kembali</a>
<button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan
@@ -85,7 +85,7 @@
</div>
<div class="mt-4 text-end">
<a href="{{ route('petani.index') }}" class="btn btn-label-secondary me-2">Kembali</a>
<a href="{{ route('petani.index') }}#data-petani" class="btn btn-label-secondary me-2">Kembali</a>
</div>
</form>
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,117 @@
<?php
use App\Enums\AksesPasar\PraktikFermentasi;
use App\Http\Controllers\Web\Dashboard\SummaryController;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
beforeEach(function () {
Schema::create('master_komoditas', function (Blueprint $table) {
$table->id();
$table->string('nama');
});
foreach ([
'master_kabupaten_kota' => 'provinsi_id',
'master_kecamatan' => 'kabupaten_kota_id',
'master_desa_kelurahan' => 'kecamatan_id',
] as $name => $parent) {
Schema::create($name, function (Blueprint $table) use ($parent) {
$table->id();
$table->integer($parent);
});
}
Schema::create('petani', function (Blueprint $table) {
$table->id();
$table->integer('komoditas_id')->nullable();
$table->integer('desa_kelurahan_id');
$table->softDeletes();
});
Schema::create('akses_pasar', function (Blueprint $table) {
$table->id();
$table->integer('petani_id');
$table->string('praktik_fermentasi')->nullable();
$table->softDeletes();
});
Schema::create('petani_skor', function (Blueprint $table) {
$table->id();
$table->integer('petani_id');
$table->float('skor_kesiapan_adopsi');
$table->softDeletes();
});
DB::table('master_komoditas')->insert([
['id' => 1, 'nama' => 'Kakao'], ['id' => 2, 'nama' => 'Kopi'], ['id' => 3, 'nama' => 'Vanili'],
]);
DB::table('master_kabupaten_kota')->insert(['id' => 1, 'provinsi_id' => 1]);
DB::table('master_kecamatan')->insert(['id' => 1, 'kabupaten_kota_id' => 1]);
DB::table('master_desa_kelurahan')->insert([
['id' => 1, 'kecamatan_id' => 1], ['id' => 2, 'kecamatan_id' => 1],
]);
foreach ([
[1, 1, 1, PraktikFermentasi::RUTIN_SENDIRI->value],
[2, 1, 1, PraktikFermentasi::RUTIN_KELOMPOK->value],
[3, 2, 2, PraktikFermentasi::PERNAH_SENDIRI->value],
[4, 2, 2, PraktikFermentasi::PERNAH_KELOMPOK->value],
[5, 3, 2, PraktikFermentasi::BELUM_PERNAH->value],
[6, 3, 2, null],
[7, 1, 1, ''],
[8, 2, 2, PraktikFermentasi::RUTIN_SENDIRI->value],
[9, null, 2, PraktikFermentasi::BELUM_PERNAH->value],
[10, 1, 1, PraktikFermentasi::RUTIN_SENDIRI->value],
[11, 1, 1, 'Tidak dikenal'],
] as [$id, $commodity, $village, $practice]) {
DB::table('petani')->insert([
'id' => $id, 'komoditas_id' => $commodity, 'desa_kelurahan_id' => $village,
'deleted_at' => $id === 10 ? '2026-01-01' : null,
]);
DB::table('akses_pasar')->insert([
'petani_id' => $id, 'praktik_fermentasi' => $practice,
'deleted_at' => $id === 8 ? '2026-01-01' : null,
]);
}
DB::table('petani_skor')->insert([
['petani_id' => 1, 'skor_kesiapan_adopsi' => 4.1, 'deleted_at' => null],
['petani_id' => 2, 'skor_kesiapan_adopsi' => 2.6, 'deleted_at' => null],
['petani_id' => 3, 'skor_kesiapan_adopsi' => 4.4, 'deleted_at' => '2026-01-01'],
]);
$controller = app(SummaryController::class);
$query = new ReflectionMethod($controller, 'filteredAksesPasar');
$chart = new ReflectionMethod($controller, 'fermentationPracticeByCommodityChart');
$this->fermentationChart = fn (array $filters = []) => $chart->invoke(
$controller, $query->invoke($controller, new Request($filters)),
);
});
test('fermentation groups individual and collective responses by commodity', function () {
expect(($this->fermentationChart)())->toBe([
'labels' => ['Belum ditentukan', 'Kakao', 'Kopi', 'Vanili'],
'series' => [
['name' => 'Ya, rutin', 'data' => [0, 2, 0, 0]],
['name' => 'Pernah mencoba', 'data' => [0, 0, 2, 0]],
['name' => 'Belum pernah', 'data' => [1, 0, 0, 1]],
],
]);
});
test('fermentation respects region and commodity filters', function () {
$chart = ($this->fermentationChart)(['desaKelurahan_id' => 2, 'komoditas_id' => 2]);
expect($chart['labels'])->toBe(['Kopi']);
expect(array_column($chart['series'], 'data'))->toBe([[0], [2], [0]]);
expect(($this->fermentationChart)(['desaKelurahan_id' => 1])['labels'])->toBe(['Kakao']);
});
test('fermentation respects segment filters and excludes deleted scores', function () {
$chart = ($this->fermentationChart)(['segmen' => 'Pemerhati']);
expect($chart['labels'])->toBe(['Kakao']);
expect(array_column($chart['series'], 'data'))->toBe([[1], [0], [0]]);
expect(($this->fermentationChart)(['segmen' => 'Penggerak'])['labels'])->toBe(['Kakao']);
});
test('fermentation returns empty grouped series when no matching responses exist', function () {
$chart = ($this->fermentationChart)(['provinsi_id' => 99]);
expect($chart['labels'])->toBe([]);
expect(array_column($chart['series'], 'data'))->toBe([[], [], []]);
});
+129
View File
@@ -0,0 +1,129 @@
<?php
use App\Enums\PraktikBudidaya\FrekuensiPemangkasanUtama;
use App\Enums\PraktikBudidaya\PemberianPupuk;
use App\Http\Controllers\Web\Dashboard\SummaryController;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
beforeEach(function () {
foreach ([
'master_provinsi' => null,
'master_kabupaten_kota' => 'provinsi_id',
'master_kecamatan' => 'kabupaten_kota_id',
'master_desa_kelurahan' => 'kecamatan_id',
] as $name => $parent) {
Schema::create($name, function (Blueprint $table) use ($parent) {
$table->id();
$table->string('nama');
$table->text('path')->nullable();
if ($parent) {
$table->integer($parent);
}
$table->softDeletes();
});
DB::table($name)->insert(['id' => 1, 'nama' => 'Wilayah'] + ($parent ? [$parent => 1] : []));
}
Schema::create('master_komoditas', function (Blueprint $table) {
$table->id();
$table->string('nama');
});
foreach (['petani', 'profiles'] as $name) {
Schema::create($name, function (Blueprint $table) {
$table->id();
$table->string('nama');
$table->softDeletes();
});
}
foreach (['map_lahan', 'map_tanaman'] as $name) {
Schema::create($name, function (Blueprint $table) use ($name) {
$table->id();
$table->integer('komoditas_id');
$table->integer('umur_tanaman')->nullable();
foreach (['serangan_hama', 'penyakit_tanaman', 'jenis_hama', 'jenis_penyakit'] as $column) {
$table->text($column)->nullable();
}
if ($name === 'map_lahan') {
$table->integer('petani_id')->nullable();
$table->integer('profile_id')->nullable();
$table->integer('desa_kelurahan_id');
$table->text('path');
$table->float('luas_lahan');
$table->integer('jumlah_tanaman');
} else {
$table->integer('lahan_id');
}
$table->timestamps();
$table->softDeletes();
});
}
Schema::create('praktik_budidaya', function (Blueprint $table) {
$table->id();
$table->integer('petani_id');
$table->integer('frekuensi_pemangkasan_utama');
$table->integer('pemberian_pupuk');
$table->text('kebiasaan_konservasi_tanah');
$table->timestamps();
$table->softDeletes();
});
DB::table('master_komoditas')->insert(['id' => 1, 'nama' => 'Kakao']);
DB::table('petani')->insert(['id' => 1, 'nama' => 'Petani A']);
DB::table('profiles')->insert(['id' => 1, 'nama' => 'Pemilik B']);
$land = [
'komoditas_id' => 1, 'desa_kelurahan_id' => 1, 'luas_lahan' => 1.5,
'jumlah_tanaman' => 50, 'umur_tanaman' => 8,
'path' => '[[[-8.1,121.1],[-8.2,121.1],[-8.1,121.2]]]',
'jenis_hama' => '["Penggerek buah"]',
];
DB::table('map_lahan')->insert($land + ['id' => 1, 'petani_id' => 1]);
DB::table('map_lahan')->insert($land + ['id' => 2, 'profile_id' => 1]);
DB::table('map_lahan')->insert($land + ['id' => 3, 'petani_id' => 1, 'deleted_at' => '2026-01-01']);
DB::table('map_tanaman')->insert([
'lahan_id' => 1, 'komoditas_id' => 1, 'umur_tanaman' => 4,
'jenis_penyakit' => '["Busuk buah"]',
]);
foreach ([
['2026-01-01', 1, null],
['2026-02-01', 4, null],
['2026-03-01', 5, '2026-03-02'],
] as [$updatedAt, $value, $deletedAt]) {
DB::table('praktik_budidaya')->insert([
'petani_id' => 1, 'frekuensi_pemangkasan_utama' => $value, 'pemberian_pupuk' => $value,
'kebiasaan_konservasi_tanah' => '["Menumpuk daun kering/sisa tanaman di sekitar pohon"]',
'updated_at' => $updatedAt, 'deleted_at' => $deletedAt,
]);
}
$controller = app(SummaryController::class);
$method = new ReflectionMethod($controller, 'productionMapLevelData');
$this->mapLevels = fn (array $filters = []) => $method->invoke($controller, new Request($filters));
});
test('land map combines plants and OPT with the latest active cultivation practice', function () {
$lands = collect(($this->mapLevels)()['lahan'])->keyBy('id');
expect($lands->keys()->all())->toBe([1, 2]);
expect($lands[1])->toMatchArray([
'nama_petani' => 'Petani A',
'luas' => 1.5,
'komoditas' => [['nama' => 'Kakao', 'jumlah' => 1, 'umur' => 4.0]],
'opt' => ['Busuk Buah', 'Penggerek Buah'],
'pemangkasan' => FrekuensiPemangkasanUtama::DUA_KALI_TAHUN->label(),
'pemupukan' => PemberianPupuk::JENIS_DOSIS_SESUAI_WAKTU_CUKUP_TEPAT->label(),
'praktik_baik' => ['Menumpuk daun kering/sisa tanaman di sekitar pohon'],
]);
expect($lands[2])->toMatchArray([
'nama_petani' => 'Pemilik B',
'komoditas' => [['nama' => 'Kakao', 'jumlah' => 50, 'umur' => 8.0]],
'pemangkasan' => 'Belum ada data',
'pemupukan' => 'Belum ada data',
'praktik_baik' => [],
]);
});
test('combined map respects region and commodity filters', function () {
expect(($this->mapLevels)(['desaKelurahan_id' => 1, 'komoditas_id' => 1])['lahan'])->toHaveCount(2);
expect(($this->mapLevels)(['desaKelurahan_id' => 99])['lahan'])->toBe([]);
expect(($this->mapLevels)(['komoditas_id' => 99])['lahan'])->toBe([]);
});