diff --git a/src/index.html b/src/index.html index 35dfb78..84826ae 100644 --- a/src/index.html +++ b/src/index.html @@ -258,6 +258,7 @@
FLEETOPS
@@ -314,6 +315,62 @@ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+

Fuel spend & litres — daily trend

+
+
+ +
+

Per-vehicle fuel

+
Loading…
+
+ +
+

By department

+
Loading…
+
+ +
+

Recent fills

+
Loading…
+
+
+
+
@@ -398,6 +455,12 @@ async function loadFilters() { const cc = $('f-cc'), city = $('f-city'); (f.cost_centres || []).forEach(v => cc.add(new Option(v, v))); (f.cities || []).forEach(v => city.add(new Option(v, v))); + // Fuel Log tab shares the same dims + its own department / fuel-type dropdowns. + const fcc = $('fuf-cc'), fcity = $('fuf-city'), fdept = $('fuf-dept'), ftype = $('fuf-type'); + (f.cost_centres || []).forEach(v => fcc.add(new Option(v, v))); + (f.cities || []).forEach(v => fcity.add(new Option(v, v))); + (f.departments || []).forEach(v => fdept.add(new Option(v, v))); + (f.fuel_types || []).forEach(v => ftype.add(new Option(v, v))); } catch (e) { console.warn('filters', e); } } @@ -567,7 +630,163 @@ async function loadAll() { } // ============================================================================ -// TABS (Logistics ↔ Tickets) +// FUEL LOG — real fills ingested from the rustfs `fuel` bucket (fleetfuel repo) +// ============================================================================ +// Lazy-loaded on first open (like the Tickets map). Reads the new dashboard_api +// endpoints: GET /analytics/fuel-fills (+ /recent), backed by reporting.v_fuel_fills. +let fuelLoaded = false, fuelTrendChart = null, lastFuelTotals = null; + +function fuelQs() { + const p = new URLSearchParams(); + p.set('period', $('fuf-period').value); + if ($('fuf-cc').value) p.set('cost_centre', $('fuf-cc').value); + if ($('fuf-city').value) p.set('assigned_city', $('fuf-city').value); + if ($('fuf-dept').value) p.set('department', $('fuf-dept').value); + if ($('fuf-type').value) p.set('fuel_type', $('fuf-type').value); + if ($('fuf-period').value === 'custom') { + if ($('fuf-start').value) p.set('start_date', $('fuf-start').value); + if ($('fuf-end').value) p.set('end_date', $('fuf-end').value); + } + return p.toString(); +} + +function renderFuelKpis(t) { + t = t || {}; + const kpis = [ + ['accent', intg(t.vehicles_fuelled), 'Vehicles'], + ['', num(t.litres, 0), 'Litres'], + ['live', t.spend_kes != null ? intg(t.spend_kes) : '—', 'Spend KES'], + ['', intg(t.fills), 'Fills'], + ['warn', t.avg_price_per_litre != null ? num(t.avg_price_per_litre, 1) : '—', 'KES / L'], + ]; + $('kpis').innerHTML = kpis.map(([c, v, l]) => + `
${v}${l}
`).join(''); +} + +function renderFuelTrend(trend) { + trend = trend || []; + const labels = trend.map(d => d.fuel_date); + const spend = trend.map(d => Number(d.spend_kes || 0)); + const litres = trend.map(d => Number(d.litres || 0)); + const css = getComputedStyle(document.documentElement); + const accent = css.getPropertyValue('--accent').trim(); + const live = (css.getPropertyValue('--live').trim() || accent); + const muted = css.getPropertyValue('--muted').trim(); + const border = css.getPropertyValue('--border').trim(); + const data = { labels, datasets: [ + { type: 'bar', label: 'Spend KES', data: spend, backgroundColor: accent + 'cc', borderRadius: 3, yAxisID: 'y', order: 2 }, + { type: 'line', label: 'Litres', data: litres, borderColor: live, backgroundColor: live, tension: .3, pointRadius: 2, yAxisID: 'y1', order: 1, spanGaps: true }, + ] }; + const scale = (pos, title) => ({ + position: pos, grid: { color: border, drawOnChartArea: pos === 'left' }, border: { color: border }, + ticks: { color: muted, callback: v => Number(v).toLocaleString('en') }, + title: { display: true, text: title, color: muted, font: { size: 10 } }, beginAtZero: true, + }); + const opts = { + responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, + plugins: { legend: { labels: { color: muted, boxWidth: 12, font: { size: 11 } } } }, + scales: { x: { grid: { color: border }, border: { color: border }, ticks: { color: muted, maxRotation: 0, autoSkip: true } }, + y: scale('left', 'KES'), y1: scale('right', 'litres') }, + }; + if (fuelTrendChart) { fuelTrendChart.data = data; fuelTrendChart.options = opts; fuelTrendChart.update(); } + else fuelTrendChart = new Chart($('fuelTrendChart'), { data, options: opts }); +} + +function renderFuelVehicles(rows) { + rows = rows || []; + $('fv-count').textContent = rows.length ? `(${rows.length})` : ''; + if (!rows.length) { $('fv-wrap').innerHTML = '
No fills in range.
'; return; } + const body = rows.map(r => ` + + ${r.vehicle_number ?? r.plate ?? '—'} + ${r.cost_centre ?? '—'} + ${r.assigned_city ?? '—'} + ${num(r.litres, 1)} + ${intg(r.spend_kes)} + ${intg(r.fills)} + ${r.km_per_litre != null ? num(r.km_per_litre, 2) : ''} + ${r.last_odometer != null ? intg(r.last_odometer) : '—'} + `).join(''); + $('fv-wrap').innerHTML = ` + + ${body}
VehicleCost centreCityLitresSpend KESFillskm/LOdometer
`; +} + +function renderFuelDepartments(rows) { + rows = rows || []; + $('fd-count').textContent = rows.length ? `(${rows.length})` : ''; + if (!rows.length) { $('fd-wrap').innerHTML = '
No data.
'; return; } + const body = rows.map(r => ` + + ${r.department ?? '—'} + ${num(r.litres, 0)} + ${intg(r.spend_kes)} + ${intg(r.fills)} + `).join(''); + $('fd-wrap').innerHTML = ` + + ${body}
DepartmentLitresSpend KESFills
`; +} + +function renderFuelRecent(rows) { + rows = rows || []; + $('fr-count').textContent = rows.length ? `(${rows.length})` : ''; + if (!rows.length) { $('fr-wrap').innerHTML = '
No fills in range.
'; return; } + // record_datetime is stored Africa/Nairobi wall-clock (naive, no offset), so + // format the parts directly — don't re-apply a timezone (that would double-shift). + const MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + const dt = (s) => { const m = s && String(s).match(/(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/); + return m ? `${+m[3]} ${MON[+m[2]-1]} ${m[4]}:${m[5]}` : '—'; }; + const body = rows.map(r => ` + + ${dt(r.record_datetime)} + ${r.vehicle_number ?? r.plate ?? '—'} + ${r.department ?? '—'} + ${r.driver ?? '—'} + ${num(r.liters, 1)} + ${intg(r.amount)} + ${r.fuel_type ?? '—'} + ${r.odometer != null ? intg(r.odometer) : '—'} + `).join(''); + $('fr-wrap').innerHTML = ` + + ${body}
WhenVehicleDeptDriverLitresKESTypeOdometer
`; +} + +async function loadFuel() { + const q = fuelQs(); + $('fuel-main').classList.add('loading'); + try { + const [fills, recent] = await Promise.all([ + api(`/analytics/fuel-fills?${q}`), + api(`/analytics/fuel-fills/recent?${q}&limit=50`), + ]); + lastFuelTotals = fills.totals; + renderFuelKpis(fills.totals); + renderFuelTrend(fills.trend); + renderFuelVehicles(fills.rows); + renderFuelDepartments(fills.by_department); + renderFuelRecent(recent.rows); + fuelLoaded = true; + } catch (e) { + console.error(e); + $('fuel-main').querySelectorAll('.tbl-scroll').forEach(el => + el.innerHTML = ``); + } finally { + $('fuel-main').classList.remove('loading'); + } +} + +$('fuf-period').addEventListener('change', () => { + const custom = $('fuf-period').value === 'custom'; + $('fuf-ff-start').classList.toggle('show', custom); + $('fuf-ff-end').classList.toggle('show', custom); +}); +$('fuf-apply').addEventListener('click', loadFuel); +$('fuf-refresh').addEventListener('click', loadFuel); + +// ============================================================================ +// TABS (Logistics ↔ Fuel Log ↔ Tickets) // ============================================================================ // The header KPI strip is shared, so we cache the last logistics totals and // re-render them when switching back from Tickets. @@ -591,6 +810,10 @@ function switchTab(name) { if (name === 'tickets') { renderTicketKpis(); initTicketsMap(); // lazy — builds once, then just resizes + } else if (name === 'fuel') { + if (lastFuelTotals) renderFuelKpis(lastFuelTotals); + if (!fuelLoaded) loadFuel(); // lazy — first open + else if (fuelTrendChart) fuelTrendChart.resize(); } else { renderKpis(lastTotals, lastFuelL); }