12 Commits
40 changed files with 3009 additions and 1034 deletions
+6
View File
@@ -63,3 +63,9 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" 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=
@@ -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('fasilitator'), 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', []));
}
});
}
}
@@ -3,6 +3,8 @@
namespace App\Http\Controllers\Web\Dashboard; namespace App\Http\Controllers\Web\Dashboard;
use App\Enums\AksesPasar\PraktikFermentasi; use App\Enums\AksesPasar\PraktikFermentasi;
use App\Enums\AksesPasar\SkorKesadaranPremium;
use App\Enums\AksesPasar\SkorPengalaman;
use App\Enums\KeparahanSerangan; use App\Enums\KeparahanSerangan;
use App\Enums\KondisiTanaman; use App\Enums\KondisiTanaman;
use App\Enums\PenyakitTanaman; use App\Enums\PenyakitTanaman;
@@ -114,70 +116,196 @@ class SummaryController extends Controller
'kecamatan_id', 'kecamatan_id',
'desaKelurahan_id', 'desaKelurahan_id',
'komoditas_id', 'komoditas_id',
'segmen',
]), ]),
] + $filterData + $this->marketDashboardData($request)); ] + $filterData + $this->marketDashboardData($request));
} }
private function marketDashboardData(Request $request): array private function marketDashboardData(Request $request): array
{ {
if (! $request->filled('komoditas_id')) { $products = [
return [ ['name' => 'Kakao', 'caption' => 'Harga Biji Kering', 'product' => 'Biji kering/Biji Kupas', 'variant' => 'success'],
'selectedKomoditas' => null, ['name' => 'Vanili', 'caption' => 'Harga Buah Segar', 'product' => 'Buah segar hasil petik', 'variant' => 'primary'],
'priceCard' => null, ['name' => 'Kopi', 'caption' => 'Harga Ceri Merah', 'product' => 'Buah segar hasil petik', 'variant' => 'warning'],
['name' => 'Kemiri', 'caption' => 'Harga Gelondong', 'product' => 'Buah segar hasil petik', 'variant' => 'info'],
]; ];
} $commodities = Komoditas::query()
->whereIn('nama', array_column($products, 'name'))
$selectedKomoditas = Komoditas::query() ->pluck('id', 'nama');
->whereKey($request->komoditas_id)
->first(['id', 'nama']);
if (! $selectedKomoditas) {
return [
'selectedKomoditas' => null,
'priceCard' => null,
];
}
$productCaption = $this->marketProductCaption($selectedKomoditas->nama);
$productLabel = 'Buah segar hasil petik';
$petaniIds = $this->filteredPetaniIdsQuery($request); $petaniIds = $this->filteredPetaniIdsQuery($request);
$priceQuery = DB::table('harga_jual_panen') $prices = DB::table('harga_jual_panen')
->join('petani', 'petani.id', '=', 'harga_jual_panen.petani_id')
->whereIn('harga_jual_panen.petani_id', $petaniIds) ->whereIn('harga_jual_panen.petani_id', $petaniIds)
->whereIn('petani.komoditas_id', $commodities->values())
->where('harga_jual_panen.harga_panen_terakhir', '>', 0) ->where('harga_jual_panen.harga_panen_terakhir', '>', 0)
->where('harga_jual_panen.bentuk_produk', $productLabel); ->whereIn('harga_jual_panen.bentuk_produk', array_unique(array_column($products, 'product')))
->select('petani.komoditas_id', 'harga_jual_panen.bentuk_produk')
$averagePrice = (float) (clone $priceQuery)->avg('harga_jual_panen.harga_panen_terakhir'); ->selectRaw('AVG(harga_jual_panen.harga_panen_terakhir) as average_price, COUNT(*) as records')
$totalRecords = (int) (clone $priceQuery)->count(); ->groupBy('petani.komoditas_id', 'harga_jual_panen.bentuk_produk')
->get();
return [ return [
'selectedKomoditas' => $selectedKomoditas, 'priceCards' => collect($products)->map(function (array $product) use ($commodities, $prices) {
'priceCard' => [ $commodityId = $commodities->get($product['name']);
'value' => $averagePrice > 0 $price = $commodityId === null ? null : $prices->first(fn ($row) =>
? 'Rp '.number_format($averagePrice, 0, ',', '.') (int) $row->komoditas_id === (int) $commodityId
: 'Rp 0', && $row->bentuk_produk === $product['product']
'label' => 'Harga Jual', );
'caption' => $productCaption,
'description' => 'Harga rata-rata buah segar hasil petik di tingkat petani', return [
'records' => $totalRecords, 'value' => 'Rp '.number_format((float) ($price?->average_price ?? 0), 0, ',', '.'),
'label' => $product['name'].' - '.$product['caption'],
'description' => 'Harga rata-rata panen terakhir di tingkat petani',
'records' => (int) ($price?->records ?? 0),
'unit' => '/kg', 'unit' => '/kg',
'variant' => 'success', 'variant' => $product['variant'],
'icon' => 'tabler-cash', 'icon' => 'tabler-cash',
], ];
})->all(),
'marketCharts' => $this->marketChannelCharts($request),
'priceTrend' => $this->marketPriceTrend($request),
'premiumAwareness' => $this->marketPremiumAwareness($request),
]; ];
} }
private function marketProductCaption(string $komoditasName): string private function marketPriceTrend(Request $request): array
{ {
$normalized = str($komoditasName)->lower()->trim()->toString(); $products = ['Buah segar hasil petik', 'Biji basah', 'Biji kering/Biji Kupas', 'Terfermentasi (Rp/kg)'];
$periods = [
'harga_dua_tahun_lalu' => 'Dua Tahun Lalu',
'harga_tahun_lalu' => 'Tahun Lalu',
'harga_panen_terakhir' => 'Panen Terakhir',
];
$commodities = Komoditas::query()
->when($request->filled('komoditas_id'), fn ($query) => $query->where('id', $request->komoditas_id))
->orderBy('nama')
->get(['id', 'nama']);
return match (true) { $query = DB::table('harga_jual_panen')
str_contains($normalized, 'kakao') => 'Harga Buah Kakao', ->join('petani', 'petani.id', '=', 'harga_jual_panen.petani_id')
str_contains($normalized, 'vanili') => 'Harga Curing', ->whereIn('harga_jual_panen.petani_id', $this->filteredPetaniIdsQuery($request))
str_contains($normalized, 'kopi') => 'Harga Ceri Merah', ->whereIn('petani.komoditas_id', $commodities->pluck('id'))
str_contains($normalized, 'kemiri') => 'Harga Gelondong', ->whereIn('harga_jual_panen.bentuk_produk', $products)
default => 'Harga Buah Segar', ->select('petani.komoditas_id', 'harga_jual_panen.bentuk_produk')
}; ->groupBy('petani.komoditas_id', 'harga_jual_panen.bentuk_produk');
foreach (array_keys($periods) as $field) {
$query->selectRaw("AVG(CASE WHEN harga_jual_panen.{$field} > 0 THEN harga_jual_panen.{$field} END) as {$field}");
}
$prices = $query->get()->groupBy('komoditas_id');
return [
'periods' => array_values($periods),
'commodities' => $commodities->map(function ($commodity) use ($products, $periods, $prices) {
$rows = $prices->get($commodity->id, collect())->keyBy('bentuk_produk');
return [
'id' => $commodity->id,
'name' => $commodity->nama,
'series' => collect($products)->map(function ($product) use ($periods, $rows) {
$row = $rows->get($product);
return [
'name' => $product === 'Terfermentasi (Rp/kg)' ? 'Terfermentasi' : $product,
'data' => array_map(fn ($field) => isset($row->{$field}) ? round((float) $row->{$field}, 2) : null, array_keys($periods)),
];
})->all(),
];
})->all(),
];
}
private function marketChannelCharts(Request $request): array
{
$rows = DB::table('akses_pasar')
->whereNull('deleted_at')
->whereIn('petani_id', $this->filteredPetaniIdsQuery($request))
->orderBy('id')
->get(['petani_id', 'tujuan_penjualan', 'sifat_hubungan_bisnis', 'frekuensi_penjualan', 'skor_pengalaman'])
->unique('petani_id');
$channels = ['Tengkulak/pedagang lokal', 'Koperasi', 'BUMDes', 'Langsung ke pembeli/eksportir', 'Lainnya'];
$relationships = [
'Bebas, bisa pindah pembeli kapan saja' => 'Bebas pindah pembeli',
'Langganan tetap, tapi tidak terikat' => 'Langganan tetap',
'Berbasis hutang, harus jual ke sana karena ada pinjaman' => 'Berbasis hutang',
'Titip jual,dibayar setelah produk terjual' => 'Titip jual',
'Ada komitmen volume, sudah ada kesepakatan jumlah' => 'Komitmen volume',
'Semi-kontrak, ada kesepakatan harga tertentu' => 'Semi-kontrak',
];
$timing = ['Langsung setelah panen', 'Ditumpuk dulu beberapa waktu', 'Saat butuh uang mendesak'];
$experience = collect(SkorPengalaman::options())
->mapWithKeys(fn ($option) => [$option['value'] => $option['label']])
->all();
return [
'saluranPenjualan' => $this->marketResponseChart($rows, 'tujuan_penjualan', array_combine($channels, $channels), true),
'hubunganPembeli' => $this->marketResponseChart($rows, 'sifat_hubungan_bisnis', $relationships),
'waktuPenjualan' => $this->marketResponseChart($rows, 'frekuensi_penjualan', array_combine($timing, $timing)),
'aksesSaluranLain' => $this->marketResponseChart($rows, 'skor_pengalaman', $experience),
];
}
private function marketPremiumAwareness(Request $request): array
{
$rows = DB::table('akses_pasar')
->whereNull('deleted_at')
->whereIn('petani_id', $this->filteredPetaniIdsQuery($request))
->orderBy('id')
->get(['petani_id', 'skor_kesadaran_premium'])
->unique('petani_id');
$options = collect(SkorKesadaranPremium::options())
->mapWithKeys(fn ($option) => [$option['value'] => $option['label']])
->all();
$chart = $this->marketResponseChart($rows, 'skor_kesadaran_premium', $options);
$chart['fullLabels'] = [
'Belum pernah mendengar sama sekali',
'Pernah mendengar tapi belum paham persyaratannya',
'Pernah mendengar dan sedikit memahami persyaratannya',
'Sudah sangat memahami persyaratannya, tapi belum pernah mengakses pasar premium',
'Sudah mengakses dan mendapat harga premium secara konsisten',
];
return $chart;
}
private function marketResponseChart($rows, string $field, array $options, bool $multiple = false): array
{
$counts = array_fill_keys(array_keys($options), 0);
$respondents = 0;
foreach ($rows as $row) {
$values = $multiple ? $this->arrayValues($row->{$field}) : [$row->{$field}];
$answers = [];
foreach ($values as $value) {
if (is_string($value) || is_int($value)) {
$value = is_string($value) ? trim($value) : $value;
if (array_key_exists($value, $options)) {
$answers[$value] = true;
}
}
}
if ($answers === []) {
continue;
}
$respondents++;
foreach (array_keys($answers) as $answer) {
$counts[$answer]++;
}
}
return [
'labels' => array_values($options),
'fullLabels' => $field === 'skor_pengalaman' ? array_values($options) : array_keys($options),
'series' => array_values($counts),
'respondents' => $respondents,
'percentages' => array_map(fn ($count) => $respondents > 0 ? round($count / $respondents * 100, 1) : 0, array_values($counts)),
];
} }
private function productionDashboardData(Request $request): array private function productionDashboardData(Request $request): array
@@ -223,7 +351,6 @@ class SummaryController extends Controller
$healthSourceName = $tanaman->isNotEmpty() ? 'tanaman' : 'lahan'; $healthSourceName = $tanaman->isNotEmpty() ? 'tanaman' : 'lahan';
$landMetrics = $this->karakteristikLandMetrics($request); $landMetrics = $this->karakteristikLandMetrics($request);
$totalPetani = $landMetrics['total_petani'];
$totalLuasLahan = $landMetrics['total_luas_lahan']; $totalLuasLahan = $landMetrics['total_luas_lahan'];
$avgLuasPerPetani = $landMetrics['ratarata_luas_lahan']; $avgLuasPerPetani = $landMetrics['ratarata_luas_lahan'];
$actualProductivity = $this->actualProductivityByCommodityChart($request); $actualProductivity = $this->actualProductivityByCommodityChart($request);
@@ -233,14 +360,6 @@ class SummaryController extends Controller
$aksesPasar = $this->filteredAksesPasar($request); $aksesPasar = $this->filteredAksesPasar($request);
$kpiCards = [ $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, ',', '.'), 'value' => number_format($totalLuasLahan, 2, ',', '.'),
'unit' => 'ha', 'unit' => 'ha',
@@ -294,7 +413,7 @@ class SummaryController extends Controller
: $value, : $value,
), ),
'praktikSortasiPanen' => $this->enumOptionChart($surveyPanen, 'praktik_sortasi', PraktikSortasi::class), 'praktikSortasiPanen' => $this->enumOptionChart($surveyPanen, 'praktik_sortasi', PraktikSortasi::class),
'praktikFermentasiPascapanen' => $this->fermentationPracticeChart($aksesPasar), 'praktikFermentasiPascapanen' => $this->fermentationPracticeByCommodityChart($aksesPasar),
'produktivitasAktualPerKomoditas' => $actualProductivity, 'produktivitasAktualPerKomoditas' => $actualProductivity,
], ],
]; ];
@@ -312,7 +431,6 @@ class SummaryController extends Controller
'desa' => (string) $request->input('desaKelurahan_id', ''), 'desa' => (string) $request->input('desaKelurahan_id', ''),
], ],
'levels' => $levels, 'levels' => $levels,
'cultivationLevels' => $this->productionCultivationMapLevelData($request, $levels),
'information' => $this->productionMapInformationData($request), 'information' => $this->productionMapInformationData($request),
]; ];
} }
@@ -511,6 +629,7 @@ class SummaryController extends Controller
'map_lahan.path', 'map_lahan.path',
'map_lahan.luas_lahan', 'map_lahan.luas_lahan',
'map_lahan.jumlah_tanaman', 'map_lahan.jumlah_tanaman',
'map_lahan.petani_id',
'map_lahan.umur_tanaman', 'map_lahan.umur_tanaman',
'map_lahan.serangan_hama', 'map_lahan.serangan_hama',
'map_lahan.penyakit_tanaman', 'map_lahan.penyakit_tanaman',
@@ -537,58 +656,10 @@ class SummaryController extends Controller
]) ])
->groupBy('lahan_id'); ->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(); $petaniIds = $mapLahan->pluck('petani_id')->filter()->unique()->values();
$practiceByPetani = DB::table('praktik_budidaya') $practiceByPetani = DB::table('praktik_budidaya')
->whereNull('praktik_budidaya.deleted_at') ->whereNull('praktik_budidaya.deleted_at')
->when($petaniIds->isNotEmpty(), fn ($query) => $query->whereIn('praktik_budidaya.petani_id', $petaniIds)) ->whereIn('praktik_budidaya.petani_id', $petaniIds)
->when($petaniIds->isEmpty(), fn ($query) => $query->whereRaw('1 = 0'))
->orderByDesc('praktik_budidaya.updated_at') ->orderByDesc('praktik_budidaya.updated_at')
->get([ ->get([
'praktik_budidaya.petani_id', 'praktik_budidaya.petani_id',
@@ -601,7 +672,10 @@ class SummaryController extends Controller
$geoLahan = $mapLahan $geoLahan = $mapLahan
->filter(fn ($lahan) => $this->hasMapPolygon($lahan->path)) ->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); $practice = $practiceByPetani->get($lahan->petani_id);
return [ return [
@@ -610,6 +684,8 @@ class SummaryController extends Controller
'polygon' => $lahan->path, 'polygon' => $lahan->path,
'nama_petani' => $lahan->petani_nama ?? $lahan->profile_nama ?? 'N/A', 'nama_petani' => $lahan->petani_nama ?? $lahan->profile_nama ?? 'N/A',
'luas' => (float) $lahan->luas_lahan, 'luas' => (float) $lahan->luas_lahan,
'komoditas' => $komoditas,
'opt' => $opt,
'pemangkasan' => $this->enumLabel(FrekuensiPemangkasanUtama::class, $practice->frekuensi_pemangkasan_utama ?? null, 'Belum ada data'), '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'), 'pemupukan' => $this->enumLabel(PemberianPupuk::class, $practice->pemberian_pupuk ?? null, 'Belum ada data'),
'praktik_baik' => $this->arrayValues($practice->kebiasaan_konservasi_tanah ?? null), 'praktik_baik' => $this->arrayValues($practice->kebiasaan_konservasi_tanah ?? null),
@@ -619,10 +695,10 @@ class SummaryController extends Controller
->all(); ->all();
return [ return [
'provinsi' => $baseLevels['provinsi'] ?? [], 'provinsi' => array_values($geoProvinsi),
'kabupaten' => $baseLevels['kabupaten'] ?? [], 'kabupaten' => array_values($geoKabupaten),
'kecamatan' => $baseLevels['kecamatan'] ?? [], 'kecamatan' => array_values($geoKecamatan),
'desa' => $baseLevels['desa'] ?? [], 'desa' => array_values($geoDesa),
'lahan' => $geoLahan, 'lahan' => $geoLahan,
]; ];
} }
@@ -826,7 +902,12 @@ class SummaryController extends Controller
return DB::table('akses_pasar') return DB::table('akses_pasar')
->whereNull('akses_pasar.deleted_at') ->whereNull('akses_pasar.deleted_at')
->whereIn('akses_pasar.petani_id', $petaniIds) ->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) private function filteredSurveyProduktivitas(Request $request)
@@ -1254,10 +1335,10 @@ class SummaryController extends Controller
]; ];
} }
private function fermentationPracticeChart($rows): array private function fermentationPracticeByCommodityChart($rows): array
{ {
$labels = ['Ya, rutin', 'Pernah mencoba', 'Belum pernah']; $labels = ['Ya, rutin', 'Pernah mencoba', 'Belum pernah'];
$counts = collect($labels)->mapWithKeys(fn ($label) => [$label => 0]); $matrix = [];
foreach ($rows as $row) { foreach ($rows as $row) {
$label = $this->fermentationPracticeLabel($row->praktik_fermentasi ?? null); $label = $this->fermentationPracticeLabel($row->praktik_fermentasi ?? null);
@@ -1266,12 +1347,21 @@ class SummaryController extends Controller
continue; 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 [ return [
'labels' => $labels, 'labels' => array_keys($matrix),
'series' => collect($labels)->map(fn ($label) => (int) ($counts[$label] ?? 0))->values()->all(), 'series' => collect($labels)
->map(fn ($label) => [
'name' => $label,
'data' => collect($matrix)->map(fn ($counts) => (int) $counts[$label])->values()->all(),
])
->all(),
]; ];
} }
@@ -24,7 +24,7 @@ class AksesPasarController extends Controller
{ {
$petani = Petani::findOrFail($petaniId); $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(); $hargaJualPanen = HargaJualPanen::where('petani_id', $petaniId)->get();
$hargaJualRows = collect([ $hargaJualRows = collect([
'Buah segar hasil petik', 'Buah segar hasil petik',
@@ -50,18 +50,26 @@ class AksesPasarController extends Controller
} }
public function store(Request $request) public function store(Request $request)
{ {
$validated = $this->validateData($request); $validated = $this->validateData($request);
DB::beginTransaction(); DB::beginTransaction();
try { try {
$aksesPasarData = $this->aksesPasarPayload($validated); $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['uid'] = 'AKP-' . Str::uuid();
$aksesPasarData['created_by'] = Auth::id(); $aksesPasarData['created_by'] = Auth::id();
$aksesPasarData['updated_by'] = Auth::id(); $aksesPasarData['updated_by'] = Auth::id();
$aksesPasar = AksesPasar::create($aksesPasarData); $aksesPasar = AksesPasar::create($aksesPasarData);
}
if ($request->has('harga_jual')) { if ($request->has('harga_jual')) {
$this->syncHargaJual($request->petani_id, $validated['harga_jual'] ?? [], Auth::id()); $this->syncHargaJual($request->petani_id, $validated['harga_jual'] ?? [], Auth::id());
@@ -75,7 +83,7 @@ class AksesPasarController extends Controller
DB::rollBack(); DB::rollBack();
return redirect()->back()->withErrors(['error' => 'Gagal menyimpan data: ' . $e->getMessage()])->withInput(); return redirect()->back()->withErrors(['error' => 'Gagal menyimpan data: ' . $e->getMessage()])->withInput();
} }
} }
public function update(Request $request, $petaniId, $id) public function update(Request $request, $petaniId, $id)
{ {
@@ -118,7 +126,8 @@ class AksesPasarController extends Controller
'skorPengalamanBerhentiValues' => [ 'skorPengalamanBerhentiValues' => [
SkorPengalaman::PERNAH_BERHENTI_TIDAK_ADA_AKSES->value, SkorPengalaman::PERNAH_BERHENTI_TIDAK_ADA_AKSES->value,
SkorPengalaman::PERNAH_BERHENTI_MUNGKIN_KEMBALI->value SkorPengalaman::PERNAH_BERHENTI_MUNGKIN_KEMBALI->value
] ],
'sumberInfoLainnyaValue' => 'Lainnya',
]; ];
} }
@@ -155,6 +164,14 @@ class AksesPasarController extends Controller
'nullable', 'string', 'max:255' '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())], 'praktik_fermentasi' => ['nullable', 'string', Rule::in(PraktikFermentasi::values())],
'proses_fermentasi' => [ 'proses_fermentasi' => [
Rule::requiredIf(fn () => filled($request->input('praktik_fermentasi')) && $request->input('praktik_fermentasi') !== PraktikFermentasi::BELUM_PERNAH->value), Rule::requiredIf(fn () => filled($request->input('praktik_fermentasi')) && $request->input('praktik_fermentasi') !== PraktikFermentasi::BELUM_PERNAH->value),
@@ -216,7 +233,6 @@ class AksesPasarController extends Controller
{ {
$payload = collect($validated)->except(['harga_jual'])->all(); $payload = collect($validated)->except(['harga_jual'])->all();
// Bersihkan input teks "Lainnya" jika checkbox Lainnya tidak dipilih
if (!in_array('Lainnya', $payload['tujuan_penjualan'] ?? [], true)) { if (!in_array('Lainnya', $payload['tujuan_penjualan'] ?? [], true)) {
$payload['tujuan_penjualan_lainnya'] = null; $payload['tujuan_penjualan_lainnya'] = null;
} }
@@ -232,6 +248,10 @@ class AksesPasarController extends Controller
$payload['saluran_terdahulu_lainnya'] = null; $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) { if (($payload['praktik_fermentasi'] ?? null) === PraktikFermentasi::BELUM_PERNAH->value) {
$payload['proses_fermentasi'] = null; $payload['proses_fermentasi'] = null;
$payload['model_fermentasi_memungkinkan'] = null; $payload['model_fermentasi_memungkinkan'] = null;
@@ -274,7 +294,7 @@ class AksesPasarController extends Controller
$data['harga_jual'][$index]['harga_tahun_lalu'] = $this->currencyDigits($item['harga_tahun_lalu']); $data['harga_jual'][$index]['harga_tahun_lalu'] = $this->currencyDigits($item['harga_tahun_lalu']);
} }
if (array_key_exists('harga_dua_tahun_lalu', $item)) { if (array_key_exists('harga_dua_tahun_lalu', $item)) {
$data['harga_jual'][$index]['harga_dua_tahun_lalu'] = $this->currencyDigits($item['harga_dua_tahun_l'], 0); // fixed $data['harga_jual'][$index]['harga_dua_tahun_lalu'] = $this->currencyDigits($item['harga_dua_tahun_lalu']);
} }
} }
} }
@@ -49,20 +49,43 @@ class ProfilPetaniController extends Controller
$skorPetani = SkorPetani::where('petani_id', $id)->first(); $skorPetani = SkorPetani::where('petani_id', $id)->first();
$enumerator = Profile::where('user_id', $petani->created_by)->first(); $enumerator = Profile::where('user_id', $petani->created_by)->first();
$komoditasUtama = Komoditas::where('id', $petani->komoditas_id)->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') $desaKelurahan = DesaKelurahan::with('kecamatan')
->where('id', $petani->desa_kelurahan_id) ->where('id', $petani->desa_kelurahan_id)
->first(); ->first();
$ktpMasked = $petani->ktp ? Str::mask($petani->ktp, '*', 4, -4) : '-'; $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) $tanaman = Tanaman::where('petani_id', $petani->id)
->where('komoditas_id', $petani->komoditas_id) ->where('komoditas_id', $petani->komoditas_id)
->first(); ->first();
if ($tanaman) { if ($tanaman?->updated_at && $tanaman->umur_tanaman !== null) {
$tahunInputTanaman = $tanaman->updated_at->year; $tahunInputTanaman = $tanaman->updated_at->year;
$umurTanaman = (int) $tanaman->umur_tanaman; $umurTanaman = (int) $tanaman->umur_tanaman;
$tahunSekarang = now()->year; $tahunSekarang = now()->year;
@@ -162,8 +185,8 @@ class ProfilPetaniController extends Controller
$d4SosialEkonomi = null; $d4SosialEkonomi = null;
$terdaftarKelompok = $sosialEkonomi->terdaftar_kelompok_tani; $terdaftarKelompok = $sosialEkonomi?->terdaftar_kelompok_tani;
$peranKelompok = $sosialEkonomi->peran_dalam_kelompok; $peranKelompok = $sosialEkonomi?->peran_dalam_kelompok;
if ($sosialEkonomi) { if ($sosialEkonomi) {
@@ -207,8 +230,8 @@ class ProfilPetaniController extends Controller
'icon' => 'tabler-minus', 'icon' => 'tabler-minus',
], ],
default => [ default => [
'label' => Str::headline($status), 'label' => filled($status) ? Str::headline($status) : 'Belum diisi',
'short' => Str::headline($status), 'short' => filled($status) ? Str::headline($status) : 'Belum diisi',
'color' => 'secondary', 'color' => 'secondary',
'icon' => 'tabler-help-circle', '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 = [ $data = [
'missingSections' => $missingSections,
'hasAssessment' => $hasAssessment,
'petani' => $petani, 'petani' => $petani,
'skorPetani' => $skorPetani, 'skorPetani' => $skorPetani,
'enumerator' => $enumerator, 'enumerator' => $enumerator,
'komoditasUtama' => $komoditasUtama, 'komoditasUtama' => $komoditasUtama,
'lahan' => $lahan, 'lahan' => $lahan,
'farmerLandMap' => $this->farmerLandMapData($lahan),
'desaKelurahan' => $desaKelurahan, 'desaKelurahan' => $desaKelurahan,
'ktpMasked' => $ktpMasked, 'ktpMasked' => $ktpMasked,
'profiling' => $profiling, 'profiling' => $profiling,
@@ -447,7 +519,6 @@ class ProfilPetaniController extends Controller
'umurTanaman' => $umurTanaman, 'umurTanaman' => $umurTanaman,
'surveyPanen' => $surveyPanen, 'surveyPanen' => $surveyPanen,
'praktikBudidaya' => $praktikBudidaya, 'praktikBudidaya' => $praktikBudidaya,
'praktikBudidaya' => $praktikBudidaya,
'indikatorOnFarm' => $indikatorOnFarm, 'indikatorOnFarm' => $indikatorOnFarm,
'detailOnFarm' => $detailOnFarm, 'detailOnFarm' => $detailOnFarm,
'sosialEkonomi' => $sosialEkonomi, 'sosialEkonomi' => $sosialEkonomi,
@@ -464,6 +535,35 @@ class ProfilPetaniController extends Controller
return view('produktivitas.profil-petani.index', $data); 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 private function getScoreColor(?int $score): string
{ {
if ($score === null) { if ($score === null) {
+4
View File
@@ -31,6 +31,9 @@ class AksesPasar extends Model
'saluran_terdahulu', 'saluran_terdahulu',
'saluran_terdahulu_lainnya', 'saluran_terdahulu_lainnya',
'alasan_berhenti', 'alasan_berhenti',
'tahu_harga_sebelumnya',
'sumber_info_harga',
'sumber_info_harga_lainnya',
'praktik_fermentasi', 'praktik_fermentasi',
'proses_fermentasi', 'proses_fermentasi',
'model_fermentasi_memungkinkan', 'model_fermentasi_memungkinkan',
@@ -51,6 +54,7 @@ class AksesPasar extends Model
protected $casts = [ protected $casts = [
'tujuan_penjualan' => 'array', 'tujuan_penjualan' => 'array',
'saluran_terdahulu' => 'array', 'saluran_terdahulu' => 'array',
'sumber_info_harga' => 'array',
]; ];
protected static function booted(): void protected static function booted(): void
+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;
}
}
}
+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' => [ 'postmark' => [
'key' => env('POSTMARK_API_KEY'), 'key' => env('POSTMARK_API_KEY'),
], ],
@@ -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',
]);
});
}
};
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

+23
View File
@@ -81,6 +81,29 @@ window.config = {
enableMenuLocalStorage: true // Enable menu state with local storage support 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.assetsPath = document.documentElement.getAttribute('data-assets-path');
window.templateName = document.documentElement.getAttribute('data-template'); window.templateName = document.documentElement.getAttribute('data-template');
@@ -2011,7 +2011,11 @@
series: data.series, series: data.series,
colors: colors, colors: colors,
stroke: { width: 0 }, stroke: { width: 0 },
dataLabels: { enabled: false }, dataLabels: {
enabled: false,
style: { colors: colors.map(config.chartTextColor) },
dropShadow: { enabled: false },
},
legend: { legend: {
show: true, show: true,
position: 'bottom', position: 'bottom',
@@ -2051,7 +2055,7 @@
<div <div
style=" style="
background: ${color}; background: ${color};
color: #fff; color: ${config.chartTextColor(color)};
padding: 8px 12px; padding: 8px 12px;
border-radius: 5px; border-radius: 5px;
font-size: 13px; font-size: 13px;
@@ -1713,6 +1713,7 @@ document.addEventListener('DOMContentLoaded', function () {
const totalPetani = lakiLaki + perempuan; const totalPetani = lakiLaki + perempuan;
if (genderChartEl) { if (genderChartEl) {
const genderColors = [pastelSemantic.info || '#5FC7D6', pastelSemantic.pink || '#F47C86'];
const genderChartConfig = { const genderChartConfig = {
chart: { chart: {
height: 365, height: 365,
@@ -1732,7 +1733,7 @@ document.addEventListener('DOMContentLoaded', function () {
}, },
labels: ['Laki-laki', 'Perempuan'], labels: ['Laki-laki', 'Perempuan'],
series: [lakiLaki, perempuan], series: [lakiLaki, perempuan],
colors: [pastelSemantic.info || '#5FC7D6', pastelSemantic.pink || '#F47C86'], colors: genderColors,
stroke: { width: 0 }, stroke: { width: 0 },
dataLabels: { dataLabels: {
enabled: true, enabled: true,
@@ -1748,15 +1749,9 @@ document.addEventListener('DOMContentLoaded', function () {
fontSize: '13px', fontSize: '13px',
fontFamily: fontFamily, fontFamily: fontFamily,
fontWeight: 600, fontWeight: 600,
colors: ['#fff'] colors: genderColors.map(config.chartTextColor)
}, },
dropShadow: { dropShadow: { enabled: false }
enabled: true,
top: 1,
left: 1,
blur: 2,
opacity: 0.45
}
}, },
legend: { legend: {
show: true, show: true,
@@ -2120,6 +2115,7 @@ document.addEventListener('DOMContentLoaded', function () {
const farmerGroupFilters = ['terdaftar', 'tidak_terdaftar', 'belum_terisi']; const farmerGroupFilters = ['terdaftar', 'tidak_terdaftar', 'belum_terisi'];
const totalFarmerGroup = farmerGroupData.reduce((total, value) => total + value, 0); const totalFarmerGroup = farmerGroupData.reduce((total, value) => total + value, 0);
const farmerGroupColors = [pastelSemantic.success || '#6FD68F', pastelSemantic.warning || '#FFB46F', pastelSemantic.secondary || '#9EA6B0'];
const farmerGroupChartConfig = { const farmerGroupChartConfig = {
chart: { chart: {
height: 365, height: 365,
@@ -2139,7 +2135,7 @@ document.addEventListener('DOMContentLoaded', function () {
}, },
labels: ['Terdaftar', 'Tidak Terdaftar', 'Belum Terisi'], labels: ['Terdaftar', 'Tidak Terdaftar', 'Belum Terisi'],
series: farmerGroupData, series: farmerGroupData,
colors: [pastelSemantic.success || '#6FD68F', pastelSemantic.warning || '#FFB46F', pastelSemantic.secondary || '#9EA6B0'], colors: farmerGroupColors,
stroke: { width: 0 }, stroke: { width: 0 },
dataLabels: { dataLabels: {
enabled: true, enabled: true,
@@ -2155,15 +2151,9 @@ document.addEventListener('DOMContentLoaded', function () {
fontSize: '13px', fontSize: '13px',
fontFamily: fontFamily, fontFamily: fontFamily,
fontWeight: 600, fontWeight: 600,
colors: ['#fff'] colors: farmerGroupColors.map(config.chartTextColor)
}, },
dropShadow: { dropShadow: { enabled: false }
enabled: true,
top: 1,
left: 1,
blur: 2,
opacity: 0.45
}
}, },
legend: { legend: {
show: true, show: true,
@@ -2219,6 +2209,14 @@ document.addEventListener('DOMContentLoaded', function () {
const commoditySeries = @json($data['komoditas_utama']['series'] ?? []); const commoditySeries = @json($data['komoditas_utama']['series'] ?? []);
const totalCommodityFarmers = commoditySeries.reduce((total, value) => total + Number(value), 0); 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 = { const commodityChartConfig = {
chart: { chart: {
height: 365, height: 365,
@@ -2237,14 +2235,7 @@ document.addEventListener('DOMContentLoaded', function () {
}, },
labels: commodityLabels, labels: commodityLabels,
series: commoditySeries.map(Number), series: commoditySeries.map(Number),
colors: [ colors: commodityColors,
pastelSemantic.primary || '#9D8EF0',
pastelSemantic.warning || '#FFB46F',
pastelSemantic.success || '#6FD68F',
pastelSemantic.info || '#5FC7D6',
pastelSemantic.danger || '#F47C86',
pastelSemantic.secondary || '#9EA6B0'
],
stroke: { width: 0 }, stroke: { width: 0 },
dataLabels: { dataLabels: {
enabled: true, enabled: true,
@@ -2260,15 +2251,9 @@ document.addEventListener('DOMContentLoaded', function () {
fontSize: '13px', fontSize: '13px',
fontFamily: fontFamily, fontFamily: fontFamily,
fontWeight: 600, fontWeight: 600,
colors: ['#fff'] colors: commodityColors.map(config.chartTextColor)
}, },
dropShadow: { dropShadow: { enabled: false }
enabled: true,
top: 1,
left: 1,
blur: 2,
opacity: 0.45
}
}, },
legend: { legend: {
show: true, show: true,
@@ -2293,7 +2278,7 @@ document.addEventListener('DOMContentLoaded', function () {
const percentage = totalCommodityFarmers > 0 ? ((value / totalCommodityFarmers) * 100).toFixed(1) : '0.0'; const percentage = totalCommodityFarmers > 0 ? ((value / totalCommodityFarmers) * 100).toFixed(1) : '0.0';
const color = w.globals.colors[seriesIndex]; const color = w.globals.colors[seriesIndex];
return ` 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> ${label}: <strong>${value} petani (${percentage}%)</strong>
</div> </div>
`; `;
@@ -2433,6 +2418,12 @@ document.addEventListener('DOMContentLoaded', function () {
const landOwnershipStatusSeries = (@json($data['status_kepemilikan_lahan']['series'] ?? [])).map(Number); const landOwnershipStatusSeries = (@json($data['status_kepemilikan_lahan']['series'] ?? [])).map(Number);
const totalLandOwnershipStatusFarmers = landOwnershipStatusSeries.reduce((total, value) => total + value, 0); 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 = { const landOwnershipStatusChartConfig = {
chart: { chart: {
height: 365, height: 365,
@@ -2452,12 +2443,7 @@ document.addEventListener('DOMContentLoaded', function () {
}, },
labels: landOwnershipStatusLabels, labels: landOwnershipStatusLabels,
series: landOwnershipStatusSeries, series: landOwnershipStatusSeries,
colors: [ colors: landOwnershipStatusColors,
pastelSemantic.success || '#6FD68F',
pastelSemantic.warning || '#FFB46F',
pastelSemantic.info || '#5FC7D6',
pastelSemantic.secondary || '#9EA6B0'
],
stroke: { width: 0 }, stroke: { width: 0 },
dataLabels: { dataLabels: {
enabled: true, enabled: true,
@@ -2473,15 +2459,9 @@ document.addEventListener('DOMContentLoaded', function () {
fontSize: '13px', fontSize: '13px',
fontFamily: fontFamily, fontFamily: fontFamily,
fontWeight: 600, fontWeight: 600,
colors: ['#fff'] colors: landOwnershipStatusColors.map(config.chartTextColor)
}, },
dropShadow: { dropShadow: { enabled: false }
enabled: true,
top: 1,
left: 1,
blur: 2,
opacity: 0.45
}
}, },
legend: { legend: {
show: true, show: true,
@@ -2918,6 +2898,8 @@ document.addEventListener('DOMContentLoaded', function () {
}, },
dataLabels: { dataLabels: {
enabled: false, enabled: false,
style: { colors: segmentPalette.map(config.chartTextColor) },
dropShadow: { enabled: false },
}, },
legend: { legend: {
show: false, show: false,
+768 -62
View File
@@ -3,12 +3,12 @@
@push('style') @push('style')
<style> <style>
.pasar-filter-card .filter-item { .pasar-filter-card .filter-item {
min-width: 210px; min-width: 190px;
} }
.pasar-kpi-value { .pasar-kpi-value {
color: var(--bs-heading-color); color: var(--bs-heading-color);
font-size: 1.85rem; font-size: 1.6rem;
font-weight: 600; font-weight: 600;
line-height: 1.2; line-height: 1.2;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@@ -25,19 +25,164 @@
line-height: 1.4; line-height: 1.4;
} }
.pasar-empty-state { .chart-group-header-toggle {
min-height: 220px; cursor: pointer;
background-color: rgba(var(--bs-primary-rgb, 115, 103, 240), 0.08);
border-inline-start: 4px solid var(--bs-primary, #9D8EF0);
box-shadow: none;
transition: background-color 0.2s ease;
}
.chart-group-header-toggle:hover {
background-color: rgba(var(--bs-primary-rgb, 115, 103, 240), 0.13);
}
.chart-group-header-toggle .card-body {
padding: 12px 16px;
}
.chart-group-header-toggle h5 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 2px;
}
.chart-group-header-toggle:focus-visible {
outline: 2px solid var(--bs-primary);
outline-offset: 3px;
}
.chart-group-icon-toggle {
width: 34px;
height: 34px;
flex-shrink: 0;
}
.chart-group-icon-toggle .icon-base {
font-size: 24px;
width: 24px;
height: 24px;
}
.chart-group-panel.show,
.chart-group-panel.collapsing {
margin-bottom: 1.5rem;
}
.pasar-chart-card .card-title h5 {
color: var(--bs-heading-color);
font-size: 1.125rem;
font-weight: 500;
line-height: 1.35;
letter-spacing: 0;
}
.pasar-chart-card .card-title small,
.chart-group-header-toggle p {
font-size: 0.8125rem;
line-height: 1.4;
}
.pasar-chart {
min-height: 330px;
}
.pasar-chart-empty {
min-height: 330px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--bs-secondary-color);
font-size: 0.8125rem;
text-align: center; text-align: center;
} }
.pasar-chart-card .apexcharts-tooltip {
background: transparent !important;
border: none !important;
box-shadow: none !important;
overflow: visible !important;
white-space: normal;
max-width: 260px;
}
.pasar-chart-tooltip {
border-radius: 5px;
box-shadow: 0 4px 12px rgba(0, 0, 0, .15);
padding: 8px 12px;
font-size: 13px;
font-weight: 500;
line-height: 1.25;
}
.pasar-trend-tooltip-row {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
}
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
.pasar-filter-card .filter-item { .pasar-filter-card .filter-item {
width: 100%; width: 100%;
} }
} }
.market-info-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex: 0 0 20px;
padding: 0;
border: 1px solid var(--bs-secondary-color);
border-radius: 50%;
background: transparent;
color: var(--bs-secondary-color);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.market-info-button:hover, .market-info-button:focus-visible {
color: var(--bs-primary);
border-color: var(--bs-primary);
outline: 2px solid rgba(var(--bs-primary-rgb), .2);
outline-offset: 2px;
}
.international-provider-logo {
width: 48px;
height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
}
.international-provider-logo[hidden] { display: none; }
.international-provider-logo img { object-fit: contain; }
.international-market-track {
display: flex;
gap: 1.25rem;
overflow-x: auto;
scroll-snap-type: none;
scrollbar-width: none;
padding: 2px 2px 16px;
}
.international-market-track::-webkit-scrollbar { display: none; }
.international-market-card {
flex: 0 0 calc((100% - 2.5rem) / 3);
min-width: 0;
scroll-snap-align: start;
}
@media (max-width: 991.98px) {
.international-market-card { flex-basis: calc((100% - 1.25rem) / 2); }
}
@media (max-width: 575.98px) {
.international-market-card { flex-basis: 100%; }
}
</style> </style>
@endpush @endpush
@@ -47,35 +192,6 @@
<form id="form-filter" class="col-12" method="GET" action="{{ route('dashboard.pasar') }}"> <form id="form-filter" class="col-12" method="GET" action="{{ route('dashboard.pasar') }}">
<div class="card card-border-shadow-primary pasar-filter-card"> <div class="card card-border-shadow-primary pasar-filter-card">
<div class="card-body py-3"> <div class="card-body py-3">
<div class="d-flex align-items-center gap-2 flex-wrap">
<div class="d-flex align-items-center me-2">
<i class="icon-base ti tabler-adjustments-horizontal me-2"></i>
<span class="fw-medium">Komoditas <span class="text-danger">*</span></span>
</div>
<div class="filter-item">
<select id="filter-komoditas" class="form-select form-select-sm" name="komoditas_id">
<option value="">Pilih Komoditas</option>
@foreach ($komoditas as $item)
<option value="{{ $item->id }}" {{ old('komoditas_id', $komoditas_id) == $item->id ? 'selected' : '' }}>
{{ $item->nama }}
</option>
@endforeach
</select>
</div>
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="icon-base ti tabler-filter me-1"></i>
Filter
</button>
<button type="button" id="btn-reset-dashboard-filter" class="btn btn-outline-secondary btn-sm">
<i class="icon-base ti tabler-refresh me-1"></i>
Reset
</button>
</div>
<hr class="my-3">
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="d-flex align-items-center gap-2 flex-wrap">
<div class="d-flex align-items-center me-2"> <div class="d-flex align-items-center me-2">
<i class="icon-base ti tabler-map-2 me-2"></i> <i class="icon-base ti tabler-map-2 me-2"></i>
@@ -111,39 +227,57 @@
</select> </select>
</div> </div>
</div> </div>
<hr class="my-3">
<div class="d-flex align-items-center gap-2 flex-wrap">
<div class="d-flex align-items-center me-2">
<i class="icon-base ti tabler-adjustments-horizontal me-2"></i>
<span class="fw-medium">Data</span>
</div>
<div class="filter-item">
<select id="filter-komoditas" class="form-select form-select-sm" name="komoditas_id">
<option value="">Semua Komoditas</option>
@foreach ($komoditas as $item)
<option value="{{ $item->id }}" {{ old('komoditas_id', $komoditas_id) == $item->id ? 'selected' : '' }}>
{{ $item->nama }}
</option>
@endforeach
</select>
</div>
<div class="filter-item">
<select id="filter-segmen" class="form-select form-select-sm" name="segmen">
<option value="">Semua Segmen</option>
@foreach ($segments as $segment)
<option value="{{ $segment['name'] }}" {{ old('segmen', $segmen) == $segment['name'] ? 'selected' : '' }}>
{{ $segment['name'] }}
</option>
@endforeach
</select>
</div>
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="icon-base ti tabler-filter me-1"></i>
Filter
</button>
<button type="button" id="btn-reset-dashboard-filter" class="btn btn-outline-secondary btn-sm">
<i class="icon-base ti tabler-refresh me-1"></i>
Reset
</button>
</div>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
@if (! $selectedKomoditas)
<div class="alert alert-primary d-flex align-items-start gap-3" role="alert">
<i class="icon-base ti tabler-info-circle mt-1"></i>
<div>
<h6 class="alert-heading mb-1">Pilih komoditas terlebih dahulu</h6>
<div>Data dashboard pasar baru akan ditampilkan setelah filter komoditas wajib diisi.</div>
</div>
</div>
<div class="card">
<div class="card-body pasar-empty-state">
<div>
<div class="avatar avatar-lg mx-auto mb-3">
<span class="avatar-initial rounded bg-label-primary">
<i class="icon-base ti tabler-building-store icon-28px"></i>
</span>
</div>
<h5 class="mb-1">Belum ada data pasar yang ditampilkan</h5>
<p class="text-body-secondary mb-0">Gunakan filter komoditas untuk melihat harga jual rata-rata.</p>
</div>
</div>
</div>
@else
<div class="row g-4 mb-4"> <div class="row g-4 mb-4">
<div class="col-xl-4 col-md-6"> @foreach ($priceCards as $priceCard)
<div class="col-12 col-md-6 col-xl-3">
<div class="card card-border-shadow-{{ $priceCard['variant'] }} h-100"> <div class="card card-border-shadow-{{ $priceCard['variant'] }} h-100">
<div class="card-body"> <div class="card-body">
<div class="d-flex align-items-center gap-4"> <div class="d-flex flex-column align-items-start gap-3">
<div class="avatar avatar-lg"> <div class="avatar avatar-lg">
<span class="avatar-initial rounded bg-label-{{ $priceCard['variant'] }}"> <span class="avatar-initial rounded bg-label-{{ $priceCard['variant'] }}">
<i class="icon-base ti {{ $priceCard['icon'] }} icon-28px"></i> <i class="icon-base ti {{ $priceCard['icon'] }} icon-28px"></i>
@@ -161,22 +295,594 @@
</div> </div>
</div> </div>
<div class="mt-4 pt-3 border-top d-flex align-items-center justify-content-between gap-3"> <div class="mt-4 pt-3 border-top d-flex align-items-center justify-content-between gap-2 flex-wrap">
<span class="badge bg-label-success">{{ $priceCard['caption'] }}</span> <span class="badge bg-label-{{ $priceCard['variant'] }}">{{ $priceCard['records'] > 0 ? 'Rata-rata harga' : 'Belum ada data' }}</span>
<small class="text-body-secondary">{{ number_format($priceCard['records'], 0, ',', '.') }} data harga</small> <small class="text-body-secondary">{{ number_format($priceCard['records'], 0, ',', '.') }} data harga</small>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@endforeach
</div> </div>
@endif
<div class="card chart-group-header-toggle mb-4" role="button" tabindex="0"
data-bs-toggle="collapse" data-bs-target="#saluran-harga-charts"
aria-expanded="false" aria-controls="saluran-harga-charts" aria-label="Tampilkan chart">
<div class="card-body d-flex justify-content-between align-items-center gap-3">
<div>
<h5>Saluran &amp; Harga</h5>
<p class="mb-0 text-body-secondary">Saluran penjualan, hubungan dengan pembeli, waktu penjualan, akses ke saluran alternatif, dan tren harga.</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>
</span>
</div>
</div>
@php
$chartDefinitions = [
'saluranPenjualan' => ['title' => 'Saluran Penjualan Utama', 'description' => 'Kepada siapa petani biasanya menjual hasil panen?', 'variant' => 'success'],
'hubunganPembeli' => ['title' => 'Sifat Hubungan dengan Pembeli', 'description' => 'Hubungan jual petani dengan pembeli utama saat ini.', 'variant' => 'primary'],
'waktuPenjualan' => ['title' => 'Waktu Penjualan Hasil Panen', 'description' => 'Kapan petani biasanya menjual hasil panen?', 'variant' => 'warning'],
'aksesSaluranLain' => ['title' => 'Pengalaman & Akses ke Saluran Lain', 'description' => 'Pengalaman menjual ke tempat lain dan akses yang masih dimiliki.', 'variant' => 'info'],
];
@endphp
<div class="collapse chart-group-panel" id="saluran-harga-charts">
<div class="row g-4 mb-4">
@foreach ($chartDefinitions as $key => $definition)
<div class="col-12 col-lg-6">
<div class="card pasar-chart-card h-100">
<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" id="title-{{ $key }}">{{ $definition['title'] }}</h5>
<small class="text-body-secondary">{{ $definition['description'] }}</small>
</div>
</div>
<div class="card-body d-flex flex-column">
<div id="chart-{{ $key }}" class="pasar-chart" role="img" aria-labelledby="title-{{ $key }}" aria-describedby="summary-{{ $key }}"></div>
<span id="summary-{{ $key }}" class="visually-hidden">
@foreach ($marketCharts[$key]['labels'] as $index => $label)
{{ $label }}: {{ $marketCharts[$key]['series'][$index] }} responden ({{ $marketCharts[$key]['percentages'][$index] }}%).
@endforeach
</span>
</div>
</div>
</div>
@endforeach
@forelse ($priceTrend['commodities'] as $commodity)
<div class="col-12 col-lg-6">
<div class="card pasar-chart-card h-100">
<div class="card-header">
<div class="card-title mb-0">
<h5 class="m-0 me-2" id="title-tren-harga-{{ $commodity['id'] }}">Tren Harga 3 Musim — {{ $commodity['name'] }}</h5>
<small class="text-body-secondary">Rata-rata harga jual per bentuk produk selama tiga periode panen (Rp/kg).</small>
</div>
</div>
<div class="card-body">
<div id="chart-tren-harga-{{ $commodity['id'] }}" class="pasar-chart" role="img" aria-labelledby="title-tren-harga-{{ $commodity['id'] }}" aria-describedby="summary-tren-harga-{{ $commodity['id'] }}"></div>
<span id="summary-tren-harga-{{ $commodity['id'] }}" class="visually-hidden"></span>
</div>
</div>
</div>
@empty
<div class="col-12">
<div class="card pasar-chart-card">
<div class="card-header">
<div class="card-title mb-0"><h5 class="m-0">Tren Harga 3 Musim</h5></div>
</div>
<div class="card-body pasar-chart-empty">Belum ada komoditas untuk filter yang dipilih.</div>
</div>
</div>
@endforelse
</div>
</div>
<div class="card chart-group-header-toggle mb-4" role="button" tabindex="0"
data-bs-toggle="collapse" data-bs-target="#kesiapan-premium-charts"
aria-expanded="false" aria-controls="kesiapan-premium-charts" aria-label="Tampilkan chart">
<div class="card-body d-flex justify-content-between align-items-center gap-3">
<div>
<h5>Kesiapan Pasar Premium</h5>
<p class="mb-0 text-body-secondary">Kesadaran petani tentang pasar premium, persyaratan masuk, dan pengalaman mengaksesnya.</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>
</span>
</div>
</div>
<div class="collapse chart-group-panel" id="kesiapan-premium-charts">
<div class="row g-4 mb-4">
<div class="col-12">
<div class="card pasar-chart-card">
<div class="card-header">
<div class="card-title mb-0">
<h5 class="m-0 me-2" id="title-kesadaranPremium">Kesadaran Pasar Premium</h5>
<small class="text-body-secondary">Pengetahuan tentang pasar premium dan persyaratan yang dibutuhkan untuk masuk ke pasar tersebut.</small>
</div>
</div>
<div class="card-body">
<div id="chart-kesadaranPremium" class="pasar-chart" role="img" aria-labelledby="title-kesadaranPremium" aria-describedby="summary-kesadaranPremium"></div>
<span id="summary-kesadaranPremium" class="visually-hidden">
@foreach ($premiumAwareness['fullLabels'] as $index => $label)
{{ $label }}: {{ $premiumAwareness['series'][$index] }} responden ({{ $premiumAwareness['percentages'][$index] }}%).
@endforeach
</span>
</div>
</div>
</div>
</div>
</div>
<section class="mt-4" aria-labelledby="international-market-title" id="international-market">
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-2">
<h5 id="international-market-title" class="mb-0">Harga Komoditas Internasional</h5>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" data-market-prev aria-label="Harga sebelumnya">&larr;</button>
<button type="button" class="btn btn-sm btn-icon btn-outline-secondary" data-market-pause aria-pressed="false" aria-label="Jeda slider" title="Jeda slider"><i class="icon-base ti tabler-player-pause" aria-hidden="true"></i></button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-market-next aria-label="Harga berikutnya">&rarr;</button>
</div>
</div>
<p class="text-body-secondary small mb-2">Setiap kartu menampilkan satu komoditas dari satu sumber. Periode dan jenis harga mengikuti penyedia; tidak mengikuti filter wilayah.</p>
<p class="small text-body-secondary" data-market-status role="status">Memuat harga internasional...</p>
<div class="international-market-track" tabindex="0" role="region" aria-roledescription="carousel" aria-label="Harga komoditas menurut sumber"></div>
<template id="international-market-card">
<article class="card card-border-shadow-info international-market-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-2">
<h6 class="mb-0" data-name></h6>
<button type="button" class="market-info-button" data-market-info data-update-info aria-label="Informasi pembaruan harga" aria-expanded="false">!</button>
</div>
<div class="small">Sumber: <a data-source target="_blank" rel="noopener noreferrer"></a></div>
</div>
<span class="international-provider-logo flex-shrink-0" data-provider-logo-wrap hidden>
<img data-provider-logo width="32" height="32" alt="" decoding="async">
</span>
</div>
<div class="pasar-kpi-value" data-price></div>
<div class="small text-body-secondary mt-1" data-unit></div>
<div class="d-flex align-items-center gap-2 mt-3 pt-3 border-top">
<div class="fw-semibold fs-5" data-rupiah></div>
<button type="button" class="market-info-button" data-market-info data-fx-info aria-label="Informasi kurs konversi" aria-expanded="false">!</button>
</div>
<p class="small text-body-secondary mt-2 mb-2" data-description></p>
<div class="small text-body-secondary" data-period></div>
</div>
</article>
</template>
</section>
</div> </div>
@endsection @endsection
@push('scripts') @push('scripts')
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
const marketSection = document.getElementById('international-market');
const marketTrack = marketSection.querySelector('.international-market-track');
const marketStatus = marketSection.querySelector('[data-market-status]');
const pauseButton = marketSection.querySelector('[data-market-pause]');
let activeMarketInfo = null;
function closeMarketInfo() {
if (!activeMarketInfo) return;
activeMarketInfo.button.setAttribute('aria-expanded', 'false');
activeMarketInfo.popover.dispose();
activeMarketInfo = null;
}
document.addEventListener('click', event => {
const button = event.target.closest('[data-market-info]');
if (button && marketSection.contains(button)) {
const same = activeMarketInfo?.button === button;
closeMarketInfo();
if (same) return;
const popover = new bootstrap.Popover(button, {
container: 'body', placement: 'auto', trigger: 'manual', html: false,
title: button.hasAttribute('data-fx-info') ? 'Kurs konversi' : 'Pembaruan harga',
content: button.dataset.info || 'Informasi belum tersedia.'
});
popover.show();
button.setAttribute('aria-expanded', 'true');
activeMarketInfo = { button, popover };
} else if (!event.target.closest('.popover')) closeMarketInfo();
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape') closeMarketInfo();
});
marketTrack.addEventListener('focusout', event => {
if (activeMarketInfo?.button === event.target) closeMarketInfo();
});
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let marketPaused = reducedMotion.matches;
let marketSignature = '';
const quoteDateFormat = new Intl.DateTimeFormat('id-ID', {
dateStyle: 'medium', timeStyle: 'long', timeZone: 'Asia/Jakarta'
});
const rupiahFormat = new Intl.NumberFormat('id-ID', {
style: 'currency', currency: 'IDR', maximumFractionDigits: 2
});
function syncMarketPause() {
const label = marketPaused ? 'Putar slider' : 'Jeda slider';
pauseButton.querySelector('i').className = 'icon-base ti ' + (marketPaused ? 'tabler-player-play' : 'tabler-player-pause');
pauseButton.setAttribute('aria-label', label);
pauseButton.title = label;
pauseButton.setAttribute('aria-pressed', String(marketPaused));
}
syncMarketPause();
pauseButton.addEventListener('click', () => { marketPaused = !marketPaused; syncMarketPause(); });
let marketCycleWidth = 0;
let lastMarketFrame = null;
function measureMarketCycle() {
const first = marketTrack.firstElementChild;
const duplicate = marketTrack.querySelector('[data-market-clone]');
marketCycleWidth = first && duplicate ? duplicate.offsetLeft - first.offsetLeft : 0;
}
function slideMarket(direction) {
if (!marketTrack.firstElementChild) return;
const step = marketTrack.firstElementChild.getBoundingClientRect().width + 20;
if (direction < 0 && marketTrack.scrollLeft < step && marketCycleWidth) {
marketTrack.scrollLeft += marketCycleWidth;
}
marketTrack.scrollLeft += step * direction;
}
marketSection.querySelector('[data-market-prev]').addEventListener('click', () => slideMarket(-1));
marketSection.querySelector('[data-market-next]').addEventListener('click', () => slideMarket(1));
window.addEventListener('resize', measureMarketCycle);
function animateMarket(timestamp) {
const elapsed = lastMarketFrame === null ? 0 : Math.min(timestamp - lastMarketFrame, 64);
lastMarketFrame = timestamp;
if (marketCycleWidth > 0) {
if (!marketPaused && !document.hidden && !marketTrack.contains(document.activeElement)) {
// Keep subpixel progress independently: scrollLeft may round tiny per-frame changes.
marketScrollPosition += elapsed * 0.028;
marketTrack.scrollLeft = marketScrollPosition;
} else {
marketScrollPosition = marketTrack.scrollLeft;
}
if (marketTrack.scrollLeft >= marketCycleWidth) {
marketScrollPosition = marketTrack.scrollLeft - marketCycleWidth;
marketTrack.scrollLeft = marketScrollPosition;
}
}
window.requestAnimationFrame(animateMarket);
}
let marketScrollPosition = 0;
marketTrack.addEventListener('scroll', () => {
if (Math.abs(marketTrack.scrollLeft - marketScrollPosition) > 2) marketScrollPosition = marketTrack.scrollLeft;
}, { passive: true });
window.requestAnimationFrame(animateMarket);
async function refreshInternationalPrices() {
try {
const response = await fetch(@json(route('dashboard.pasar.international')), {
headers: { 'Accept': 'application/json' }, credentials: 'same-origin',
signal: AbortSignal.timeout(25000)
});
if (!response.ok) throw new Error('Harga tidak tersedia');
const payload = await response.json();
if (!Array.isArray(payload.quotes)) throw new Error('Data tidak valid');
const quotes = payload.quotes.filter(quote => typeof quote.price === 'number' && Number.isFinite(quote.price) && quote.price > 0);
const signature = JSON.stringify([quotes, payload.exchange_rate]);
if (signature !== marketSignature) {
const previousScroll = marketTrack.scrollLeft;
const fragment = document.createDocumentFragment();
quotes.forEach(quote => {
const card = document.getElementById('international-market-card').content.cloneNode(true);
card.querySelector('[data-name]').textContent = quote.name;
card.querySelector('[data-update-info]').dataset.info = quote.update_info || (quote.frequency === 'monthly'
? 'IMF menyediakan harga acuan rata-rata bulanan. Data terbaru diperiksa setiap hari. Periode harga tercantum di kartu.'
: 'Harga Yahoo Finance diperiksa setiap 5 menit. Data dapat tertunda mengikuti penyedia dan jam perdagangan.');
const rate = payload.exchange_rate;
let rateInfo = 'Kurs USD/IDR belum tersedia. Harga asli tetap ditampilkan.';
if (rate && typeof rate.value === 'number') {
rateInfo = '1 USD = ' + rupiahFormat.format(rate.value) + '. Sumber: ' + rate.source + '.';
const rateDate = new Date(rate.quoted_at);
if (!Number.isNaN(rateDate.getTime())) rateInfo += ' Waktu kurs: ' + quoteDateFormat.format(rateDate) + '.';
if (rate.stale) rateInfo += ' Menggunakan kurs tersimpan.';
if (quote.frequency === 'monthly') rateInfo += ' Konversi memakai kurs ini, bukan kurs historis bulan harga tersebut.';
}
card.querySelector('[data-fx-info]').dataset.info = rateInfo;
const providerLogos = {
yahoo: @json(asset('assets/img/providers/yahoo-finance.ico')),
imf: @json(asset('assets/img/providers/imf.ico')),
alpha: @json(asset('assets/img/providers/alpha-vantage.ico'))
};
const logoUrl = providerLogos[(quote.id || '').split(':')[0]];
if (logoUrl) {
const logo = card.querySelector('[data-provider-logo]');
logo.src = logoUrl;
logo.alt = 'Logo ' + quote.source;
card.querySelector('[data-provider-logo-wrap]').hidden = false;
}
const source = card.querySelector('[data-source]');
source.textContent = quote.source;
if (quote.source_url?.startsWith('https://')) source.href = quote.source_url;
card.querySelector('[data-price]').textContent = quote.price.toLocaleString('id-ID', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
card.querySelector('[data-unit]').textContent = quote.unit;
card.querySelector('[data-rupiah]').textContent = typeof quote.price_idr_per_kg === 'number'
? rupiahFormat.format(quote.price_idr_per_kg) + ' / kg' : 'Konversi rupiah belum tersedia';
card.querySelector('[data-description]').textContent = quote.description;
let period = '-';
if (quote.frequency === 'monthly' && /^\d{4}-\d{2}$/.test(quote.period || '')) {
period = 'Rata-rata ' + new Intl.DateTimeFormat('id-ID', { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(quote.period + '-01T00:00:00Z'));
} else if (quote.period) {
const date = new Date(quote.period);
if (!Number.isNaN(date.getTime())) period = 'Waktu harga: ' + quoteDateFormat.format(date);
}
card.querySelector('[data-period]').textContent = period + (quote.stale ? ' · Data tersimpan' : '');
fragment.append(card);
});
closeMarketInfo();
marketTrack.replaceChildren(fragment);
if (quotes.length > 1) {
// Repeat the cards so the end joins the start without a visible rewind.
const originals = [...marketTrack.children];
[...originals, ...originals].forEach(original => {
const clone = original.cloneNode(true);
clone.dataset.marketClone = 'true';
marketTrack.append(clone);
});
}
measureMarketCycle();
marketScrollPosition = marketCycleWidth ? previousScroll % marketCycleWidth : 0;
marketTrack.scrollLeft = marketScrollPosition;
marketSignature = signature;
}
marketStatus.textContent = quotes.length
? '' : 'Harga belum tersedia. Pembaruan akan dicoba kembali otomatis.';
marketStatus.hidden = quotes.length > 0;
[pauseButton, ...marketSection.querySelectorAll('[data-market-prev], [data-market-next]')].forEach(button => button.disabled = quotes.length < 2);
} catch (error) {
marketStatus.hidden = false;
marketStatus.textContent = marketTrack.childElementCount
? 'Pembaruan gagal. Menampilkan harga sebelumnya dan akan mencoba kembali.'
: 'Harga belum dapat dimuat. Akan dicoba kembali otomatis.';
} finally {
window.setTimeout(() => {
if (!document.hidden) refreshInternationalPrices();
else waitForVisible();
}, 60000);
}
}
function waitForVisible() {
document.addEventListener('visibilitychange', function onVisible() {
if (document.hidden) return;
document.removeEventListener('visibilitychange', onVisible);
refreshInternationalPrices();
});
}
refreshInternationalPrices();
const wilayah = @json($wilayah); const wilayah = @json($wilayah);
const marketCharts = @json($marketCharts);
const chartDefinitions = @json($chartDefinitions);
const priceTrend = @json($priceTrend);
const premiumAwareness = @json($premiumAwareness);
function escapeHtml(value) {
const element = document.createElement('span');
element.textContent = String(value);
return element.innerHTML;
}
function formatNumber(value, digits = 0) {
return Number(value).toLocaleString('id-ID', { maximumFractionDigits: digits });
}
const compactLabels = {
saluranPenjualan: ['Tengkulak/pedagang lokal', 'Koperasi', 'BUMDes', 'Pembeli langsung/eksportir', 'Lainnya'],
waktuPenjualan: ['Langsung setelah panen', 'Ditumpuk beberapa waktu', 'Saat butuh uang mendesak'],
aksesSaluranLain: ['Tidak pernah', 'Berhenti, tanpa akses', 'Berhenti, bisa kembali', 'Sesekali masih menjual', 'Akses alternatif aktif'],
kesadaranPremium: ['Belum pernah mendengar', 'Belum paham persyaratan', 'Sedikit paham persyaratan', 'Paham, belum mengakses', 'Akses premium konsisten'],
};
const mobileLabels = {
saluranPenjualan: ['Tengkulak/lokal', 'Koperasi', 'BUMDes', 'Pembeli/eksportir', 'Lainnya'],
hubunganPembeli: ['Bebas pindah', 'Langganan tetap', 'Berbasis hutang', 'Titip jual', 'Komitmen volume', 'Semi-kontrak'],
waktuPenjualan: ['Setelah panen', 'Ditumpuk dulu', 'Uang mendesak'],
aksesSaluranLain: ['Tidak pernah', 'Tanpa akses', 'Bisa kembali', 'Sesekali jual', 'Akses aktif'],
kesadaranPremium: ['Belum mendengar', 'Belum paham syarat', 'Sedikit paham', 'Paham, belum akses', 'Akses konsisten'],
};
function hasTrendData(commodity) {
return commodity && commodity.series.some(function (series) {
return series.data.some(function (value) { return value !== null && Number(value) > 0; });
});
}
function formatPrice(value) {
return value === null || value === undefined ? 'Belum ada data' : 'Rp ' + formatNumber(value) + '/kg';
}
function renderPriceTrend(commodity) {
const element = document.getElementById('chart-tren-harga-' + commodity.id);
const summary = document.getElementById('summary-tren-harga-' + commodity.id);
if (!hasTrendData(commodity) || typeof ApexCharts === 'undefined') {
element.classList.add('pasar-chart-empty');
element.textContent = !hasTrendData(commodity)
? 'Belum ada data harga untuk komoditas dan filter yang dipilih.'
: 'Chart belum dapat dimuat. Silakan muat ulang halaman.';
return;
}
summary.textContent = commodity.name + '. ' + commodity.series.map(function (series) {
return series.name + ': ' + series.data.map(function (value, index) {
return priceTrend.periods[index] + ' ' + formatPrice(value);
}).join(', ');
}).join('. ');
const colors = config.chartColors.series.slice(0, 4);
const labelStyle = { colors: config.colors.bodyColor, fontSize: '12px', fontFamily: config.fontFamily };
new ApexCharts(element, {
chart: { type: 'line', height: 360, fontFamily: config.fontFamily, foreColor: config.colors.bodyColor, toolbar: { show: false }, zoom: { enabled: false } },
series: commodity.series,
colors: colors,
stroke: { width: 3, curve: 'straight' },
markers: { size: 5, strokeWidth: 2, hover: { sizeOffset: 2 } },
dataLabels: { enabled: false },
xaxis: {
categories: priceTrend.periods,
tickPlacement: 'between',
labels: { rotate: 0, style: labelStyle },
axisBorder: { color: config.chartColors.grid },
axisTicks: { color: config.chartColors.grid },
},
yaxis: {
min: 0,
title: { text: 'Harga (Rp/kg)', style: { color: config.colors.bodyColor, fontFamily: config.fontFamily, fontWeight: 400 } },
labels: { style: labelStyle, formatter: function (value) { return formatNumber(value); } },
},
grid: { borderColor: config.chartColors.grid, strokeDashArray: 4, padding: { left: 8, right: 20 } },
legend: { position: 'bottom', fontSize: '13px', fontFamily: config.fontFamily, labels: { colors: config.colors.bodyColor }, itemMargin: { horizontal: 12, vertical: 6 } },
tooltip: {
shared: true,
intersect: false,
custom: function ({ dataPointIndex }) {
const rows = commodity.series.map(function (series, index) {
return `<div class="pasar-trend-tooltip-row"><span style="color:${colors[index]}">${escapeHtml(series.name)}</span><strong>${formatPrice(series.data[dataPointIndex])}</strong></div>`;
}).join('');
return `<div class="pasar-chart-tooltip" style="background:${config.colors.cardColor};color:${config.colors.headingColor}"><strong>${escapeHtml(commodity.name)} — ${escapeHtml(priceTrend.periods[dataPointIndex])}</strong>${rows}</div>`;
},
},
responsive: [{
breakpoint: 576,
options: {
chart: { height: 420 },
legend: { fontSize: '12px', itemMargin: { horizontal: 6, vertical: 4 } },
xaxis: {
labels: {
hideOverlappingLabels: false,
style: { fontSize: '11px' },
formatter: function (value) {
return { 'Dua Tahun Lalu': '2 thn lalu', 'Tahun Lalu': 'Tahun lalu', 'Panen Terakhir': 'Terakhir' }[value] || value;
},
},
},
yaxis: {
title: { text: undefined },
labels: { style: { fontSize: '11px' }, formatter: function (value) { return formatNumber(value); } },
},
},
}],
}).render();
}
function renderResponseChart(key, data, definition) {
const element = document.getElementById('chart-' + key);
if (!data.respondents || typeof ApexCharts === 'undefined') {
element.classList.add('pasar-chart-empty');
element.textContent = !data.respondents
? 'Belum ada data jawaban untuk filter yang dipilih.'
: 'Chart belum dapat dimuat. Silakan muat ulang halaman.';
return;
}
const color = config.chartColors.semantic[definition.variant];
const borderColor = config.chartColors.grid;
const labelStyle = { colors: config.colors.bodyColor, fontSize: '12px', fontFamily: config.fontFamily };
const maxCount = Math.max(...data.series);
const tickCount = Math.min(4, maxCount);
const axisMax = Math.ceil(maxCount * 1.25 / tickCount) * tickCount;
new ApexCharts(element, {
chart: { type: 'bar', height: 330, fontFamily: config.fontFamily, foreColor: config.colors.bodyColor, toolbar: { show: false } },
series: [{ name: 'Responden', data: data.series }],
colors: [color],
plotOptions: { bar: { horizontal: true, borderRadius: 5, barHeight: '62%', dataLabels: { position: 'top' } } },
dataLabels: {
enabled: true,
textAnchor: 'start',
offsetX: 6,
style: { fontSize: '12px', fontFamily: config.fontFamily, fontWeight: 500, colors: [config.colors.headingColor] },
formatter: function (value, options) {
return `${formatNumber(value)} (${formatNumber(data.percentages[options.dataPointIndex], 1)}%)`;
},
},
xaxis: {
categories: compactLabels[key] || data.labels,
min: 0,
max: axisMax,
tickAmount: tickCount,
labels: { style: labelStyle, formatter: function (value) { return formatNumber(value); } },
axisBorder: { color: borderColor },
axisTicks: { color: borderColor },
},
yaxis: { labels: { maxWidth: 185, style: labelStyle } },
grid: { borderColor: borderColor, strokeDashArray: 4, padding: { right: 20 } },
legend: { show: false },
tooltip: {
theme: document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'light',
custom: function ({ dataPointIndex }) {
return `<div class="pasar-chart-tooltip" style="background:${color};color:${config.colors.headingColor};font-family:${config.fontFamily}">${escapeHtml(data.fullLabels[dataPointIndex])}: <strong>${formatNumber(data.series[dataPointIndex])} responden (${formatNumber(data.percentages[dataPointIndex], 1)}%)</strong></div>`;
},
},
responsive: [{
breakpoint: 576,
options: {
xaxis: { categories: mobileLabels[key], tickAmount: 2 },
yaxis: { labels: { maxWidth: 125, style: { fontSize: '11px' } } },
grid: { padding: { right: 10 } },
dataLabels: { style: { fontSize: '11px' }, formatter: function (value) { return formatNumber(value); } },
},
}],
}).render();
}
function renderMarketCharts() {
Object.entries(marketCharts).forEach(function ([key, data]) {
renderResponseChart(key, data, chartDefinitions[key]);
});
priceTrend.commodities.forEach(renderPriceTrend);
}
function setupChartPanel(panelId, renderCharts) {
const chartPanel = document.getElementById(panelId);
const chartControl = document.querySelector('.chart-group-header-toggle[aria-controls="' + chartPanel.id + '"]');
const chartIcon = chartControl.querySelector('.chart-group-icon-toggle i');
let rendered = false;
function syncChartPanelState() {
const expanded = chartPanel.classList.contains('show');
const label = expanded ? 'Sembunyikan chart' : 'Tampilkan chart';
chartControl.setAttribute('aria-expanded', String(expanded));
chartControl.setAttribute('aria-label', label);
chartControl.title = label;
chartIcon.classList.toggle('tabler-chevron-up', expanded);
chartIcon.classList.toggle('tabler-chevron-down', !expanded);
}
chartControl.addEventListener('keydown', function (event) {
if (event.target !== chartControl || !['Enter', ' '].includes(event.key)) return;
event.preventDefault();
chartControl.click();
});
chartPanel.addEventListener('shown.bs.collapse', function (event) {
if (event.target !== chartPanel) return;
syncChartPanelState();
if (!rendered) {
renderCharts();
rendered = true;
}
window.dispatchEvent(new Event('resize'));
});
chartPanel.addEventListener('hidden.bs.collapse', function (event) {
if (event.target === chartPanel) syncChartPanelState();
});
syncChartPanelState();
}
setupChartPanel('saluran-harga-charts', renderMarketCharts);
setupChartPanel('kesiapan-premium-charts', function () {
renderResponseChart('kesadaranPremium', premiumAwareness, { variant: 'primary' });
});
function fillSelect($select, items, placeholder) { function fillSelect($select, items, placeholder) {
$select.empty().append(new Option(placeholder, '')); $select.empty().append(new Option(placeholder, ''));
@@ -208,7 +914,7 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
} }
$('#filter-provinsi, #filter-kabkota, #filter-kecamatan, #filter-desa, #filter-komoditas').select2({ width: '100%' }); $('#filter-provinsi, #filter-kabkota, #filter-kecamatan, #filter-desa, #filter-komoditas, #filter-segmen').select2({ width: '100%' });
$('#filter-provinsi').on('change', function () { $('#filter-provinsi').on('change', function () {
const provinsi = selectedProvinsi(); const provinsi = selectedProvinsi();
+43 -320
View File
@@ -758,7 +758,7 @@
<div class="card-body d-flex justify-content-between align-items-center gap-3"> <div class="card-body d-flex justify-content-between align-items-center gap-3">
<div> <div>
<h5>Peta Sebaran</h5> <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> </div>
<span class="btn btn-icon btn-label-secondary btn-sm chart-group-icon-toggle" aria-hidden="true"> <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> <i class="icon-base ti tabler-chevron-down"></i>
@@ -773,8 +773,8 @@
<div class="card-body"> <div class="card-body">
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-2"> <div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-2">
<div> <div>
<h6 class="mb-0">Peta Sebaran Lahan & Tanaman</h6> <h6 class="mb-0">Peta Sebaran Lahan, Tanaman & Praktik Budidaya</h6>
<small class="text-body-secondary">Lahan dan tanaman berdasarkan filter wilayah, komoditas, dan segmen</small> <small class="text-body-secondary">Lahan, tanaman, OPT, pemangkasan, pemupukan, dan praktik baik berdasarkan filter wilayah, komoditas, dan segmen</small>
</div> </div>
<div class="btn-group btn-group-sm"> <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> <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>
</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>
</div> </div>
@@ -1044,8 +1016,8 @@
<div class="card h-100 produksi-chart-card"> <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-header d-flex align-items-start justify-content-between gap-2">
<div class="card-title mb-0"> <div class="card-title mb-0">
<h5 class="m-0 me-2">Praktik Fermentasi atau Pengolahan Pascapanen</h5> <h5 class="m-0 me-2">Praktik Fermentasi atau Pengolahan Pascapanen per Komoditas</h5>
<small class="text-body-secondary">Apakah sudah ada praktik fermentasi atau pengolahan pascapanen yang dilakukan</small> <small class="text-body-secondary">Jumlah petani per komoditas berdasarkan praktik fermentasi atau pengolahan pascapanen.</small>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
@@ -1079,11 +1051,7 @@ document.addEventListener('DOMContentLoaded', function () {
const mapData = @json($mapData); const mapData = @json($mapData);
const dashboardFilters = @json($dashboardFilters); const dashboardFilters = @json($dashboardFilters);
const mapInformationUrl = @json(route('dashboard.produktivitas.map-information')); const mapInformationUrl = @json(route('dashboard.produktivitas.map-information'));
const initialMapInformation = mapData.information || {}; const mapInformation = { ...(mapData.information || {}) };
const mapInformation = {
production: { ...initialMapInformation },
cultivation: { ...initialMapInformation },
};
const wilayah = @json($wilayah); const wilayah = @json($wilayah);
const headingColor = config.colors.headingColor; const headingColor = config.colors.headingColor;
const legendColor = config.colors.bodyColor; const legendColor = config.colors.bodyColor;
@@ -1094,9 +1062,8 @@ document.addEventListener('DOMContentLoaded', function () {
const labelColor = legendColor; const labelColor = legendColor;
const borderColor = pastelChart.grid || 'rgba(128, 128, 128, .18)'; const borderColor = pastelChart.grid || 'rgba(128, 128, 128, .18)';
let productionMapRendered = false; let productionMapRendered = false;
let cultivationMapRendered = false;
let actualProductivityRendered = false; let actualProductivityRendered = false;
const mapInformationRequestIds = { production: 0, cultivation: 0 }; let mapInformationRequestId = 0;
function hasData(data) { function hasData(data) {
return data && Array.isArray(data.series) && data.series.some(function (value) { 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); const element = document.getElementById(elementId);
if (!element) return; if (!element) return;
@@ -1193,17 +1160,15 @@ document.addEventListener('DOMContentLoaded', function () {
<span class="produksi-map-information-section-title">Jumlah Petani / Komoditas</span> <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> <div class="produksi-map-information-bars">${renderBars(commodityRows, 'Belum ada data komoditas.', 'petani')}</div>
</section> </section>
${includeOpt ? `
<section> <section>
<span class="produksi-map-information-section-title">OPT (Organisme Pengganggu Tanaman)</span> <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> <div class="produksi-map-information-bars">${renderBars(optRows, 'Belum ada data OPT.', 'lahan')}</div>
</section> </section>
` : ''}
`; `;
} }
async function loadProductionMapInformation(mapType, level = null, id = null) { async function loadProductionMapInformation(level = null, id = null) {
const requestId = ++mapInformationRequestIds[mapType]; const requestId = ++mapInformationRequestId;
const params = new URLSearchParams(); const params = new URLSearchParams();
Object.entries(dashboardFilters || {}).forEach(function ([key, value]) { Object.entries(dashboardFilters || {}).forEach(function ([key, value]) {
@@ -1225,14 +1190,10 @@ document.addEventListener('DOMContentLoaded', function () {
if (!response.ok) return; if (!response.ok) return;
const information = await response.json(); const information = await response.json();
if (requestId !== mapInformationRequestIds[mapType]) return; if (requestId !== mapInformationRequestId) return;
Object.assign(mapInformation[mapType], information); Object.assign(mapInformation, information);
if (mapType === 'production') { renderProductionMapInformation('produksi-map-information', mapInformation);
renderProductionMapInformation('produksi-map-information', mapInformation.production);
} else {
renderProductionMapInformation('produksi-budidaya-map-information', mapInformation.cultivation, false);
}
} catch (error) { } catch (error) {
console.error('Gagal memuat informasi wilayah pada peta.', error); console.error('Gagal memuat informasi wilayah pada peta.', error);
} }
@@ -1290,13 +1251,13 @@ document.addEventListener('DOMContentLoaded', function () {
}, 0); }, 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 numericValue = Number(value || 0);
const unitText = unit ? ` ${escapeHtml(unit)}` : ''; const unitText = unit ? ` ${escapeHtml(unit)}` : '';
const percentageText = total === null ? '' : ` (${chartPercentage(numericValue, total)}%)`; const percentageText = total === null ? '' : ` (${chartPercentage(numericValue, total)}%)`;
return ` 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> ${escapeHtml(label)}: <strong>${formatNumber(numericValue, digits)}${unitText}${percentageText}</strong>
</div> </div>
`; `;
@@ -1425,6 +1386,9 @@ document.addEventListener('DOMContentLoaded', function () {
const opt = Array.isArray(item.opt) && item.opt.length const opt = Array.isArray(item.opt) && item.opt.length
? item.opt.map(value => `<span class="produksi-opt-chip">${escapeHtml(value)}</span>`).join('') ? item.opt.map(value => `<span class="produksi-opt-chip">${escapeHtml(value)}</span>`).join('')
: '<span class="text-body-secondary small">Belum ada data OPT</span>'; : '<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 ` return `
<div class="produksi-lahan-popup"> <div class="produksi-lahan-popup">
@@ -1449,6 +1413,19 @@ document.addEventListener('DOMContentLoaded', function () {
<span class="produksi-popup-label">OPT</span> <span class="produksi-popup-label">OPT</span>
<div class="produksi-opt-list">${opt}</div> <div class="produksi-opt-list">${opt}</div>
</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>
</div> </div>
`; `;
@@ -1513,7 +1490,7 @@ document.addEventListener('DOMContentLoaded', function () {
renderProductionMapLevel(previous.level, previous.parentId); renderProductionMapLevel(previous.level, previous.parentId);
productionMapState.informationLevel = previous.informationLevel; productionMapState.informationLevel = previous.informationLevel;
productionMapState.informationId = previous.informationId; productionMapState.informationId = previous.informationId;
loadProductionMapInformation('production', previous.informationLevel, previous.informationId); loadProductionMapInformation(previous.informationLevel, previous.informationId);
if (previous.bounds?.isValid()) { if (previous.bounds?.isValid()) {
productionMapState.map.fitBounds(previous.bounds, { padding: [30, 30] }); productionMapState.map.fitBounds(previous.bounds, { padding: [30, 30] });
@@ -1585,7 +1562,8 @@ document.addEventListener('DOMContentLoaded', function () {
layer.bindPopup(landPopup(item), { layer.bindPopup(landPopup(item), {
className: 'produksi-map-popup', className: 'produksi-map-popup',
closeButton: true, closeButton: true,
maxWidth: 320, maxWidth: 340,
maxHeight: 360,
}); });
} else { } else {
layer.bindTooltip(regionTooltip(item), { layer.bindTooltip(regionTooltip(item), {
@@ -1606,7 +1584,7 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
productionMapState.informationLevel = nextLevel; productionMapState.informationLevel = nextLevel;
productionMapState.informationId = item.id; productionMapState.informationId = item.id;
loadProductionMapInformation('production', nextLevel, item.id); loadProductionMapInformation(nextLevel, item.id);
renderProductionMapLevel(nextProductionMapLevel(nextLevel), item.id, event.target.getBounds()); renderProductionMapLevel(nextProductionMapLevel(nextLevel), item.id, event.target.getBounds());
}); });
} }
@@ -1641,234 +1619,6 @@ document.addEventListener('DOMContentLoaded', function () {
}, 150); }, 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 = {}) { function renderDonut(selector, data, options = {}) {
const element = document.querySelector(selector); const element = document.querySelector(selector);
if (!element) return; if (!element) return;
@@ -1886,6 +1636,7 @@ document.addEventListener('DOMContentLoaded', function () {
stroke: { width: 0 }, stroke: { width: 0 },
dataLabels: { dataLabels: {
enabled: true, enabled: true,
dropShadow: { enabled: false },
formatter: function (value) { formatter: function (value) {
return value.toFixed(0) + '%'; return value.toFixed(0) + '%';
}, },
@@ -1893,7 +1644,7 @@ document.addEventListener('DOMContentLoaded', function () {
fontSize: '13px', fontSize: '13px',
fontFamily: fontFamily, fontFamily: fontFamily,
fontWeight: 600, fontWeight: 600,
colors: ['#fff'], colors: chartColors.map(config.chartTextColor),
}, },
}, },
legend: { legend: {
@@ -1915,7 +1666,7 @@ document.addEventListener('DOMContentLoaded', function () {
const label = w.globals.labels[seriesIndex] || 'Data'; const label = w.globals.labels[seriesIndex] || 'Data';
const color = w.globals.colors?.[seriesIndex] || chartColors[seriesIndex % chartColors.length] || '#9D8EF0'; 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: { plotOptions: {
@@ -1992,6 +1743,7 @@ document.addEventListener('DOMContentLoaded', function () {
legend: { show: false }, legend: { show: false },
dataLabels: { dataLabels: {
enabled: true, enabled: true,
dropShadow: { enabled: false },
formatter: function (value) { formatter: function (value) {
return value >= 8 ? value.toFixed(0) + '%' : ''; return value >= 8 ? value.toFixed(0) + '%' : '';
}, },
@@ -1999,7 +1751,7 @@ document.addEventListener('DOMContentLoaded', function () {
fontSize: '11px', fontSize: '11px',
fontFamily: fontFamily, fontFamily: fontFamily,
fontWeight: 600, fontWeight: 600,
colors: ['#fff'], colors: colors.map(config.chartTextColor),
}, },
}, },
tooltip: { tooltip: {
@@ -2008,7 +1760,7 @@ document.addEventListener('DOMContentLoaded', function () {
const label = w.globals.labels[seriesIndex] || 'Data'; const label = w.globals.labels[seriesIndex] || 'Data';
const color = w.globals.colors?.[seriesIndex] || colors[seriesIndex % colors.length] || '#9D8EF0'; 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 } }, 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-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 }); renderGroupedBar('#chart-kebiasaan-praktik-baik', chartData.kebiasaanPraktikBaik, { unit: 'petani', yTitle: 'Jumlah Petani', height: 330 });
renderPieWithLegend('#chart-praktik-sortasi-panen', chartData.praktikSortasiPanen, { unit: 'petani' }); 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(); renderActualProductivityIfVisible();
} }
@@ -2261,37 +2013,15 @@ document.addEventListener('DOMContentLoaded', function () {
productionMapRendered = true; productionMapRendered = true;
} }
function renderCultivationMapIfOpen() { renderProductionMapInformation('produksi-map-information', mapInformation);
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);
if (typeof L === 'undefined') { if (typeof L === 'undefined') {
const mapEmpty = document.getElementById('produksi-map-empty'); const mapEmpty = document.getElementById('produksi-map-empty');
const cultivationMapEmpty = document.getElementById('produksi-budidaya-map-empty');
if (mapEmpty) { if (mapEmpty) {
mapEmpty.textContent = 'Leaflet belum dimuat.'; mapEmpty.textContent = 'Leaflet belum dimuat.';
mapEmpty.style.display = 'flex'; mapEmpty.style.display = 'flex';
} }
if (cultivationMapEmpty) {
cultivationMapEmpty.textContent = 'Leaflet belum dimuat.';
cultivationMapEmpty.style.display = 'flex';
}
} }
document.querySelectorAll('.produksi-map-layer-btn').forEach(function (button) { 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, #filter-kabkota, #filter-kecamatan, #filter-desa, #filter-komoditas, #filter-segmen').select2({ width: '100%' });
$('#filter-provinsi').on('change', function () { $('#filter-provinsi').on('change', function () {
@@ -2388,7 +2112,6 @@ document.addEventListener('DOMContentLoaded', function () {
if (panel.id === 'peta-sebaran-panel' && typeof L !== 'undefined') { if (panel.id === 'peta-sebaran-panel' && typeof L !== 'undefined') {
renderProductionMapIfOpen(); renderProductionMapIfOpen();
renderCultivationMapIfOpen();
} }
window.dispatchEvent(new Event('resize')); window.dispatchEvent(new Event('resize'));
+1 -1
View File
@@ -7,7 +7,7 @@
<script> <script>
document.write(new Date().getFullYear()); document.write(new Date().getFullYear());
</script> </script>
, by <a href="#" target="_blank" class="footer-link">Yayasan Bhumi Loka Lestari</a> , by <a href="#" target="_blank" class="footer-link">PT Republik Solusi</a>
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -90,6 +90,6 @@
<script src="{{ asset('assets/vendor/js/template-customizer.js') }}"></script> <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') @stack('scripts-head')
</head> </head>
@@ -234,7 +234,7 @@
</div> </div>
<div class="mt-4 text-end"> <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> <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> </div>
@@ -31,12 +31,12 @@
<div class="card mb-6"> <div class="card mb-6">
<div class="card-body pt-12"> <div class="card-body pt-12">
<h5 class="pb-3 border-bottom mb-4">Saluran Penjualan & Akses Pasar</h5> <h5 class="pb-4 border-bottom mb-4">Saluran Penjualan & Akses Pasar</h5>
<h6 class="fw-bold text-dark mb-2">Saluran Penjualan Utama</h6> <h5 class="mb-4 mt-4">Saluran Penjualan Utama</h5>
<div class="mb-4"> <div class="mb-4">
<label class="form-label fw-semibold">144. Kepada siapa Bapak/Ibu biasanya menjual hasil panen? (boleh pilih lebih dari satu)</label> <p class="form-label d-block">Kepada siapa Bapak/Ibu biasanya menjual hasil panen? (boleh pilih lebih dari satu)</p>
<div class="row g-2"> <div class="row">
@php @php
$tujuanOptions = ['Tengkulak/pedagang lokal', 'Koperasi', 'BUMDes', 'Langsung ke pembeli/eksportir']; $tujuanOptions = ['Tengkulak/pedagang lokal', 'Koperasi', 'BUMDes', 'Langsung ke pembeli/eksportir'];
@endphp @endphp
@@ -55,16 +55,16 @@
<input class="form-check-input" type="checkbox" name="tujuan_penjualan[]" value="Lainnya" id="tujuan-lainnya" {{ in_array('Lainnya', (array) old('tujuan_penjualan', $aksesPasar?->tujuan_penjualan ?? []), true) ? 'checked' : '' }}> <input class="form-check-input" type="checkbox" name="tujuan_penjualan[]" value="Lainnya" id="tujuan-lainnya" {{ in_array('Lainnya', (array) old('tujuan_penjualan', $aksesPasar?->tujuan_penjualan ?? []), true) ? 'checked' : '' }}>
<label class="form-check-label" for="tujuan-lainnya">Lainnya:</label> <label class="form-check-label" for="tujuan-lainnya">Lainnya:</label>
</div> </div>
<input type="text" class="form-control form-control-sm w-auto flex-grow-1 max-w-400" style="max-width: 400px;" name="tujuan_penjualan_lainnya" value="{{ old('tujuan_penjualan_lainnya', $aksesPasar?->tujuan_penjualan_lainnya) }}" placeholder="Sebutkan lainnya..."> <input type="text" class="form-control w-auto flex-grow-1 max-w-400" style="max-width: 400px;" name="tujuan_penjualan_lainnya" value="{{ old('tujuan_penjualan_lainnya', $aksesPasar?->tujuan_penjualan_lainnya) }}" placeholder="Sebutkan lainnya...">
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<h6 class="fw-bold text-dark mt-4 mb-2">Sifat Hubungan dengan Pembeli</h6> <h5 class="mb-4 mt-4">Sifat Hubungan Dengan Pembeli</h5>
<div class="mb-4"> <div class="mb-4">
<label class="form-label fw-semibold">Bagaimana sifat hubungan jual Bapak/Ibu dengan pembeli utama saat ini?</label> <p class="form-label d-block">Bagaimana sifat hubungan jual Bapak/Ibu dengan pembeli utama saat ini?</p>
<div class="row g-2"> <div class="row">
@php @php
$sifatHubunganOptions = [ $sifatHubunganOptions = [
'Bebas, bisa pindah pembeli kapan saja', 'Bebas, bisa pindah pembeli kapan saja',
@@ -86,10 +86,10 @@
</div> </div>
</div> </div>
<h6 class="fw-bold text-dark mt-4 mb-2">Bentuk Produk yang Dijual</h6> <h5 class="mb-4 mt-4">Bentuk Produk Yang Dijual</h5>
<div class="mb-4"> <div class="mb-4">
<label class="form-label fw-semibold">Dalam bentuk apa produk dijual?</label> <p class="form-label d-block">Dalam bentuk apa produk dijual?</p>
<div class="row g-2"> <div class="row">
@php @php
$bentukPenjualanOptions = [ $bentukPenjualanOptions = [
'Buah segar hasil petik (gelondongan)', 'Buah segar hasil petik (gelondongan)',
@@ -109,10 +109,10 @@
</div> </div>
</div> </div>
<h6 class="fw-bold text-dark mt-4 mb-2">Frekuensi Penjualan</h6> <h5 class="mb-4 mt-4">Frekuensi Penjualan</h5>
<div class="mb-4"> <div class="mb-4">
<label class="form-label fw-semibold">Kapan biasanya menjual hasil panen?</label> <p class="form-label d-block">Kapan biasanya menjual hasil panen?</p>
<div class="d-flex flex-column gap-2"> <div class="d-flex flex-column">
@php @php
$frekuensiOptions = [ $frekuensiOptions = [
'Langsung setelah panen', 'Langsung setelah panen',
@@ -121,20 +121,19 @@
]; ];
@endphp @endphp
@foreach($frekuensiOptions as $freq) @foreach($frekuensiOptions as $freq)
<div class="form-check mb-1"> <div class="form-check mb-2">
<input class="form-check-input" type="radio" name="frekuensi_penjualan" value="{{ $freq }}" id="freq-{{ Str::slug($freq) }}" {{ old('frekuensi_penjualan', $aksesPasar?->frekuensi_penjualan) === $freq ? 'checked' : '' }}> <input class="form-check-input" type="radio" name="frekuensi_penjualan" value="{{ $freq }}" id="freq-{{ Str::slug($freq) }}" {{ old('frekuensi_penjualan', $aksesPasar?->frekuensi_penjualan) === $freq ? 'checked' : '' }}>
<label class="form-check-label" for="freq-{{ Str::slug($freq) }}">{{ $freq }}</label> <label class="form-check-label" for="freq-{{ Str::slug($freq) }}">{{ $freq }}</label>
</div> </div>
@endforeach @endforeach
</div> </div>
</div> </div>
<hr class="my-4">
<h5 class="pb-3 border-bottom mb-4">Pengalaman Saluran Lain</h5> <h5 class="pb-4 border-bottom mb-4 mt-4">Pengalaman Saluran Lain</h5>
<div class="row"> <div class="row">
<div class="col-md-6 mb-4 form-control-validation"> <div class="col-md-12 mb-4 form-control-validation">
<label class="form-label d-block fw-bold">Pengalaman & akses ke saluran lain</label> <label class="form-label d-block">Apakah pernah menjual ke tempat lain sebelumnya?</label>
@foreach($skorPengalamanOptions as $option) @foreach($skorPengalamanOptions as $option)
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input @error('skor_pengalaman') is-invalid @enderror" type="radio" <input class="form-check-input @error('skor_pengalaman') is-invalid @enderror" type="radio"
@@ -146,15 +145,23 @@
@error('skor_pengalaman')<div class="invalid-feedback d-block">{{ $message }}</div>@enderror @error('skor_pengalaman')<div class="invalid-feedback d-block">{{ $message }}</div>@enderror
</div> </div>
<div class="col-md-4 mb-4 form-control-validation"> <div class="col-md-12" id="blok-saluran-lanjutan">
<div class="row">
<div class="col-md-6 mb-4 form-control-validation">
<label class="form-label" for="alasan-berhenti">Alasan berhenti</label> <label class="form-label" for="alasan-berhenti">Alasan berhenti</label>
<input type="text" class="form-control @error('alasan_berhenti') is-invalid @enderror" id="alasan-berhenti" name="alasan_berhenti" value="{{ old('alasan_berhenti', $aksesPasar?->alasan_berhenti) }}" placeholder="Opsional (jika sudah berhenti)..."> <input type="text" class="form-control @error('alasan_berhenti') is-invalid @enderror" id="alasan-berhenti" name="alasan_berhenti" value="{{ old('alasan_berhenti', $aksesPasar?->alasan_berhenti) }}" placeholder="Opsional (jika sudah berhenti)...">
@error('alasan_berhenti')<div class="invalid-feedback">{{ $message }}</div>@enderror @error('alasan_berhenti')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div> </div>
<div class="col-md-6 mb-4 form-control-validation"> <div class="col-md-6 mb-4"></div>
<label class="form-label d-block mb-2" for="saluran-terdahulu">Dulu pernah jual ke?</label>
<div class="col-md-12 mb-4 form-control-validation">
<label class="form-label d-block" for="saluran-terdahulu">Jika pernah, dulu menjual ke mana? (boleh pilih lebih dari satu)</label>
<div class="row">
@foreach($saluranTerdahuluOptions as $option) @foreach($saluranTerdahuluOptions as $option)
@if($option['value'] !== $saluranLainnyaValue)
<div class="col-md-6">
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input @error('saluran_terdahulu') is-invalid @enderror" <input class="form-check-input @error('saluran_terdahulu') is-invalid @enderror"
type="checkbox" type="checkbox"
@@ -166,21 +173,39 @@
{{ $option['label'] }} {{ $option['label'] }}
</label> </label>
</div> </div>
</div>
@endif
@endforeach @endforeach
<div class="col-12 mt-2">
<div class="d-flex align-items-center gap-2 flex-wrap">
<div class="form-check mb-0">
<input class="form-check-input @error('saluran_terdahulu') is-invalid @enderror"
type="checkbox"
id="saluran-terdahulu-lainnya-checkbox"
name="saluran_terdahulu[]"
value="{{ $saluranLainnyaValue }}"
{{ in_array($saluranLainnyaValue, (array) old('saluran_terdahulu', $aksesPasar?->saluran_terdahulu ?? []), true) ? 'checked' : '' }}>
<label class="form-check-label text-nowrap" for="saluran-terdahulu-lainnya-checkbox">
Lainnya:
</label>
</div>
<input type="text" class="form-control w-auto flex-grow-1 @error('saluran_terdahulu_lainnya') is-invalid @enderror" style="max-width: 400px;" id="saluran-lainnya" name="saluran_terdahulu_lainnya" value="{{ old('saluran_terdahulu_lainnya', $aksesPasar?->saluran_terdahulu_lainnya) }}" placeholder="Sebutkan saluran lainnya...">
</div>
</div>
</div>
@error('saluran_terdahulu')<div class="invalid-feedback d-block">{{ $message }}</div>@enderror @error('saluran_terdahulu')<div class="invalid-feedback d-block">{{ $message }}</div>@enderror
@error('saluran_terdahulu_lainnya')<div class="invalid-feedback d-block">{{ $message }}</div>@enderror
</div> </div>
<div class="col-md-4 mb-4 form-control-validation"> </div>
<label class="form-label" for="saluran-lainnya">Lainnya</label>
<input type="text" class="form-control @error('saluran_terdahulu_lainnya') is-invalid @enderror" id="saluran-lainnya" name="saluran_terdahulu_lainnya" value="{{ old('saluran_terdahulu_lainnya', $aksesPasar?->saluran_terdahulu_lainnya) }}" placeholder="Sebutkan saluran lainnya...">
@error('saluran_terdahulu_lainnya')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div> </div>
</div> </div>
<h5 class="pb-3 border-bottom mb-4 mt-4">HARGA DI SETIAP TITIK</h5> <h5 class="pb-4 border-bottom mb-4 mt-4">Harga Di Setiap Titik</h5>
<h6 class="mb-2">Harga Jual Panen Terakhir</h6> <h5 class="mb-4 mt-4">Harga Jual Panen Terakhir</h5>
<p class="mb-1">Harga jual komoditas tiga musim panen terakhir:</p> <p class="form-label d-block">Harga jual komoditas tiga musim panen terakhir:</p>
<p class="mb-4">Dalam tiga musim panen terakhir, berapa harga jual yang Bapak/Ibu terima untuk komoditas ini sesuai dengan bentuk produk yang dijual?</p> <p class="form-label d-block">Dalam tiga musim panen terakhir, berapa harga jual yang Bapak/Ibu terima untuk komoditas ini sesuai dengan bentuk produk yang dijual?</p>
<div class="table-responsive mb-4"> <div class="table-responsive mb-4">
<table class="table table-bordered align-middle"> <table class="table table-bordered align-middle">
@@ -197,7 +222,7 @@
<tr> <tr>
<td> <td>
<input type="hidden" name="harga_jual[{{ $row['index'] }}][bentuk_produk]" value="{{ $row['bentuk'] }}"> <input type="hidden" name="harga_jual[{{ $row['index'] }}][bentuk_produk]" value="{{ $row['bentuk'] }}">
<strong>{{ $row['bentuk'] }}:</strong> {{ $row['bentuk'] }}:
</td> </td>
<td> <td>
<div class="input-group"> <div class="input-group">
@@ -226,18 +251,87 @@
</table> </table>
</div> </div>
<h5 class="pb-3 border-bottom mb-4 mt-4">PELUANG NILAI TAMBAH</h5> <h5 class="mb-4 mt-4">Sumber Informasi Harga</h5>
<div class="border rounded p-4 mb-4">
<div class="mb-4"> <div class="mb-4">
<h6 class="mb-1 text-primary">Detail Praktik Fermentasi</h6>
<p class="text-muted small mb-0">Apakah sudah ada praktik fermentasi atau pengolahan pascapanen yang Ibu/Bapak lakukan?</p> <div class="mb-4">
<p class="form-label d-block">Sebelum menjual, apakah Bapak/Ibu sudah tahu harga yang akan diterima?</p>
<div class="row">
@php
$tahuHargaOptions = [
'Selalu tahu sebelum pergi jual',
'Kadang tahu, kadang tidak',
'Baru tahu saat sudah di tempat pembeli',
'Tidak pernah tahu sebelumnya — langsung jual saja'
];
@endphp
@foreach($tahuHargaOptions as $tahu)
<div class="col-md-6">
<div class="form-check mb-2">
<input class="form-check-input @error('tahu_harga_sebelumnya') is-invalid @enderror" type="radio" name="tahu_harga_sebelumnya" value="{{ $tahu }}" id="tahu-{{ Str::slug($tahu) }}" {{ old('tahu_harga_sebelumnya', $aksesPasar?->tahu_harga_sebelumnya) === $tahu ? 'checked' : '' }}>
<label class="form-check-label" for="tahu-{{ Str::slug($tahu) }}">{{ $tahu }}</label>
</div>
</div>
@endforeach
</div>
@error('tahu_harga_sebelumnya')<div class="invalid-feedback d-block">{{ $message }}</div>@enderror
</div>
<div class="mb-0">
<p class="form-label d-block">Dari mana biasanya Bapak/Ibu tahu harga hasil panen sebelum menjual? (boleh pilih lebih dari satu)</p>
<div class="row">
@php
$sumberInfoHargaOptions = [
'Tengkulak/pedagang yang datang langsung',
'Sesama petani/tetangga',
'PPL/petugas dinas',
'HP/WhatsApp/internet',
'Radio/TV',
'Facebook',
'Tiktok',
'Instagram',
'Tidak pernah cek — langsung jual saja'
];
$sumberInfoLainnyaValue = 'Lainnya';
@endphp
@foreach($sumberInfoHargaOptions as $sumber)
<div class="col-md-4">
<div class="form-check mb-2">
<input class="form-check-input @error('sumber_info_harga') is-invalid @enderror" type="checkbox" name="sumber_info_harga[]" value="{{ $sumber }}" id="sumber-{{ Str::slug($sumber) }}" {{ in_array($sumber, (array) old('sumber_info_harga', $aksesPasar?->sumber_info_harga ?? []), true) ? 'checked' : '' }}>
<label class="form-check-label" for="sumber-{{ Str::slug($sumber) }}">{{ $sumber }}</label>
</div>
</div>
@endforeach
<div class="col-12 mt-2">
<div class="d-flex align-items-center gap-2 flex-wrap">
<div class="form-check mb-0">
<input class="form-check-input @error('sumber_info_harga') is-invalid @enderror" type="checkbox" name="sumber_info_harga[]" value="{{ $sumberInfoLainnyaValue }}" id="sumber-lainnya-checkbox" {{ in_array($sumberInfoLainnyaValue, (array) old('sumber_info_harga', $aksesPasar?->sumber_info_harga ?? []), true) ? 'checked' : '' }}>
<label class="form-check-label text-nowrap" for="sumber-lainnya-checkbox">Lainnya:</label>
</div>
<input type="text" class="form-control w-auto flex-grow-1 @error('sumber_info_harga_lainnya') is-invalid @enderror" style="max-width: 400px;" id="sumber-info-lainnya-input" name="sumber_info_harga_lainnya" value="{{ old('sumber_info_harga_lainnya', $aksesPasar?->sumber_info_harga_lainnya) }}" placeholder="Sebutkan sumber lainnya...">
</div>
</div>
</div>
@error('sumber_info_harga')<div class="invalid-feedback d-block">{{ $message }}</div>@enderror
@error('sumber_info_harga_lainnya')<div class="invalid-feedback d-block">{{ $message }}</div>@enderror
</div>
</div>
<h5 class="pb-4 border-bottom mb-4 mt-4">Peluang Nilai Tambah</h5>
<div class="mb-4">
<div class="mb-4">
<h5 class="mb-4 mt-4">Detail Praktik Fermentasi</h5>
<p class="form-label d-block">Apakah sudah ada praktik fermentasi atau pengolahan pascapanen yang Ibu/Bapak lakukan?</p>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-4 mb-4 form-control-validation d-flex flex-column"> <div class="col-md-4 mb-4 form-control-validation d-flex flex-column">
<div class="mb-2 d-flex align-items-end"> <div class="d-flex align-items-end">
<label class="form-label mb-0" for="praktik-fermentasi">Praktik fermentasi yang dilakukan</label> <label class="form-label" for="praktik-fermentasi">Praktik fermentasi yang dilakukan</label>
</div> </div>
<div> <div>
@foreach($praktikFermentasiOptions as $option) @foreach($praktikFermentasiOptions as $option)
@@ -258,8 +352,8 @@
</div> </div>
<div class="col-md-4 mb-4 form-control-validation d-flex flex-column"> <div class="col-md-4 mb-4 form-control-validation d-flex flex-column">
<div class="mb-2 d-flex align-items-end"> <div class="d-flex align-items-end">
<label class="form-label mb-0" for="proses-fermentasi">Jika ya, bagaimana prosesnya?</label> <label class="form-label" for="proses-fermentasi">Jika ya, bagaimana prosesnya?</label>
</div> </div>
<div> <div>
<textarea class="form-control @error('proses_fermentasi') is-invalid @enderror" id="proses-fermentasi" name="proses_fermentasi" rows="2" placeholder="Ceritakan prosesnya di sini...">{{ old('proses_fermentasi', $aksesPasar?->proses_fermentasi) }}</textarea> <textarea class="form-control @error('proses_fermentasi') is-invalid @enderror" id="proses-fermentasi" name="proses_fermentasi" rows="2" placeholder="Ceritakan prosesnya di sini...">{{ old('proses_fermentasi', $aksesPasar?->proses_fermentasi) }}</textarea>
@@ -268,8 +362,8 @@
</div> </div>
<div class="col-md-4 mb-4 form-control-validation d-flex flex-column"> <div class="col-md-4 mb-4 form-control-validation d-flex flex-column">
<div class="mb-2 d-flex align-items-end"> <div class="d-flex align-items-end">
<label class="form-label mb-0" for="model-fermentasi">Menurut Bapak/Ibu, apa model yang paling memungkinkan?</label> <label class="form-label" for="model-fermentasi">Menurut Bapak/Ibu, apa model yang paling memungkinkan?</label>
</div> </div>
<div> <div>
<textarea class="form-control @error('model_fermentasi_memungkinkan') is-invalid @enderror" id="model-fermentasi" name="model_fermentasi_memungkinkan" rows="2" placeholder="Model fermentasinya...">{{ old('model_fermentasi_memungkinkan', $aksesPasar?->model_fermentasi_memungkinkan) }}</textarea> <textarea class="form-control @error('model_fermentasi_memungkinkan') is-invalid @enderror" id="model-fermentasi" name="model_fermentasi_memungkinkan" rows="2" placeholder="Model fermentasinya...">{{ old('model_fermentasi_memungkinkan', $aksesPasar?->model_fermentasi_memungkinkan) }}</textarea>
@@ -279,11 +373,11 @@
</div> </div>
</div> </div>
<h5 class="pb-3 border-bottom mb-4 mt-4">AKSES PASAR PREMIUM</h5> <h5 class="pb-4 border-bottom mb-4 mt-4">Akses Pasar Premium</h5>
<h6 class="mb-3">Kesadaran Pasar Premium*</h6> <h5 class="mb-4 mt-4">Kesadaran Pasar Premium*</h5>
<div class="row"> <div class="row">
<div class="col-md-6 mb-4 form-control-validation"> <div class="col-md-6 mb-4 form-control-validation">
<label class="form-label d-block mb-2" for="skor-premium">Pengetahuan & akses pasar premium</label> <label class="form-label d-block" for="skor-premium">Pengetahuan & akses pasar premium</label>
@foreach($skorKesadaranPremiumOptions as $option) @foreach($skorKesadaranPremiumOptions as $option)
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input @error('skor_kesadaran_premium') is-invalid @enderror" <input class="form-check-input @error('skor_kesadaran_premium') is-invalid @enderror"
@@ -301,16 +395,16 @@
</div> </div>
<div class="col-md-6 mb-4 form-control-validation"> <div class="col-md-6 mb-4 form-control-validation">
<label class="form-label mb-2" for="sumber-info">Sumber informasi utama</label> <label class="form-label" for="sumber-info">Sumber informasi utama</label>
<input type="text" class="form-control @error('sumber_info_premium') is-invalid @enderror" id="sumber-info" name="sumber_info_premium" value="{{ old('sumber_info_premium', $aksesPasar?->sumber_info_premium) }}"> <input type="text" class="form-control @error('sumber_info_premium') is-invalid @enderror" id="sumber-info" name="sumber_info_premium" value="{{ old('sumber_info_premium', $aksesPasar?->sumber_info_premium) }}">
@error('sumber_info_premium')<div class="invalid-feedback">{{ $message }}</div>@enderror @error('sumber_info_premium')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div> </div>
</div> </div>
<h5 class="pb-3 border-bottom mb-4 mt-4">KEPERCAYAAN & PERSEPSI RISIKO</h5> <h5 class="pb-4 border-bottom mb-4 mt-4">Kepercayaan & Persepsi Risiko</h5>
<div class="row"> <div class="row">
<div class="col-md-6 mb-4 form-control-validation"> <div class="col-md-6 mb-4 form-control-validation">
<label class="form-label d-block mb-2" for="tingkat-kepercayaan">Seberapa percaya Bapak/Ibu untuk menjual ke pembeli atau pasar baru, di luar pembeli yang biasa?</label> <label class="form-label d-block" for="tingkat-kepercayaan">Seberapa percaya Bapak/Ibu untuk menjual ke pembeli atau pasar baru, di luar pembeli yang biasa?</label>
@foreach($tingkatKepercayaanOptions as $option) @foreach($tingkatKepercayaanOptions as $option)
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input @error('tingkat_kepercayaan_pembeli_baru') is-invalid @enderror" <input class="form-check-input @error('tingkat_kepercayaan_pembeli_baru') is-invalid @enderror"
@@ -328,7 +422,7 @@
</div> </div>
<div class="col-md-6 mb-4 form-control-validation"> <div class="col-md-6 mb-4 form-control-validation">
<label class="form-label mb-2" for="alasan-kepercayaan">Apa yang membuat Bapak/Ibu ragu atau yakin untuk mencoba pembeli baru?</label> <label class="form-label" for="alasan-kepercayaan">Apa yang membuat Bapak/Ibu ragu atau yakin untuk mencoba pembeli baru?</label>
<textarea class="form-control @error('alasan_kepercayaan_pembeli_baru') is-invalid @enderror" <textarea class="form-control @error('alasan_kepercayaan_pembeli_baru') is-invalid @enderror"
id="alasan-kepercayaan" id="alasan-kepercayaan"
name="alasan_kepercayaan_pembeli_baru" name="alasan_kepercayaan_pembeli_baru"
@@ -339,8 +433,9 @@
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-device-floppy me-1_5 icon-sm"></i>Simpan Akses Pasar <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>Simpan
</button> </button>
</div> </div>
</div> </div>
@@ -358,6 +453,8 @@
const saluranLainnyaValue = @json($saluranLainnyaValue); const saluranLainnyaValue = @json($saluranLainnyaValue);
const praktikBelumPernahValue = String(@json($praktikBelumPernahValue)); const praktikBelumPernahValue = String(@json($praktikBelumPernahValue));
const skorPengalamanBerhentiValues = @json($skorPengalamanBerhentiValues ?? [2,3]).map(Number); const skorPengalamanBerhentiValues = @json($skorPengalamanBerhentiValues ?? [2,3]).map(Number);
const skorBelumPernahValue = "1";
const sumberInfoLainnyaValue = @json($sumberInfoLainnyaValue ?? 'Lainnya');
function onlyDigits(value) { function onlyDigits(value) {
return String(value || '').replace(/\D/g, ''); return String(value || '').replace(/\D/g, '');
@@ -368,6 +465,22 @@
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, '.'); return digits.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
} }
function toggleSaluranLanjutan() {
const selectedRadio = $('input[name="skor_pengalaman"]:checked');
const val = selectedRadio.val();
const labelText = selectedRadio.parent().text().toLowerCase();
const isBelumPernah = !val || labelText.includes('belum pernah') || val === skorBelumPernahValue;
if (isBelumPernah) {
$('#blok-saluran-lanjutan').addClass('d-none');
$('#blok-saluran-lanjutan input[type="text"]').val('');
$('#blok-saluran-lanjutan input[type="checkbox"]').prop('checked', false);
} else {
$('#blok-saluran-lanjutan').removeClass('d-none');
}
}
function syncAlasanBerhenti() { function syncAlasanBerhenti() {
const val = Number($('input[name="skor_pengalaman"]:checked').val()); const val = Number($('input[name="skor_pengalaman"]:checked').val());
const isBerhenti = skorPengalamanBerhentiValues.includes(val); const isBerhenti = skorPengalamanBerhentiValues.includes(val);
@@ -381,6 +494,12 @@
if (!isLainnya) $('#saluran-lainnya').val(''); if (!isLainnya) $('#saluran-lainnya').val('');
} }
function syncSumberInfoLainnya() {
const isLainnya = $('input[name="sumber_info_harga[]"][value="' + sumberInfoLainnyaValue + '"]').is(':checked');
$('#sumber-info-lainnya-input').prop('disabled', !isLainnya);
if (!isLainnya) $('#sumber-info-lainnya-input').val('');
}
function syncPraktikFermentasi() { function syncPraktikFermentasi() {
const isBelumPernah = $('input[name="praktik_fermentasi"]:checked').val() === praktikBelumPernahValue; const isBelumPernah = $('input[name="praktik_fermentasi"]:checked').val() === praktikBelumPernahValue;
$('#proses-fermentasi, #model-fermentasi').prop('disabled', isBelumPernah); $('#proses-fermentasi, #model-fermentasi').prop('disabled', isBelumPernah);
@@ -389,9 +508,15 @@
} }
} }
$('input[name="skor_pengalaman"]').on('change', syncAlasanBerhenti); $('input[name="skor_pengalaman"]').on('change', function () {
toggleSaluranLanjutan();
syncAlasanBerhenti();
});
$('input[name="saluran_terdahulu[]"]').on('change', syncSaluranLainnya); $('input[name="saluran_terdahulu[]"]').on('change', syncSaluranLainnya);
$('input[name="sumber_info_harga[]"]').on('change', syncSumberInfoLainnya);
$('input[name="praktik_fermentasi"]').on('change', syncPraktikFermentasi); $('input[name="praktik_fermentasi"]').on('change', syncPraktikFermentasi);
$(document).on('input', '.rupiah-input', function () { $(document).on('input', '.rupiah-input', function () {
const cursorAtEnd = this.selectionStart === this.value.length; const cursorAtEnd = this.selectionStart === this.value.length;
this.value = formatRupiah(this.value); this.value = formatRupiah(this.value);
@@ -400,12 +525,15 @@
this.setSelectionRange(this.value.length, this.value.length); this.setSelectionRange(this.value.length, this.value.length);
} }
}); });
$('.rupiah-input').each(function () { $('.rupiah-input').each(function () {
this.value = formatRupiah(this.value); this.value = formatRupiah(this.value);
}); });
toggleSaluranLanjutan();
syncAlasanBerhenti(); syncAlasanBerhenti();
syncSaluranLainnya(); syncSaluranLainnya();
syncSumberInfoLainnya();
syncPraktikFermentasi(); syncPraktikFermentasi();
}); });
</script> </script>
@@ -13,6 +13,7 @@
@push('scripts') @push('scripts')
<script src="https://cdnjs.cloudflare.com/ajax/libs/croppie/2.6.5/croppie.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/croppie/2.6.5/croppie.min.js"></script>
@endpush
@section('content') @section('content')
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
@@ -43,13 +44,18 @@
<div class="col-md-3"> <div class="col-md-3">
<div class="user-avatar-section"> <div class="user-avatar-section">
<div class=" d-flex align-items-center flex-column"> <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" <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') }}" src="{{ $petani->foto ? route('petani.show-image', ['id' => $petani->id, 'field' => 'foto']) : asset('assets/img/avatars/3.jpg') }}"
alt="Foto profil petani" 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;" /> 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' }};"> <button type="button" id="btnHapusFoto" class="btn btn-sm btn-outline-danger mb-4" style="display: {{ $petani->foto ? 'block' : 'none' }};">
Hapus Foto Hapus Foto
@@ -113,7 +119,7 @@
<select class="form-control @error('pendidikan_terakhir') is-invalid @enderror" id="edit-petani-pendidikan-terakhir" name="pendidikan_terakhir"> <select class="form-control @error('pendidikan_terakhir') is-invalid @enderror" id="edit-petani-pendidikan-terakhir" name="pendidikan_terakhir">
<option value="">Pilih Pendidikan Terakhir</option> <option value="">Pilih Pendidikan Terakhir</option>
@foreach($pendidikan as $value) @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 @endforeach
</select> </select>
@error('pendidikan_terakhir')<div class="error invalid-feedback">{{ $message }}</div>@enderror @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"> <select class="form-control @error('status_perkawinan') is-invalid @enderror" id="edit-petani-status-pernikahan" name="status_perkawinan">
<option value="">Pilih Status Pernikahan</option> <option value="">Pilih Status Pernikahan</option>
@foreach($pernikahan as $value) @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 @endforeach
</select> </select>
@error('status_perkawinan')<div class="error invalid-feedback">{{ $message }}</div>@enderror @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"> <select class="form-control @error('provinsi_id') is-invalid @enderror" id="edit-petani-provinsi" name="provinsi_id">
<option value="">Pilih Provinsi</option> <option value="">Pilih Provinsi</option>
@foreach($provinsi as $p) @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 @endforeach
</select> </select>
@error('provinsi_id')<div class="error invalid-feedback">{{ $message }}</div>@enderror @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> <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" > <select class="form-control @error('kabupaten_id') is-invalid @enderror" id="edit-petani-kabupaten" name="kabupaten_id" >
<option value="">Pilih Kabupaten/Kota</option> <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> <option value="{{ $petani->desaKelurahan->kecamatan->kabupatenKota->id }}" selected>{{ $petani->desaKelurahan->kecamatan->kabupatenKota->nama }}</option>
@endif
</select> </select>
@error('kabupaten_id')<div class="error invalid-feedback">{{ $message }}</div>@enderror @error('kabupaten_id')<div class="error invalid-feedback">{{ $message }}</div>@enderror
</div> </div>
@@ -176,7 +184,9 @@
<label class="form-label" for="edit-petani-kecamatan">Kecamatan<span class="text-danger">*</span></label> <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" > <select class="form-control @error('kecamatan_id') is-invalid @enderror" id="edit-petani-kecamatan" name="kecamatan_id" >
<option value="">Pilih Kecamatan</option> <option value="">Pilih Kecamatan</option>
@if($petani->desaKelurahan?->kecamatan)
<option value="{{ $petani->desaKelurahan->kecamatan->id }}" selected>{{ $petani->desaKelurahan->kecamatan->nama }}</option> <option value="{{ $petani->desaKelurahan->kecamatan->id }}" selected>{{ $petani->desaKelurahan->kecamatan->nama }}</option>
@endif
</select> </select>
@error('kecamatan_id')<div class="error invalid-feedback">{{ $message }}</div>@enderror @error('kecamatan_id')<div class="error invalid-feedback">{{ $message }}</div>@enderror
</div> </div>
@@ -184,7 +194,9 @@
<label class="form-label" for="edit-petani-desa">Desa/Kelurahan<span class="text-danger">*</span></label> <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" > <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> <option value="">Pilih Desa/Kelurahan</option>
@if($petani->desaKelurahan)
<option value="{{ $petani->desaKelurahan->id }}" selected>{{ $petani->desaKelurahan->nama }}</option> <option value="{{ $petani->desaKelurahan->id }}" selected>{{ $petani->desaKelurahan->nama }}</option>
@endif
</select> </select>
@error('desa_kelurahan_id')<div class="error invalid-feedback">{{ $message }}</div>@enderror @error('desa_kelurahan_id')<div class="error invalid-feedback">{{ $message }}</div>@enderror
</div> </div>
@@ -242,7 +254,7 @@
</div> </div>
<div class="mt-4 text-end"> <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> <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> </div>
@@ -253,6 +265,20 @@
</div> </div>
</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 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-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
@@ -344,7 +370,11 @@
let croppieInstance = null; 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').val('');
$('#foto').click(); $('#foto').click();
}); });
@@ -58,7 +58,7 @@
<hr class="my-2"> <h6 class="mb-1" style="font-size: 0.85rem;">Domisili</h6> <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;">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> </div>
</div> </div>
@@ -116,7 +116,7 @@
</h5> </h5>
<p class="mb-0" style="font-size: 0.75rem;"> <p class="mb-0" style="font-size: 0.75rem;">
@if(!is_null($skorLahanTanaman)) @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 @else
<span class="me-1">Belum terisi</span> <span class="me-1">Belum terisi</span>
@endif @endif
@@ -2,6 +2,14 @@
@push('style') @push('style')
<style> <style>
#data-petani {
scroll-margin-top: 7rem;
}
.petani-row {
cursor: pointer;
}
.indicator-filter-row { .indicator-filter-row {
padding-bottom: 1.5rem; padding-bottom: 1.5rem;
padding-top: 1.5rem; padding-top: 1.5rem;
@@ -84,8 +92,8 @@
@section('content') @section('content')
<div class="container-fluid flex-grow-1 container-p-y"> <div class="container-fluid flex-grow-1 container-p-y">
<div class="d-flex flex-row gap-3 mb-4"> <div class="row g-3 mb-4">
<div class="w-25"> <div class="col-12 col-md-4">
<x-summary-card <x-summary-card
title="Petani" title="Petani"
count="{{ number_format($totalPetani ?? 0, 0, ',', '.') }}" count="{{ number_format($totalPetani ?? 0, 0, ',', '.') }}"
@@ -94,7 +102,7 @@
color="primary" color="primary"
/> />
</div> </div>
<div class="w-25"> <div class="col-12 col-md-4">
<x-summary-card <x-summary-card
title="Lahan & Tanaman" title="Lahan & Tanaman"
count="{{ number_format($totalLuasLahan ?? 0, 2, ',', '.') }} ha" count="{{ number_format($totalLuasLahan ?? 0, 2, ',', '.') }} ha"
@@ -103,7 +111,7 @@
color="success" color="success"
/> />
</div> </div>
<div class="w-25"> <div class="col-12 col-md-4">
<x-summary-card <x-summary-card
title="Segmentasi" title="Segmentasi"
count="{{ number_format((int) ($skor->firstWhere('segmen', 'Penggerak')->total ?? 0), 0, ',', '.') }} Penggerak" count="{{ number_format((int) ($skor->firstWhere('segmen', 'Penggerak')->total ?? 0), 0, ',', '.') }} Penggerak"
@@ -265,7 +273,7 @@
</div> </div>
</div> </div>
<div class="card"> <div class="card" id="data-petani">
<div class="card-header border-bottom"> <div class="card-header border-bottom">
<h5 class="card-title mb-0">Data Petani</h5> <h5 class="card-title mb-0">Data Petani</h5>
</div> </div>
@@ -315,10 +323,10 @@
</thead> </thead>
<tbody> <tbody>
@forelse($petani as $index => $item) @forelse($petani as $index => $item)
<tr> <tr class="petani-row">
<td>{{ $loop->iteration }}</td> <td>{{ $loop->iteration }}</td>
<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"> <small class="text-muted">
{{ $item->jenis_kelamin ? 'Laki-laki' : 'Perempuan' }} {{ $item->jenis_kelamin ? 'Laki-laki' : 'Perempuan' }}
@if(! is_null($item->umur)) @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 () { $(document).on('click', '.delete-record', function () {
const id = $(this).data('id'); const id = $(this).data('id');
const name = $(this).data('nama'); const name = $(this).data('nama');
@@ -517,7 +517,7 @@
<div class="row mt-3"> <div class="row mt-3">
<div class="col-12 text-end"> <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> <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>
</div> </div>
@@ -406,8 +406,8 @@
<div class="row mt-3"> <div class="row mt-3">
<div class="col-12 text-end"> <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> <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> <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>
</div> </div>
@@ -86,6 +86,7 @@
</div> </div>
<div class="mb-3"> <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> <button type="submit" class="btn btn-primary">Simpan Perubahan</button>
</div> </div>
</form> </form>
@@ -77,6 +77,7 @@
</table> </table>
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Simpan
@@ -167,11 +167,14 @@
Tambah Pelatihan Tambah Pelatihan
</button> </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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Riwayat Pelatihan Simpan Riwayat Pelatihan
</button> </button>
</div> </div>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -347,11 +350,14 @@
Tambah Sertifikasi Tambah Sertifikasi
</button> </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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Sertifikasi Keahlian Simpan Sertifikasi Keahlian
</button> </button>
</div> </div>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -499,6 +499,7 @@
</div> </div>
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
Simpan Simpan
</button> </button>
@@ -530,6 +530,7 @@
</div> </div>
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Simpan
@@ -1039,6 +1040,7 @@
</div> </div>
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Simpan
@@ -1352,6 +1354,7 @@
</div> </div>
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Simpan
@@ -1542,6 +1545,7 @@
</div> </div>
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Simpan
@@ -1678,10 +1682,13 @@
Tambah Komoditas Tambah Komoditas
</button> </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"> <button type="submit" class="btn btn-success">
Simpan Produktivitas Simpan
</button> </button>
</div> </div>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -1725,6 +1732,7 @@
</div> </div>
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Simpan
@@ -399,6 +399,7 @@
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Simpan
@@ -567,6 +568,7 @@
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Simpan
@@ -696,6 +698,7 @@
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Simpan
@@ -915,6 +918,7 @@
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Simpan
@@ -1043,6 +1047,7 @@
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Simpan
@@ -398,6 +398,7 @@
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Simpan
@@ -566,6 +567,7 @@
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Simpan
@@ -694,6 +696,7 @@
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Simpan
@@ -912,6 +915,7 @@
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Simpan
@@ -1039,6 +1043,7 @@
</div> </div>
<div class="mt-4 text-end"> <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"> <button type="submit" class="btn btn-success">
<i class="icon-base ti tabler-check me-1_5 icon-sm"></i> <i class="icon-base ti tabler-check me-1_5 icon-sm"></i>
Simpan Simpan
@@ -85,7 +85,7 @@
</div> </div>
<div class="mt-4 text-end"> <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> </div>
</form> </form>
</div> </div>
File diff suppressed because it is too large Load Diff
+1
View File
@@ -43,6 +43,7 @@ Route::middleware('auth')
Route::get('/dashboard/produktivitas/map-information', [App\Http\Controllers\Web\Dashboard\SummaryController::class, 'mapInformation'])->name('dashboard.produktivitas.map-information'); Route::get('/dashboard/produktivitas/map-information', [App\Http\Controllers\Web\Dashboard\SummaryController::class, 'mapInformation'])->name('dashboard.produktivitas.map-information');
Route::get('/dashboard/produktivitas', [App\Http\Controllers\Web\Dashboard\SummaryController::class, 'produktivitas'])->name('dashboard.produktivitas'); Route::get('/dashboard/produktivitas', [App\Http\Controllers\Web\Dashboard\SummaryController::class, 'produktivitas'])->name('dashboard.produktivitas');
Route::get('/dashboard/pasar/harga-internasional', App\Http\Controllers\Web\Dashboard\InternationalMarketController::class)->name('dashboard.pasar.international');
Route::get('/dashboard/pasar', [App\Http\Controllers\Web\Dashboard\SummaryController::class, 'pasar'])->name('dashboard.pasar'); Route::get('/dashboard/pasar', [App\Http\Controllers\Web\Dashboard\SummaryController::class, 'pasar'])->name('dashboard.pasar');
// Dashboard // Dashboard
@@ -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([[], [], []]);
});
+338
View File
@@ -0,0 +1,338 @@
<?php
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');
$table->softDeletes();
});
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');
$table->integer('desa_kelurahan_id');
$table->softDeletes();
});
Schema::create('harga_jual_panen', function (Blueprint $table) {
$table->id();
$table->integer('petani_id');
$table->string('bentuk_produk');
$table->integer('harga_panen_terakhir')->nullable();
$table->integer('harga_tahun_lalu')->nullable();
$table->integer('harga_dua_tahun_lalu')->nullable();
});
Schema::create('petani_skor', function (Blueprint $table) {
$table->id();
$table->integer('petani_id');
$table->float('skor_kesiapan_adopsi');
$table->softDeletes();
});
Schema::create('akses_pasar', function (Blueprint $table) {
$table->id();
$table->integer('petani_id');
$table->text('tujuan_penjualan')->nullable();
$table->text('sifat_hubungan_bisnis')->nullable();
$table->string('frekuensi_penjualan')->nullable();
$table->integer('skor_pengalaman')->nullable();
$table->integer('skor_kesadaran_premium')->nullable();
$table->softDeletes();
});
DB::table('master_komoditas')->insert([
['id' => 1, 'nama' => 'Kakao'], ['id' => 2, 'nama' => 'Vanili'],
['id' => 3, 'nama' => 'Kopi'], ['id' => 4, 'nama' => 'Kemiri'],
]);
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],
]);
DB::table('petani')->insert([
['id' => 1, 'komoditas_id' => 1, 'desa_kelurahan_id' => 1, 'deleted_at' => null],
['id' => 2, 'komoditas_id' => 1, 'desa_kelurahan_id' => 2, 'deleted_at' => null],
['id' => 3, 'komoditas_id' => 2, 'desa_kelurahan_id' => 1, 'deleted_at' => null],
['id' => 4, 'komoditas_id' => 3, 'desa_kelurahan_id' => 1, 'deleted_at' => null],
['id' => 5, 'komoditas_id' => 4, 'desa_kelurahan_id' => 1, 'deleted_at' => null],
['id' => 6, 'komoditas_id' => 1, 'desa_kelurahan_id' => 1, 'deleted_at' => '2026-01-01'],
]);
foreach ([
[1, 'Biji kering/Biji Kupas', 10000], [2, 'Biji kering/Biji Kupas', 20000],
[1, 'Biji kering/Biji Kupas', 0], [1, 'Biji kering/Biji Kupas', null],
[1, 'Biji kering/Biji Kupas', -1000], [1, 'Biji basah', 90000],
[1, 'Buah segar hasil petik', 5000],
[3, 'Buah segar hasil petik', 30000], [3, 'Biji kering/Biji Kupas', 500000],
[3, 'Terfermentasi (Rp/kg)', 900000], [4, 'Buah segar hasil petik', 12000],
[5, 'Buah segar hasil petik', 8000], [5, 'Biji kering/Biji Kupas', 40000],
[6, 'Biji kering/Biji Kupas', 1000000],
] as [$farmer, $product, $price]) {
DB::table('harga_jual_panen')->insert([
'petani_id' => $farmer, 'bentuk_produk' => $product, 'harga_panen_terakhir' => $price,
]);
}
DB::table('petani_skor')->insert([
['petani_id' => 1, 'skor_kesiapan_adopsi' => 4.2, 'deleted_at' => null],
['petani_id' => 2, 'skor_kesiapan_adopsi' => 2.5, 'deleted_at' => null],
['petani_id' => 3, 'skor_kesiapan_adopsi' => 4.5, 'deleted_at' => '2026-01-01'],
]);
$controller = app(SummaryController::class);
$method = new ReflectionMethod($controller, 'marketDashboardData');
$this->marketCards = fn (array $filters = []) => $method->invoke($controller, new Request($filters))['priceCards'];
$this->marketCharts = fn (array $filters = []) => $method->invoke($controller, new Request($filters))['marketCharts'];
$this->priceTrend = fn (array $filters = []) => $method->invoke($controller, new Request($filters))['priceTrend'];
$this->premiumAwareness = fn (array $filters = []) => $method->invoke($controller, new Request($filters))['premiumAwareness'];
});
test('market shows all four prices using the appropriate product and positive active farmer prices', function () {
$cards = ($this->marketCards)();
expect(array_column($cards, 'label'))->toBe([
'Kakao - Harga Biji Kering', 'Vanili - Harga Buah Segar',
'Kopi - Harga Ceri Merah', 'Kemiri - Harga Gelondong',
]);
expect(array_column($cards, 'value'))->toBe(['Rp 15.000', 'Rp 30.000', 'Rp 12.000', 'Rp 8.000']);
expect(array_column($cards, 'records'))->toBe([2, 1, 1, 1]);
});
test('market prices respect each region level and commodity selection', function () {
foreach (['provinsi_id', 'kabupatenKota_id', 'kecamatan_id', 'desaKelurahan_id'] as $region) {
expect(($this->marketCards)([$region => 1])[0]['value'])
->toBe($region === 'desaKelurahan_id' ? 'Rp 10.000' : 'Rp 15.000');
expect(array_column(($this->marketCards)([$region => 99]), 'records'))->toBe([0, 0, 0, 0]);
}
$cards = ($this->marketCards)(['komoditas_id' => 2]);
expect(array_column($cards, 'records'))->toBe([0, 1, 0, 0]);
expect(array_column($cards, 'value'))->toBe(['Rp 0', 'Rp 30.000', 'Rp 0', 'Rp 0']);
});
test('market prices respect segment selection and ignore deleted scores', function () {
$cards = ($this->marketCards)(['segmen' => 'Penggerak']);
expect(array_column($cards, 'records'))->toBe([1, 0, 0, 0]);
expect($cards[0]['value'])->toBe('Rp 10.000');
expect(($this->marketCards)(['segmen' => 'Pemerhati'])[0]['value'])->toBe('Rp 20.000');
});
test('market keeps four empty cards when no prices or active commodities are available', function () {
DB::table('master_komoditas')->update(['deleted_at' => '2026-01-01']);
$cards = ($this->marketCards)();
expect(array_column($cards, 'records'))->toBe([0, 0, 0, 0]);
expect(array_column($cards, 'value'))->toBe(['Rp 0', 'Rp 0', 'Rp 0', 'Rp 0']);
});
test('market charts count multiple channels once per respondent and use answered question denominators', function () {
DB::table('akses_pasar')->insert([
[
'petani_id' => 1,
'tujuan_penjualan' => json_encode(['Koperasi', 'BUMDes', 'Koperasi', 'Lainnya']),
'sifat_hubungan_bisnis' => 'Bebas, bisa pindah pembeli kapan saja',
'frekuensi_penjualan' => 'Langsung setelah panen',
'skor_pengalaman' => 5,
],
[
'petani_id' => 2,
'tujuan_penjualan' => json_encode(['Koperasi']),
'sifat_hubungan_bisnis' => 'Titip jual,dibayar setelah produk terjual',
'frekuensi_penjualan' => null,
'skor_pengalaman' => 1,
],
[
'petani_id' => 3,
'tujuan_penjualan' => '[]',
'sifat_hubungan_bisnis' => '',
'frekuensi_penjualan' => 'Saat butuh uang mendesak',
'skor_pengalaman' => 3,
],
]);
$charts = ($this->marketCharts)();
expect($charts['saluranPenjualan']['series'])->toBe([0, 2, 1, 0, 1]);
expect($charts['saluranPenjualan']['respondents'])->toBe(2);
expect($charts['saluranPenjualan']['percentages'])->toBe([0.0, 100.0, 50.0, 0.0, 50.0]);
expect($charts['hubunganPembeli']['series'])->toBe([1, 0, 0, 1, 0, 0]);
expect($charts['hubunganPembeli']['respondents'])->toBe(2);
expect($charts['waktuPenjualan']['series'])->toBe([1, 0, 1]);
expect($charts['waktuPenjualan']['respondents'])->toBe(2);
expect($charts['aksesSaluranLain']['series'])->toBe([1, 0, 1, 0, 1]);
expect($charts['aksesSaluranLain']['percentages'])->toBe([33.3, 0.0, 33.3, 0.0, 33.3]);
});
test('market charts respect all dashboard filters and exclude deleted farmers and responses', function () {
foreach ([1, 2, 3, 6] as $farmer) {
DB::table('akses_pasar')->insert([
'petani_id' => $farmer,
'tujuan_penjualan' => json_encode(['Koperasi']),
'sifat_hubungan_bisnis' => 'Langganan tetap, tapi tidak terikat',
'frekuensi_penjualan' => 'Ditumpuk dulu beberapa waktu',
'skor_pengalaman' => 4,
]);
}
DB::table('akses_pasar')->insert([
'petani_id' => 4, 'tujuan_penjualan' => json_encode(['BUMDes']), 'deleted_at' => '2026-01-01',
]);
foreach (['provinsi_id', 'kabupatenKota_id', 'kecamatan_id', 'desaKelurahan_id'] as $region) {
foreach (($this->marketCharts)([$region => 1]) as $chart) {
expect($chart['respondents'])->toBe($region === 'desaKelurahan_id' ? 2 : 3);
}
foreach (($this->marketCharts)([$region => 99]) as $chart) {
expect($chart['respondents'])->toBe(0);
}
}
foreach ([['komoditas_id' => 1], ['komoditas_id' => 2], ['segmen' => 'Penggerak'], ['segmen' => 'Pemerhati']] as $filters) {
foreach (($this->marketCharts)($filters) as $chart) {
expect($chart['respondents'])->toBe($filters === ['komoditas_id' => 1] ? 2 : 1);
}
}
foreach (($this->marketCharts)(['komoditas_id' => 2, 'segmen' => 'Penggerak']) as $chart) {
expect($chart['respondents'])->toBe(0);
}
});
test('market charts keep ordered zero categories when answers are missing or invalid', function () {
DB::table('akses_pasar')->insert([
'petani_id' => 1, 'tujuan_penjualan' => '[null, {}, "Unknown"]',
'sifat_hubungan_bisnis' => 'Unknown', 'frekuensi_penjualan' => '', 'skor_pengalaman' => 9,
]);
$charts = ($this->marketCharts)();
expect(array_map(fn ($chart) => count($chart['series']), $charts))->toBe([
'saluranPenjualan' => 5, 'hubunganPembeli' => 6, 'waktuPenjualan' => 3, 'aksesSaluranLain' => 5,
]);
foreach ($charts as $chart) {
expect($chart['respondents'])->toBe(0);
expect(array_sum($chart['series']))->toBe(0);
expect(array_sum($chart['percentages']))->toBe(0);
}
});
test('price trend averages each product and commodity chronologically without treating missing prices as zero', function () {
DB::table('harga_jual_panen')->delete();
foreach ([
[1, 'Buah segar hasil petik', 10000, 8000, 6000],
[2, 'Buah segar hasil petik', 20000, 12000, null],
[3, 'Buah segar hasil petik', 30000, 20000, 10000],
[1, 'Biji basah', 0, 7000, 4000],
[1, 'Biji kering/Biji Kupas', -1000, null, 0],
[1, 'Terfermentasi (Rp/kg)', null, 20000, 10000],
[6, 'Buah segar hasil petik', 1000000, 1000000, 1000000],
] as [$farmer, $product, $latest, $lastYear, $twoYearsAgo]) {
DB::table('harga_jual_panen')->insert([
'petani_id' => $farmer, 'bentuk_produk' => $product,
'harga_panen_terakhir' => $latest, 'harga_tahun_lalu' => $lastYear, 'harga_dua_tahun_lalu' => $twoYearsAgo,
]);
}
$trend = ($this->priceTrend)();
$commodities = array_column($trend['commodities'], null, 'id');
expect($trend['periods'])->toBe(['Dua Tahun Lalu', 'Tahun Lalu', 'Panen Terakhir']);
expect(array_column($commodities[1]['series'], 'name'))->toBe([
'Buah segar hasil petik', 'Biji basah', 'Biji kering/Biji Kupas', 'Terfermentasi',
]);
expect(array_column($commodities[1]['series'], 'data'))->toBe([
[6000.0, 10000.0, 15000.0], [4000.0, 7000.0, null], [null, null, null], [10000.0, 20000.0, null],
]);
expect($commodities[2]['series'][0]['data'])->toBe([10000.0, 20000.0, 30000.0]);
});
test('price trend respects dashboard region commodity and segment filters', function () {
// Historical prices must follow the same filters as the latest harvest.
DB::table('harga_jual_panen')->where('harga_panen_terakhir', '>', 0)
->update(['harga_tahun_lalu' => DB::raw('harga_panen_terakhir / 2')]);
foreach (['provinsi_id', 'kabupatenKota_id', 'kecamatan_id', 'desaKelurahan_id'] as $region) {
$commodities = array_column(($this->priceTrend)([$region => 1])['commodities'], null, 'id');
$price = $region === 'desaKelurahan_id' ? 10000.0 : 15000.0;
expect($commodities[1]['series'][2]['data'])->toBe([null, $price / 2, $price]);
foreach (($this->priceTrend)([$region => 99])['commodities'] as $commodity) {
expect(array_column($commodity['series'], 'data'))->toBe(array_fill(0, 4, [null, null, null]));
}
}
$trend = ($this->priceTrend)(['komoditas_id' => 2]);
expect(array_column($trend['commodities'], 'id'))->toBe([2]);
expect($trend['commodities'][0]['series'][0]['data'])->toBe([null, 15000.0, 30000.0]);
foreach (['Penggerak' => 10000.0, 'Pemerhati' => 20000.0] as $segment => $price) {
$commodities = array_column(($this->priceTrend)(['segmen' => $segment])['commodities'], null, 'id');
expect($commodities[1]['series'][2]['data'])->toBe([null, $price / 2, $price]);
expect($commodities[2]['series'][0]['data'])->toBe([null, null, null]);
}
});
test('price trend retains missing points and handles empty or deleted commodity selections', function () {
DB::table('harga_jual_panen')->delete();
$trend = ($this->priceTrend)();
expect($trend['commodities'])->toHaveCount(4);
foreach ($trend['commodities'] as $commodity) {
expect(array_column($commodity['series'], 'data'))->toBe(array_fill(0, 4, [null, null, null]));
}
expect(($this->priceTrend)(['komoditas_id' => 99])['commodities'])->toBe([]);
DB::table('master_komoditas')->where('id', 1)->update(['deleted_at' => '2026-01-01']);
expect(($this->priceTrend)(['komoditas_id' => 1])['commodities'])->toBe([]);
});
test('premium awareness counts each active farmer once and preserves the five ordered levels', function () {
DB::table('akses_pasar')->insert([
'petani_id' => 4, 'skor_kesadaran_premium' => 4, 'deleted_at' => '2026-01-01',
]);
foreach ([1 => 1, 2 => 3, 3 => 3, 4 => 5, 5 => null, 6 => 5] as $farmer => $score) {
DB::table('akses_pasar')->insert(['petani_id' => $farmer, 'skor_kesadaran_premium' => $score]);
}
DB::table('akses_pasar')->insert(['petani_id' => 1, 'skor_kesadaran_premium' => 2]);
$chart = ($this->premiumAwareness)();
expect($chart['series'])->toBe([1, 0, 2, 0, 1]);
expect($chart['percentages'])->toBe([25.0, 0.0, 50.0, 0.0, 25.0]);
expect($chart['respondents'])->toBe(4);
expect($chart['fullLabels'][0])->toBe('Belum pernah mendengar sama sekali');
expect($chart['fullLabels'][4])->toBe('Sudah mengakses dan mendapat harga premium secara konsisten');
});
test('premium awareness respects all dashboard region commodity and segment filters', function () {
foreach ([1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 5] as $farmer => $score) {
DB::table('akses_pasar')->insert(['petani_id' => $farmer, 'skor_kesadaran_premium' => $score]);
}
foreach (['provinsi_id', 'kabupatenKota_id', 'kecamatan_id', 'desaKelurahan_id'] as $region) {
$chart = ($this->premiumAwareness)([$region => 1]);
expect($chart['respondents'])->toBe($region === 'desaKelurahan_id' ? 4 : 5);
expect(($this->premiumAwareness)([$region => 99])['respondents'])->toBe(0);
}
expect(($this->premiumAwareness)(['komoditas_id' => 1])['series'])->toBe([1, 1, 0, 0, 0]);
expect(($this->premiumAwareness)(['komoditas_id' => 2])['series'])->toBe([0, 0, 1, 0, 0]);
expect(($this->premiumAwareness)(['segmen' => 'Penggerak'])['series'])->toBe([1, 0, 0, 0, 0]);
expect(($this->premiumAwareness)(['segmen' => 'Pemerhati'])['series'])->toBe([0, 1, 0, 0, 0]);
expect(($this->premiumAwareness)(['komoditas_id' => 2, 'segmen' => 'Penggerak'])['respondents'])->toBe(0);
});
test('premium awareness retains empty categories and excludes missing or invalid scores', function () {
$emptyChart = ($this->premiumAwareness)();
foreach ([1 => 0, 2 => 6, 3 => null, 4 => -1, 5 => null] as $farmer => $score) {
DB::table('akses_pasar')->insert(['petani_id' => $farmer, 'skor_kesadaran_premium' => $score]);
}
$chart = ($this->premiumAwareness)();
expect($chart)->toBe($emptyChart);
expect($chart['series'])->toBe([0, 0, 0, 0, 0]);
expect($chart['respondents'])->toBe(0);
expect($chart['labels'])->toHaveCount(5);
expect(array_sum($chart['percentages']))->toBe(0);
});
+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([]);
});