966 lines
47 KiB
PHP
966 lines
47 KiB
PHP
@extends('layout.main')
|
|
|
|
@push('style')
|
|
<style>
|
|
.pasar-filter-card .filter-item {
|
|
min-width: 190px;
|
|
}
|
|
|
|
.pasar-kpi-value {
|
|
color: var(--bs-heading-color);
|
|
font-size: 1.6rem;
|
|
font-weight: 600;
|
|
line-height: 1.2;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.pasar-kpi-label {
|
|
color: var(--bs-heading-color);
|
|
font-size: 0.9375rem;
|
|
font-weight: 400;
|
|
}
|
|
|
|
.pasar-kpi-desc {
|
|
font-size: 0.8125rem;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.chart-group-header-toggle {
|
|
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;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--bs-secondary-color);
|
|
font-size: 0.8125rem;
|
|
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) {
|
|
.pasar-filter-card .filter-item {
|
|
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>
|
|
@endpush
|
|
|
|
@section('content')
|
|
<div class="container-xxl flex-grow-1 container-p-y">
|
|
<div class="row g-4 mb-4">
|
|
<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-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-map-2 me-2"></i>
|
|
<span class="fw-medium">Wilayah</span>
|
|
</div>
|
|
|
|
<div class="filter-item">
|
|
<select id="filter-provinsi" class="form-select form-select-sm" name="provinsi_id" data-default="{{ $provinsi_id }}">
|
|
<option value="">Semua Provinsi</option>
|
|
@foreach($provinsi as $p)
|
|
<option value="{{ $p->provinsi_id }}" {{ old('provinsi_id', $provinsi_id) == $p->provinsi_id ? 'selected' : '' }}>
|
|
{{ $p->provinsi }}
|
|
</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
|
|
<div class="filter-item">
|
|
<select id="filter-kabkota" class="form-select form-select-sm" name="kabupatenKota_id" data-default="{{ $kabupatenKota_id }}" disabled>
|
|
<option value="">Semua Kabupaten/Kota</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="filter-item">
|
|
<select id="filter-kecamatan" class="form-select form-select-sm" name="kecamatan_id" data-default="{{ $kecamatan_id }}" disabled>
|
|
<option value="">Semua Kecamatan</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="filter-item">
|
|
<select id="filter-desa" class="form-select form-select-sm" name="desaKelurahan_id" data-default="{{ $desaKelurahan_id }}" disabled>
|
|
<option value="">Semua Desa/Kelurahan</option>
|
|
</select>
|
|
</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>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="row g-4 mb-4">
|
|
@foreach ($priceCards as $priceCard)
|
|
<div class="col-12 col-md-6 col-xl-3">
|
|
<div class="card card-border-shadow-{{ $priceCard['variant'] }} h-100">
|
|
<div class="card-body">
|
|
<div class="d-flex flex-column align-items-start gap-3">
|
|
<div class="avatar avatar-lg">
|
|
<span class="avatar-initial rounded bg-label-{{ $priceCard['variant'] }}">
|
|
<i class="icon-base ti {{ $priceCard['icon'] }} icon-28px"></i>
|
|
</span>
|
|
</div>
|
|
|
|
<div>
|
|
<div class="d-flex align-items-baseline gap-2 flex-wrap">
|
|
<h4 class="mb-0 pasar-kpi-value">{{ $priceCard['value'] }}</h4>
|
|
<small class="text-body-secondary">{{ $priceCard['unit'] }}</small>
|
|
</div>
|
|
|
|
<p class="mb-0 mt-1 pasar-kpi-label">{{ $priceCard['label'] }}</p>
|
|
<small class="text-body-secondary pasar-kpi-desc">{{ $priceCard['description'] }}</small>
|
|
</div>
|
|
</div>
|
|
|
|
<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-{{ $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>
|
|
</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>
|
|
|
|
@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>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
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 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) {
|
|
$select.empty().append(new Option(placeholder, ''));
|
|
items.forEach(function (item) {
|
|
$select.append(new Option(item.nama, item.id));
|
|
});
|
|
}
|
|
|
|
function selectedProvinsi() {
|
|
const id = $('#filter-provinsi').val();
|
|
return wilayah.find(function (item) {
|
|
return String(item.id) === String(id);
|
|
});
|
|
}
|
|
|
|
function selectedKabupaten() {
|
|
const provinsi = selectedProvinsi();
|
|
const id = $('#filter-kabkota').val();
|
|
return (provinsi?.kabupaten || []).find(function (item) {
|
|
return String(item.id) === String(id);
|
|
});
|
|
}
|
|
|
|
function selectedKecamatan() {
|
|
const kabupaten = selectedKabupaten();
|
|
const id = $('#filter-kecamatan').val();
|
|
return (kabupaten?.kecamatan || []).find(function (item) {
|
|
return String(item.id) === String(id);
|
|
});
|
|
}
|
|
|
|
$('#filter-provinsi, #filter-kabkota, #filter-kecamatan, #filter-desa, #filter-komoditas, #filter-segmen').select2({ width: '100%' });
|
|
|
|
$('#filter-provinsi').on('change', function () {
|
|
const provinsi = selectedProvinsi();
|
|
fillSelect($('#filter-kabkota'), provinsi?.kabupaten || [], 'Semua Kabupaten/Kota');
|
|
fillSelect($('#filter-kecamatan'), [], 'Semua Kecamatan');
|
|
fillSelect($('#filter-desa'), [], 'Semua Desa/Kelurahan');
|
|
$('#filter-kabkota').prop('disabled', !provinsi).trigger('change.select2');
|
|
$('#filter-kecamatan, #filter-desa').prop('disabled', true).trigger('change.select2');
|
|
});
|
|
|
|
$('#filter-kabkota').on('change', function () {
|
|
const kabupaten = selectedKabupaten();
|
|
fillSelect($('#filter-kecamatan'), kabupaten?.kecamatan || [], 'Semua Kecamatan');
|
|
fillSelect($('#filter-desa'), [], 'Semua Desa/Kelurahan');
|
|
$('#filter-kecamatan').prop('disabled', !kabupaten).trigger('change.select2');
|
|
$('#filter-desa').prop('disabled', true).trigger('change.select2');
|
|
});
|
|
|
|
$('#filter-kecamatan').on('change', function () {
|
|
const kecamatan = selectedKecamatan();
|
|
fillSelect($('#filter-desa'), kecamatan?.desa || [], 'Semua Desa/Kelurahan');
|
|
$('#filter-desa').prop('disabled', !kecamatan).trigger('change.select2');
|
|
});
|
|
|
|
const defaultProvinsi = String($('#filter-provinsi').data('default') || '');
|
|
const defaultKabupaten = String($('#filter-kabkota').data('default') || '');
|
|
const defaultKecamatan = String($('#filter-kecamatan').data('default') || '');
|
|
const defaultDesa = String($('#filter-desa').data('default') || '');
|
|
|
|
if (defaultProvinsi) {
|
|
$('#filter-provinsi').val(defaultProvinsi).trigger('change');
|
|
if (defaultKabupaten) {
|
|
$('#filter-kabkota').val(defaultKabupaten).trigger('change');
|
|
if (defaultKecamatan) {
|
|
$('#filter-kecamatan').val(defaultKecamatan).trigger('change');
|
|
if (defaultDesa) {
|
|
$('#filter-desa').val(defaultDesa).trigger('change.select2');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$('#btn-reset-dashboard-filter').on('click', function () {
|
|
window.location.href = @json(route('dashboard.pasar'));
|
|
});
|
|
});
|
|
</script>
|
|
@endpush
|