@@ -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 = `
+ | Vehicle | Cost centre | City | Litres | Spend KES | Fills | km/L | Odometer |
+ ${body}
`;
+}
+
+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 = `
+ | Department | Litres | Spend KES | Fills |
+ ${body}
`;
+}
+
+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 = `
+ | When | Vehicle | Dept | Driver | Litres | KES | Type | Odometer |
+ ${body}
`;
+}
+
+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 = `
${e.message || 'Failed to load. Is the API reachable?'}
`);
+ } 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);
}