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:
parent
6bfb72751f
commit
f27afcfa9e
1 changed files with 224 additions and 1 deletions
225
src/index.html
225
src/index.html
|
|
@ -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 & 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue