diff --git a/.env.example b/.env.example index 3dfc344..2f91c1a 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,9 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# Optional CA bundle path for Yahoo Finance when PHP has no system CA configured. +YAHOO_FINANCE_CA_BUNDLE= + + +ALPHA_VANTAGE_API_KEY= diff --git a/app/Http/Controllers/Web/Dashboard/InternationalMarketController.php b/app/Http/Controllers/Web/Dashboard/InternationalMarketController.php new file mode 100644 index 0000000..5d1bf3b --- /dev/null +++ b/app/Http/Controllers/Web/Dashboard/InternationalMarketController.php @@ -0,0 +1,210 @@ +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', [])); + } + }); + } + +} diff --git a/app/Http/Controllers/Web/Dashboard/SummaryController.php b/app/Http/Controllers/Web/Dashboard/SummaryController.php index 437d0d6..122d280 100644 --- a/app/Http/Controllers/Web/Dashboard/SummaryController.php +++ b/app/Http/Controllers/Web/Dashboard/SummaryController.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers\Web\Dashboard; use App\Enums\AksesPasar\PraktikFermentasi; +use App\Enums\AksesPasar\SkorKesadaranPremium; +use App\Enums\AksesPasar\SkorPengalaman; use App\Enums\KeparahanSerangan; use App\Enums\KondisiTanaman; use App\Enums\PenyakitTanaman; @@ -114,70 +116,196 @@ class SummaryController extends Controller 'kecamatan_id', 'desaKelurahan_id', 'komoditas_id', + 'segmen', ]), ] + $filterData + $this->marketDashboardData($request)); } private function marketDashboardData(Request $request): array { - if (! $request->filled('komoditas_id')) { - return [ - 'selectedKomoditas' => null, - 'priceCard' => null, - ]; - } - - $selectedKomoditas = Komoditas::query() - ->whereKey($request->komoditas_id) - ->first(['id', 'nama']); - - if (! $selectedKomoditas) { - return [ - 'selectedKomoditas' => null, - 'priceCard' => null, - ]; - } - - $productCaption = $this->marketProductCaption($selectedKomoditas->nama); - $productLabel = 'Buah segar hasil petik'; + $products = [ + ['name' => 'Kakao', 'caption' => 'Harga Biji Kering', 'product' => 'Biji kering/Biji Kupas', 'variant' => 'success'], + ['name' => 'Vanili', 'caption' => 'Harga Buah Segar', 'product' => 'Buah segar hasil petik', 'variant' => 'primary'], + ['name' => 'Kopi', 'caption' => 'Harga Ceri Merah', 'product' => 'Buah segar hasil petik', 'variant' => 'warning'], + ['name' => 'Kemiri', 'caption' => 'Harga Gelondong', 'product' => 'Buah segar hasil petik', 'variant' => 'info'], + ]; + $commodities = Komoditas::query() + ->whereIn('nama', array_column($products, 'name')) + ->pluck('id', 'nama'); $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('petani.komoditas_id', $commodities->values()) ->where('harga_jual_panen.harga_panen_terakhir', '>', 0) - ->where('harga_jual_panen.bentuk_produk', $productLabel); - - $averagePrice = (float) (clone $priceQuery)->avg('harga_jual_panen.harga_panen_terakhir'); - $totalRecords = (int) (clone $priceQuery)->count(); + ->whereIn('harga_jual_panen.bentuk_produk', array_unique(array_column($products, 'product'))) + ->select('petani.komoditas_id', 'harga_jual_panen.bentuk_produk') + ->selectRaw('AVG(harga_jual_panen.harga_panen_terakhir) as average_price, COUNT(*) as records') + ->groupBy('petani.komoditas_id', 'harga_jual_panen.bentuk_produk') + ->get(); return [ - 'selectedKomoditas' => $selectedKomoditas, - 'priceCard' => [ - 'value' => $averagePrice > 0 - ? 'Rp '.number_format($averagePrice, 0, ',', '.') - : 'Rp 0', - 'label' => 'Harga Jual', - 'caption' => $productCaption, - 'description' => 'Harga rata-rata buah segar hasil petik di tingkat petani', - 'records' => $totalRecords, - 'unit' => '/kg', - 'variant' => 'success', - 'icon' => 'tabler-cash', - ], + 'priceCards' => collect($products)->map(function (array $product) use ($commodities, $prices) { + $commodityId = $commodities->get($product['name']); + $price = $commodityId === null ? null : $prices->first(fn ($row) => + (int) $row->komoditas_id === (int) $commodityId + && $row->bentuk_produk === $product['product'] + ); + + return [ + 'value' => 'Rp '.number_format((float) ($price?->average_price ?? 0), 0, ',', '.'), + 'label' => $product['name'].' - '.$product['caption'], + 'description' => 'Harga rata-rata panen terakhir di tingkat petani', + 'records' => (int) ($price?->records ?? 0), + 'unit' => '/kg', + '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) { - str_contains($normalized, 'kakao') => 'Harga Buah Kakao', - str_contains($normalized, 'vanili') => 'Harga Curing', - str_contains($normalized, 'kopi') => 'Harga Ceri Merah', - str_contains($normalized, 'kemiri') => 'Harga Gelondong', - default => 'Harga Buah Segar', - }; + $query = DB::table('harga_jual_panen') + ->join('petani', 'petani.id', '=', 'harga_jual_panen.petani_id') + ->whereIn('harga_jual_panen.petani_id', $this->filteredPetaniIdsQuery($request)) + ->whereIn('petani.komoditas_id', $commodities->pluck('id')) + ->whereIn('harga_jual_panen.bentuk_produk', $products) + ->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 diff --git a/app/Services/AlphaVantageMarket.php b/app/Services/AlphaVantageMarket.php new file mode 100644 index 0000000..66298a1 --- /dev/null +++ b/app/Services/AlphaVantageMarket.php @@ -0,0 +1,68 @@ +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; + } + } +} diff --git a/config/services.php b/config/services.php index 6a90eb8..7370b69 100644 --- a/config/services.php +++ b/config/services.php @@ -14,6 +14,12 @@ return [ | */ + 'alpha_vantage' => ['api_key' => env('ALPHA_VANTAGE_API_KEY')], + + 'yahoo_finance' => [ + 'ca_bundle' => env('YAHOO_FINANCE_CA_BUNDLE'), + ], + 'postmark' => [ 'key' => env('POSTMARK_API_KEY'), ], diff --git a/public/assets/img/providers/alpha-vantage.ico b/public/assets/img/providers/alpha-vantage.ico new file mode 100644 index 0000000..0af8e0f Binary files /dev/null and b/public/assets/img/providers/alpha-vantage.ico differ diff --git a/public/assets/img/providers/imf.ico b/public/assets/img/providers/imf.ico new file mode 100644 index 0000000..dad38d5 Binary files /dev/null and b/public/assets/img/providers/imf.ico differ diff --git a/public/assets/img/providers/yahoo-finance.ico b/public/assets/img/providers/yahoo-finance.ico new file mode 100644 index 0000000..2dd7936 Binary files /dev/null and b/public/assets/img/providers/yahoo-finance.ico differ diff --git a/resources/views/dashboard/pasar.blade.php b/resources/views/dashboard/pasar.blade.php index 6561b3d..fb8e8a3 100644 --- a/resources/views/dashboard/pasar.blade.php +++ b/resources/views/dashboard/pasar.blade.php @@ -3,12 +3,12 @@ @push('style') @endpush @@ -47,35 +192,6 @@
-
-
- - Komoditas * -
- -
- -
- - - -
- -
-
@@ -111,39 +227,57 @@
+ +
+ +
+
+ + Data +
+ +
+ +
+ +
+ +
+ + + +
- @if (! $selectedKomoditas) - - -
-
-
-
- - - -
-
Belum ada data pasar yang ditampilkan
-

Gunakan filter komoditas untuk melihat harga jual rata-rata.

-
-
-
- @else -
-
+
+ @foreach ($priceCards as $priceCard) +
-
+
@@ -161,22 +295,594 @@
-
- {{ $priceCard['caption'] }} +
+ {{ $priceCard['records'] > 0 ? 'Rata-rata harga' : 'Belum ada data' }} {{ number_format($priceCard['records'], 0, ',', '.') }} data harga
+ @endforeach +
+ + + + @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 + +
+
+ @foreach ($chartDefinitions as $key => $definition) +
+
+
+
+
{{ $definition['title'] }}
+ {{ $definition['description'] }} +
+
+
+ + + @foreach ($marketCharts[$key]['labels'] as $index => $label) + {{ $label }}: {{ $marketCharts[$key]['series'][$index] }} responden ({{ $marketCharts[$key]['percentages'][$index] }}%). + @endforeach + +
+
+
+ @endforeach + @forelse ($priceTrend['commodities'] as $commodity) +
+
+
+
+
Tren Harga 3 Musim — {{ $commodity['name'] }}
+ Rata-rata harga jual per bentuk produk selama tiga periode panen (Rp/kg). +
+
+
+ + +
+
+
+ @empty +
+
+
+
Tren Harga 3 Musim
+
+
Belum ada komoditas untuk filter yang dipilih.
+
+
+ @endforelse +
+
+ + + +
+
+
+
+
+
+
Kesadaran Pasar Premium
+ Pengetahuan tentang pasar premium dan persyaratan yang dibutuhkan untuk masuk ke pasar tersebut. +
+
+
+ + + @foreach ($premiumAwareness['fullLabels'] as $index => $label) + {{ $label }}: {{ $premiumAwareness['series'][$index] }} responden ({{ $premiumAwareness['percentages'][$index] }}%). + @endforeach + +
+
+
+
+
+ +
+
+
Harga Komoditas Internasional
+
+ + + +
+
+

Setiap kartu menampilkan satu komoditas dari satu sumber. Periode dan jenis harga mengikuti penyedia; tidak mengikuti filter wilayah.

+

Memuat harga internasional...

+
+ +
@endsection @push('scripts') - , by Yayasan Bhumi Loka Lestari + , by PT Republik Solusi
diff --git a/resources/views/produktivitas/petani/akses-pasar/index.blade.php b/resources/views/produktivitas/petani/akses-pasar/index.blade.php index aee1fe1..dab961c 100644 --- a/resources/views/produktivitas/petani/akses-pasar/index.blade.php +++ b/resources/views/produktivitas/petani/akses-pasar/index.blade.php @@ -31,12 +31,12 @@
-
Saluran Penjualan & Akses Pasar
+
Saluran Penjualan & Akses Pasar
-
Saluran Penjualan Utama
+
Saluran Penjualan Utama
- -
+

Kepada siapa Bapak/Ibu biasanya menjual hasil panen? (boleh pilih lebih dari satu)

+
@php $tujuanOptions = ['Tengkulak/pedagang lokal', 'Koperasi', 'BUMDes', 'Langsung ke pembeli/eksportir']; @endphp @@ -55,16 +55,16 @@ tujuan_penjualan ?? []), true) ? 'checked' : '' }}>
- +
-
Sifat Hubungan dengan Pembeli
+
Sifat Hubungan Dengan Pembeli
- -
+

Bagaimana sifat hubungan jual Bapak/Ibu dengan pembeli utama saat ini?

+
@php $sifatHubunganOptions = [ 'Bebas, bisa pindah pembeli kapan saja', @@ -86,10 +86,10 @@
-
Bentuk Produk yang Dijual
+
Bentuk Produk Yang Dijual
- -
+

Dalam bentuk apa produk dijual?

+
@php $bentukPenjualanOptions = [ 'Buah segar hasil petik (gelondongan)', @@ -109,10 +109,10 @@
-
Frekuensi Penjualan
+
Frekuensi Penjualan
- -
+

Kapan biasanya menjual hasil panen?

+
@php $frekuensiOptions = [ 'Langsung setelah panen', @@ -121,20 +121,19 @@ ]; @endphp @foreach($frekuensiOptions as $freq) -
+
frekuensi_penjualan) === $freq ? 'checked' : '' }}>
@endforeach
-
-
Pengalaman Saluran Lain
+
Pengalaman Saluran Lain
- + @foreach($skorPengalamanOptions as $option)
- -
+ +
@foreach($saluranTerdahuluOptions as $option) @if($option['value'] !== $saluranLainnyaValue)
@@ -191,7 +190,7 @@ Lainnya:
- +
@@ -203,10 +202,10 @@
-
HARGA DI SETIAP TITIK
-
Harga Jual Panen Terakhir
-

Harga jual komoditas tiga musim panen terakhir:

-

Dalam tiga musim panen terakhir, berapa harga jual yang Bapak/Ibu terima untuk komoditas ini sesuai dengan bentuk produk yang dijual?

+
Harga Di Setiap Titik
+
Harga Jual Panen Terakhir
+

Harga jual komoditas tiga musim panen terakhir:

+

Dalam tiga musim panen terakhir, berapa harga jual yang Bapak/Ibu terima untuk komoditas ini sesuai dengan bentuk produk yang dijual?

@@ -223,7 +222,7 @@
- {{ $row['bentuk'] }}: + {{ $row['bentuk'] }}:
@@ -252,12 +251,12 @@
-
Sumber Informasi Harga
-
+
Sumber Informasi Harga
+
- -
+

Sebelum menjual, apakah Bapak/Ibu sudah tahu harga yang akan diterima?

+
@php $tahuHargaOptions = [ 'Selalu tahu sebelum pergi jual', @@ -279,8 +278,8 @@
- -
+

Dari mana biasanya Bapak/Ibu tahu harga hasil panen sebelum menjual? (boleh pilih lebih dari satu)

+
@php $sumberInfoHargaOptions = [ 'Tengkulak/pedagang yang datang langsung', @@ -311,7 +310,7 @@ sumber_info_harga ?? []), true) ? 'checked' : '' }}>
- +
@@ -321,18 +320,18 @@
-
PELUANG NILAI TAMBAH
+
Peluang Nilai Tambah
-
+
-
Detail Praktik Fermentasi
-

Apakah sudah ada praktik fermentasi atau pengolahan pascapanen yang Ibu/Bapak lakukan?

+
Detail Praktik Fermentasi
+

Apakah sudah ada praktik fermentasi atau pengolahan pascapanen yang Ibu/Bapak lakukan?

-
- +
+
@foreach($praktikFermentasiOptions as $option) @@ -353,8 +352,8 @@
-
- +
+
@@ -363,8 +362,8 @@
-
- +
+
@@ -374,11 +373,11 @@
-
AKSES PASAR PREMIUM
-
Kesadaran Pasar Premium*
+
Akses Pasar Premium
+
Kesadaran Pasar Premium*
- + @foreach($skorKesadaranPremiumOptions as $option)
- + @error('sumber_info_premium')
{{ $message }}
@enderror
-
KEPERCAYAAN & PERSEPSI RISIKO
+
Kepercayaan & Persepsi Risiko
- + @foreach($tingkatKepercayaanOptions as $option)
- +