feat(fuel): add Fuel Log tab (real fills from the fuel feed)

New tab backed by dashboard_api /analytics/fuel-fills(+/recent): KPI strip
(litres, KES spend, fills, KES/L, vehicles), spend+litres daily trend, per-vehicle
table (incl. km/L), by-department breakdown, recent fills. Shares the filter state
plus new department/fuel-type dropdowns; lazy-loads on first open. Recent-fills
time renders the Africa/Nairobi wall-clock value directly (no double tz shift).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kianiadee 2026-06-12 00:01:08 +03:00
parent 6bfb72751f
commit f27afcfa9e

View file

@ -258,6 +258,7 @@
<div class="brand"><span class="mark"></span>FLEET<span class="nm">OPS</span></div> <div class="brand"><span class="mark"></span>FLEET<span class="nm">OPS</span></div>
<nav class="tabs" id="tabs"> <nav class="tabs" id="tabs">
<button class="tab active" data-tab="logistics" type="button">Logistics</button> <button class="tab active" data-tab="logistics" type="button">Logistics</button>
<button class="tab" data-tab="fuel" type="button">Fuel Log</button>
<button class="tab" data-tab="tickets" type="button">Tickets</button> <button class="tab" data-tab="tickets" type="button">Tickets</button>
</nav> </nav>
<div id="kpis"></div> <div id="kpis"></div>
@ -314,6 +315,62 @@
</main> </main>
</section> </section>
<section class="view" id="view-fuel">
<div class="filterbar">
<div class="ff">
<label for="fuf-cc">Cost centre</label>
<select id="fuf-cc"><option value="">All cost centres</option></select>
</div>
<div class="ff">
<label for="fuf-city">Assigned city</label>
<select id="fuf-city"><option value="">All cities</option></select>
</div>
<div class="ff">
<label for="fuf-dept">Department</label>
<select id="fuf-dept"><option value="">All departments</option></select>
</div>
<div class="ff">
<label for="fuf-type">Fuel type</label>
<select id="fuf-type"><option value="">All types</option></select>
</div>
<div class="ff">
<label for="fuf-period">Period</label>
<select id="fuf-period">
<option value="7d">Last 1 week</option>
<option value="30d">Last 1 month</option>
<option value="90d" selected>Last 3 months</option>
<option value="custom">Custom range</option>
</select>
</div>
<div class="ff custom" id="fuf-ff-start"><label for="fuf-start">Start</label><input type="date" id="fuf-start"></div>
<div class="ff custom" id="fuf-ff-end"><label for="fuf-end">End</label><input type="date" id="fuf-end"></div>
<button class="btn" id="fuf-apply" type="button">Apply</button>
<button class="btn ghost" id="fuf-refresh" type="button" title="Reload"></button>
</div>
<main id="fuel-main">
<div class="card span12">
<h2>Fuel spend &amp; litres — daily trend</h2>
<div class="chart-wrap"><canvas id="fuelTrendChart"></canvas></div>
</div>
<div class="card span8">
<h2>Per-vehicle fuel <span class="count" id="fv-count"></span></h2>
<div class="tbl-scroll" id="fv-wrap"><div class="empty">Loading…</div></div>
</div>
<div class="card span4">
<h2>By department <span class="count" id="fd-count"></span></h2>
<div class="tbl-scroll" id="fd-wrap"><div class="empty">Loading…</div></div>
</div>
<div class="card span12">
<h2>Recent fills <span class="count" id="fr-count"></span></h2>
<div class="tbl-scroll" id="fr-wrap"><div class="empty">Loading…</div></div>
</div>
</main>
</section>
<section class="view" id="view-tickets"> <section class="view" id="view-tickets">
<div class="map-wrap"> <div class="map-wrap">
<div id="tk-map"></div> <div id="tk-map"></div>
@ -398,6 +455,12 @@ async function loadFilters() {
const cc = $('f-cc'), city = $('f-city'); const cc = $('f-cc'), city = $('f-city');
(f.cost_centres || []).forEach(v => cc.add(new Option(v, v))); (f.cost_centres || []).forEach(v => cc.add(new Option(v, v)));
(f.cities || []).forEach(v => city.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); } } 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]) =>
`<div class="kpi"><b class="${c}">${v}</b><span>${l}</span></div>`).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 = '<div class="empty">No fills in range.</div>'; return; }
const body = rows.map(r => `
<tr>
<td class="plate">${r.vehicle_number ?? r.plate ?? '—'}</td>
<td class="dim">${r.cost_centre ?? '—'}</td>
<td class="dim">${r.assigned_city ?? '—'}</td>
<td>${num(r.litres, 1)}</td>
<td>${intg(r.spend_kes)}</td>
<td>${intg(r.fills)}</td>
<td>${r.km_per_litre != null ? num(r.km_per_litre, 2) : '<span class="dim"></span>'}</td>
<td class="dim">${r.last_odometer != null ? intg(r.last_odometer) : '—'}</td>
</tr>`).join('');
$('fv-wrap').innerHTML = `<table>
<thead><tr><th>Vehicle</th><th>Cost centre</th><th>City</th><th>Litres</th><th>Spend KES</th><th>Fills</th><th>km/L</th><th>Odometer</th></tr></thead>
<tbody>${body}</tbody></table>`;
}
function renderFuelDepartments(rows) {
rows = rows || [];
$('fd-count').textContent = rows.length ? `(${rows.length})` : '';
if (!rows.length) { $('fd-wrap').innerHTML = '<div class="empty">No data.</div>'; return; }
const body = rows.map(r => `
<tr>
<td>${r.department ?? '—'}</td>
<td>${num(r.litres, 0)}</td>
<td>${intg(r.spend_kes)}</td>
<td>${intg(r.fills)}</td>
</tr>`).join('');
$('fd-wrap').innerHTML = `<table>
<thead><tr><th>Department</th><th>Litres</th><th>Spend KES</th><th>Fills</th></tr></thead>
<tbody>${body}</tbody></table>`;
}
function renderFuelRecent(rows) {
rows = rows || [];
$('fr-count').textContent = rows.length ? `(${rows.length})` : '';
if (!rows.length) { $('fr-wrap').innerHTML = '<div class="empty">No fills in range.</div>'; 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 => `
<tr>
<td class="dim">${dt(r.record_datetime)}</td>
<td class="plate">${r.vehicle_number ?? r.plate ?? '—'}</td>
<td class="dim">${r.department ?? '—'}</td>
<td class="dim">${r.driver ?? '—'}</td>
<td>${num(r.liters, 1)}</td>
<td>${intg(r.amount)}</td>
<td class="dim">${r.fuel_type ?? '—'}</td>
<td class="dim">${r.odometer != null ? intg(r.odometer) : '—'}</td>
</tr>`).join('');
$('fr-wrap').innerHTML = `<table>
<thead><tr><th>When</th><th>Vehicle</th><th>Dept</th><th>Driver</th><th>Litres</th><th>KES</th><th>Type</th><th>Odometer</th></tr></thead>
<tbody>${body}</tbody></table>`;
}
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 = `<div class="banner error">${e.message || 'Failed to load. Is the API reachable?'}</div>`);
} 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 // The header KPI strip is shared, so we cache the last logistics totals and
// re-render them when switching back from Tickets. // re-render them when switching back from Tickets.
@ -591,6 +810,10 @@ function switchTab(name) {
if (name === 'tickets') { if (name === 'tickets') {
renderTicketKpis(); renderTicketKpis();
initTicketsMap(); // lazy — builds once, then just resizes 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 { } else {
renderKpis(lastTotals, lastFuelL); renderKpis(lastTotals, lastFuelL);
} }