Update Harga Internasional
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', []));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 |
@@ -127,6 +127,62 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -358,12 +414,250 @@
|
|||||||
</div>
|
</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 marketCharts = @json($marketCharts);
|
||||||
const chartDefinitions = @json($chartDefinitions);
|
const chartDefinitions = @json($chartDefinitions);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user