Merge branch 'main' of https://git.probindo.com/Probindo/database-pertani-web
This commit is contained in:
@@ -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()
|
||||||
$selectedKomoditas = Komoditas::query()
|
->whereIn('nama', array_column($products, 'name'))
|
||||||
->whereKey($request->komoditas_id)
|
->pluck('id', 'nama');
|
||||||
->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, ',', '.'),
|
||||||
'unit' => '/kg',
|
'label' => $product['name'].' - '.$product['caption'],
|
||||||
'variant' => 'success',
|
'description' => 'Harga rata-rata panen terakhir di tingkat petani',
|
||||||
'icon' => 'tabler-cash',
|
'records' => (int) ($price?->records ?? 0),
|
||||||
],
|
'unit' => '/kg',
|
||||||
|
'variant' => $product['variant'],
|
||||||
|
'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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
],
|
],
|
||||||
|
|||||||
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 |
@@ -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="row g-4 mb-4">
|
||||||
<div class="alert alert-primary d-flex align-items-start gap-3" role="alert">
|
@foreach ($priceCards as $priceCard)
|
||||||
<i class="icon-base ti tabler-info-circle mt-1"></i>
|
<div class="col-12 col-md-6 col-xl-3">
|
||||||
<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="col-xl-4 col-md-6">
|
|
||||||
<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 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 & 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>
|
||||||
@endif
|
</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">←</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">→</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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">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-12 mb-4 form-control-validation">
|
<div class="col-md-12 mb-4 form-control-validation">
|
||||||
<label class="form-label d-block fw-bold">Apakah pernah menjual ke tempat lain sebelumnya?</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"
|
||||||
@@ -158,8 +157,8 @@
|
|||||||
<div class="col-md-6 mb-4"></div>
|
<div class="col-md-6 mb-4"></div>
|
||||||
|
|
||||||
<div class="col-md-12 mb-4 form-control-validation">
|
<div class="col-md-12 mb-4 form-control-validation">
|
||||||
<label class="form-label d-block mb-2" for="saluran-terdahulu">Jika pernah, dulu menjual ke mana? (boleh pilih lebih dari satu)</label>
|
<label class="form-label d-block" for="saluran-terdahulu">Jika pernah, dulu menjual ke mana? (boleh pilih lebih dari satu)</label>
|
||||||
<div class="row g-2">
|
<div class="row">
|
||||||
@foreach($saluranTerdahuluOptions as $option)
|
@foreach($saluranTerdahuluOptions as $option)
|
||||||
@if($option['value'] !== $saluranLainnyaValue)
|
@if($option['value'] !== $saluranLainnyaValue)
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -191,7 +190,7 @@
|
|||||||
Lainnya:
|
Lainnya:
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" class="form-control form-control-sm 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...">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,10 +202,10 @@
|
|||||||
</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">
|
||||||
@@ -223,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">
|
||||||
@@ -252,12 +251,12 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h6 class="fw-bold text-dark mt-4 mb-2">Sumber Informasi Harga</h6>
|
<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">
|
<div class="mb-4">
|
||||||
<label class="form-label fw-semibold">Sebelum menjual, apakah Bapak/Ibu sudah tahu harga yang akan diterima?</label>
|
<p class="form-label d-block">Sebelum menjual, apakah Bapak/Ibu sudah tahu harga yang akan diterima?</p>
|
||||||
<div class="row g-2">
|
<div class="row">
|
||||||
@php
|
@php
|
||||||
$tahuHargaOptions = [
|
$tahuHargaOptions = [
|
||||||
'Selalu tahu sebelum pergi jual',
|
'Selalu tahu sebelum pergi jual',
|
||||||
@@ -279,8 +278,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-0">
|
<div class="mb-0">
|
||||||
<label class="form-label fw-semibold">Dari mana biasanya Bapak/Ibu tahu harga hasil panen sebelum menjual? (boleh pilih lebih dari satu)</label>
|
<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 g-2">
|
<div class="row">
|
||||||
@php
|
@php
|
||||||
$sumberInfoHargaOptions = [
|
$sumberInfoHargaOptions = [
|
||||||
'Tengkulak/pedagang yang datang langsung',
|
'Tengkulak/pedagang yang datang langsung',
|
||||||
@@ -311,7 +310,7 @@
|
|||||||
<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' : '' }}>
|
<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>
|
<label class="form-check-label text-nowrap" for="sumber-lainnya-checkbox">Lainnya:</label>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" class="form-control form-control-sm 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...">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -321,18 +320,18 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h5 class="pb-3 border-bottom mb-4 mt-4">PELUANG NILAI TAMBAH</h5>
|
<h5 class="pb-4 border-bottom mb-4 mt-4">Peluang Nilai Tambah</h5>
|
||||||
|
|
||||||
<div class="border rounded p-4 mb-4">
|
<div class="mb-4">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h6 class="mb-1 text-primary">Detail Praktik Fermentasi</h6>
|
<h5 class="mb-4 mt-4">Detail Praktik Fermentasi</h5>
|
||||||
<p class="text-muted small mb-0">Apakah sudah ada praktik fermentasi atau pengolahan pascapanen yang Ibu/Bapak lakukan?</p>
|
<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)
|
||||||
@@ -353,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>
|
||||||
@@ -363,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>
|
||||||
@@ -374,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"
|
||||||
@@ -396,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"
|
||||||
@@ -423,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"
|
||||||
@@ -436,7 +435,7 @@
|
|||||||
<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>
|
<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>
|
||||||
@@ -538,4 +537,4 @@
|
|||||||
syncPraktikFermentasi();
|
syncPraktikFermentasi();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@endpush
|
@endpush
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user