2026-06-10 10:00:31 +00:00
<!doctype html>
< html lang = "en" >
< head >
< meta charset = "utf-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1" / >
< title > FleetOps — Fleet Operations< / title >
<!--
FleetOps — fleet operations analytics (fuel · utilisation · driver behaviour).
Sibling to FleetNow (live tracking); reuses the same warm-dark ops palette so
the two feel like one product. Self-contained single file + Chart.js (CDN).
Reads the FleetOps analytics API (dashboard_api /analytics/*):
GET < API_BASE > /analytics/filters → dropdown options
GET < API_BASE > /analytics/fleet-summary → totals + per-vehicle rows
GET < API_BASE > /analytics/utilisation → per-vehicle + daily_trend
GET < API_BASE > /analytics/driver-behaviour → per-driver speeding/harsh
GET < API_BASE > /analytics/fuel → actual vs estimated litres
API_BASE is injected at runtime by Caddy via /env.js (see Caddyfile).
-->
< link rel = "preconnect" href = "https://cdn.jsdelivr.net" / >
< script src = "/env.js" > < / script >
< script src = "https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" > < / script >
< style >
:root {
/* Shared with FleetNow (warm dark ops palette) */
--bg: #161a23;
--panel: #1e232e;
--panel-2: #232a36;
--border: #2c333f;
--text: #ECEFF4;
--muted: #93a0b4;
--accent: #E8954A; /* amber — primary, brand, focus */
--accent-hover:#d97b2c;
--live: #2dd4a7; /* teal-green — good / active */
--parked: #6b7280;
--offline: #b4791f;
--warn: #f0a93b;
--danger: #ef5b5b;
--error-bg: #2a0a0a;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--text);
}
.app {
display: grid; min-height: 100vh;
2026-06-11 11:19:56 +00:00
grid-template-rows: auto 1fr; /* header · content (tabs/filters live inside each view) */
2026-06-10 10:00:31 +00:00
}
/* ── Top bar (mirrors FleetNow) ──────────────────────────────────────── */
header {
padding: 9px 18px; background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex; gap: 20px; align-items: center; flex-wrap: wrap;
position: sticky; top: 0; z-index: 20;
}
.brand {
font-weight: 800; letter-spacing: .5px; font-size: 16px;
display: flex; align-items: center; gap: 8px; white-space: nowrap;
}
.brand .mark {
width: 10px; height: 10px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 10px var(--accent);
}
.brand .nm { color: var(--accent); }
#kpis { display: flex; gap: 22px; flex-wrap: wrap; align-items: center; }
.kpi { display: flex; flex-direction: column; min-width: 56px; }
.kpi b { font-size: 18px; line-height: 1.15; font-variant-numeric: tabular-nums; }
.kpi b.accent { color: var(--accent); }
.kpi b.live { color: var(--live); }
.kpi b.warn { color: var(--warn); }
.kpi span {
font-size: 9.5px; color: var(--muted); text-transform: uppercase;
letter-spacing: .6px; margin-top: 2px;
}
.spacer { margin-left: auto; }
.clock {
color: var(--text); font-size: 14px; font-variant-numeric: tabular-nums;
display: flex; flex-direction: column; align-items: flex-end;
}
.clock .label { font-size: 9.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; }
.clock b { font-weight: 600; }
2026-06-11 11:19:56 +00:00
/* ── Tab nav (segmented control) ─────────────────────────────────────── */
.tabs {
display: flex; gap: 4px; background: var(--bg);
border: 1px solid var(--border); border-radius: 8px; padding: 3px;
}
.tab {
background: transparent; color: var(--muted); border: 0; border-radius: 6px;
padding: 6px 14px; font-size: 12.5px; font-weight: 700; letter-spacing: .3px;
cursor: pointer; white-space: nowrap;
}
.tab:hover { color: var(--text); }
.tab.active { background: var(--accent); color: #1a1009; }
/* ── Tabbed views ────────────────────────────────────────────────────── */
.view { display: none; }
.view.active { display: block; }
2026-06-10 10:00:31 +00:00
/* ── Filter bar ──────────────────────────────────────────────────────── */
.filterbar {
padding: 10px 18px; background: var(--panel-2);
border-bottom: 1px solid var(--border);
display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap;
position: sticky; top: 49px; z-index: 19;
}
.ff { display: flex; flex-direction: column; gap: 4px; }
.ff label { font-size: 9.5px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); }
.ff select, .ff input {
background: var(--bg); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 7px 9px; font-size: 13px; min-width: 150px;
}
.ff select:focus, .ff input:focus { outline: none; border-color: var(--accent); }
.btn {
background: var(--accent); color: #1a1009; font-weight: 700; font-size: 13px;
border: 0; border-radius: 6px; padding: 8px 16px; cursor: pointer;
}
.btn:hover { background: var(--accent-hover); }
.btn.ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
.btn.ghost:hover { color: var(--text); border-color: var(--muted); }
.ff.custom { display: none; }
.ff.custom.show { display: flex; }
/* ── Content grid ────────────────────────────────────────────────────── */
main { padding: 16px 18px 40px; display: grid; gap: 16px; grid-template-columns: repeat(12, 1fr); }
.card {
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 14px 16px; min-width: 0;
}
.card.span12 { grid-column: span 12; }
.card.span8 { grid-column: span 8; }
.card.span6 { grid-column: span 6; }
.card.span4 { grid-column: span 4; }
@media (max-width: 1100px) { .card.span8, .card.span6, .card.span4 { grid-column: span 12; } }
.card h2 {
margin: 0 0 12px; font-size: 12px; text-transform: uppercase; letter-spacing: .8px;
color: var(--muted); font-weight: 700; display: flex; align-items: center; gap: 8px;
}
.card h2 .count { color: var(--accent); font-weight: 700; }
.chart-wrap { position: relative; height: 280px; }
/* ── Tables ──────────────────────────────────────────────────────────── */
.tbl-scroll { overflow: auto; max-height: 420px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
position: sticky; top: 0; background: var(--panel-2); color: var(--muted);
text-align: right; font-size: 10px; text-transform: uppercase; letter-spacing: .5px;
padding: 8px 10px; white-space: nowrap; border-bottom: 1px solid var(--border);
}
thead th:first-child, tbody td:first-child { text-align: left; }
tbody td {
padding: 7px 10px; text-align: right; border-bottom: 1px solid var(--border);
font-variant-numeric: tabular-nums; white-space: nowrap;
}
tbody tr:hover { background: var(--panel-2); }
td.plate { font-weight: 600; color: var(--text); }
td.dim { color: var(--muted); }
.pill {
display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px;
font-weight: 600; font-variant-numeric: tabular-nums;
}
.pill.good { background: rgba(45,212,167,.14); color: var(--live); }
.pill.warn { background: rgba(240,169,59,.14); color: var(--warn); }
.pill.bad { background: rgba(239,91,91,.16); color: var(--danger); }
.empty { color: var(--muted); padding: 24px; text-align: center; font-size: 13px; }
.banner {
background: rgba(240,169,59,.10); border: 1px solid rgba(240,169,59,.35);
color: var(--warn); border-radius: 8px; padding: 10px 12px; font-size: 12.5px; margin-bottom: 12px;
}
.banner ul { margin: 6px 0 0; padding-left: 18px; }
.banner.error { background: var(--error-bg); border-color: rgba(239,91,91,.45); color: var(--danger); }
.loading { opacity: .45; pointer-events: none; }
< / style >
< / head >
< body >
< div class = "app" >
< header >
< div class = "brand" > < span class = "mark" > < / span > FLEET< span class = "nm" > OPS< / span > < / div >
2026-06-11 11:19:56 +00:00
< nav class = "tabs" id = "tabs" >
< button class = "tab active" data-tab = "logistics" type = "button" > Logistics< / button >
< button class = "tab" data-tab = "tickets" type = "button" > Tickets< / button >
< / nav >
2026-06-10 10:00:31 +00:00
< div id = "kpis" > < / div >
< div class = "spacer" > < / div >
< div class = "clock" > < span class = "label" > EAT< / span > < b id = "clock-time" > —< / b > < / div >
< / header >
2026-06-11 11:19:56 +00:00
< div class = "content" >
< section class = "view active" id = "view-logistics" >
2026-06-10 10:00:31 +00:00
< div class = "filterbar" >
< div class = "ff" >
< label for = "f-cc" > Cost centre< / label >
< select id = "f-cc" > < option value = "" > All cost centres< / option > < / select >
< / div >
< div class = "ff" >
< label for = "f-city" > Assigned city< / label >
< select id = "f-city" > < option value = "" > All cities< / option > < / select >
< / div >
< div class = "ff" >
< label for = "f-period" > Period< / label >
< select id = "f-period" >
< option value = "today" > Today< / option >
< option value = "7d" > Last 1 week< / option >
< option value = "30d" selected > Last 1 month< / option >
< option value = "custom" > Custom range< / option >
< / select >
< / div >
< div class = "ff custom" id = "ff-start" > < label for = "f-start" > Start< / label > < input type = "date" id = "f-start" > < / div >
< div class = "ff custom" id = "ff-end" > < label for = "f-end" > End< / label > < input type = "date" id = "f-end" > < / div >
< button class = "btn" id = "apply" type = "button" > Apply< / button >
< button class = "btn ghost" id = "refresh" type = "button" title = "Reload" > ↻< / button >
< / div >
< main id = "main" >
< div class = "card span12" >
< h2 > Distance & idle — daily trend< / h2 >
< div class = "chart-wrap" > < canvas id = "trendChart" > < / canvas > < / div >
< / div >
< div class = "card span8" >
< h2 > Per-vehicle summary < span class = "count" id = "veh-count" > < / span > < / h2 >
< div class = "tbl-scroll" id = "veh-wrap" > < div class = "empty" > Loading…< / div > < / div >
< / div >
< div class = "card span4" >
< h2 > Fuel < span class = "count" id = "fuel-count" > < / span > < / h2 >
< div id = "fuel-wrap" > < div class = "empty" > Loading…< / div > < / div >
< / div >
< div class = "card span12" >
< h2 > Driver behaviour — 30-day leaderboard < span class = "count" id = "drv-count" > < / span > < / h2 >
< div class = "tbl-scroll" id = "drv-wrap" > < div class = "empty" > Loading…< / div > < / div >
< / div >
< / main >
2026-06-11 11:19:56 +00:00
< / section >
< section class = "view" id = "view-tickets" >
< main id = "main-tickets" >
< div class = "card span12" >
< div class = "banner" id = "tk-banner" >
Tickets data source not connected yet — this tab is scaffolded and ready to wire.
< ul >
< li > Ticket data must be served via < code > dashboard_api< / code > (proxied / presigned from the rustfs < code > tickets< / code > bucket); credentials are never embedded in this static SPA.< / li >
< / ul >
< / div >
< / div >
< div class = "card span8" >
< h2 > Recent tickets < span class = "count" id = "tk-count" > < / span > < / h2 >
< div class = "tbl-scroll" id = "tk-wrap" > < div class = "empty" > Loading…< / div > < / div >
< / div >
< div class = "card span4" >
< h2 > By status< / h2 >
< div id = "tk-status" > < div class = "empty" > Loading…< / div > < / div >
< / div >
< / main >
< / section >
< / div >
2026-06-10 10:00:31 +00:00
< / div >
< script >
// ============================================================================
// CONFIG
// ============================================================================
const API_BASE = (window.FLEETOPS_API_BASE & & /^https?:\/\//.test(window.FLEETOPS_API_BASE))
? window.FLEETOPS_API_BASE.replace(/\/$/, '')
: 'https://fleetapi.fivetitude.com'; // staging default
// ============================================================================
// HELPERS
// ============================================================================
const $ = (id) => document.getElementById(id);
const num = (v, d = 0) => (v == null || isNaN(v)) ? '—' : Number(v).toLocaleString('en', { minimumFractionDigits: d, maximumFractionDigits: d });
const intg = (v) => (v == null || isNaN(v)) ? '—' : Number(v).toLocaleString('en');
async function api(path) {
const r = await fetch(`${API_BASE}${path}`, { headers: { 'Accept': 'application/json' } });
if (!r.ok) throw new Error(`${path} → HTTP ${r.status}`);
const j = await r.json();
if (j & & j.error) throw new Error(j.error.message || 'API error');
return j;
}
function qs() {
const p = new URLSearchParams();
p.set('period', $('f-period').value);
if ($('f-cc').value) p.set('cost_centre', $('f-cc').value);
if ($('f-city').value) p.set('assigned_city', $('f-city').value);
if ($('f-period').value === 'custom') {
if ($('f-start').value) p.set('start_date', $('f-start').value);
if ($('f-end').value) p.set('end_date', $('f-end').value);
}
return p.toString();
}
function idlePill(pct) {
if (pct == null) return '< span class = "dim" > —< / span > ';
const cls = pct < 15 ? ' good ' : pct < = 30 ? ' warn ' : ' bad ' ;
return `< span class = "pill ${cls}" > ${num(pct, 1)}%< / span > `;
}
function ratePill(v, green, amber) {
if (v == null) return '< span class = "dim" > —< / span > ';
const cls = v < green ? ' good ' : v < = amber ? ' warn ' : ' bad ' ;
return `< span class = "pill ${cls}" > ${num(v, 2)}< / span > `;
}
// ============================================================================
// CLOCK
// ============================================================================
function tickClock() {
const t = new Date().toLocaleTimeString('en-GB', { timeZone: 'Africa/Nairobi', hour: '2-digit', minute: '2-digit', second: '2-digit' });
$('clock-time').textContent = t;
}
setInterval(tickClock, 1000); tickClock();
// ============================================================================
// FILTERS
// ============================================================================
async function loadFilters() {
try {
const f = await api('/analytics/filters');
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)));
} catch (e) { console.warn('filters', e); }
}
$('f-period').addEventListener('change', () => {
const custom = $('f-period').value === 'custom';
$('ff-start').classList.toggle('show', custom);
$('ff-end').classList.toggle('show', custom);
});
$('apply').addEventListener('click', loadAll);
$('refresh').addEventListener('click', loadAll);
// ============================================================================
// RENDER — KPIs
// ============================================================================
function renderKpis(totals, fuelL) {
totals = totals || {};
const kpis = [
['accent', intg(totals.vehicles), 'Vehicles'],
['', num(totals.total_km, 0), 'Fleet km'],
['', intg(totals.trips), 'Trips'],
['', num(totals.driving_hours, 0), 'Drive hrs'],
['warn', num(totals.idle_hours, 0), 'Idle hrs'],
['live', fuelL > 0 ? num(fuelL, 0) : '—', 'Fuel L'],
];
$('kpis').innerHTML = kpis.map(([cls, v, l]) =>
`< div class = "kpi" > < b class = "${cls}" > ${v}< / b > < span > ${l}< / span > < / div > `).join('');
}
// ============================================================================
// RENDER — trend chart
// ============================================================================
let trendChart = null;
function renderTrend(daily) {
daily = daily || [];
const labels = daily.map(d => d.trip_date);
const km = daily.map(d => Number(d.total_km || 0));
const idle = daily.map(d => d.idle_pct == null ? null : Number(d.idle_pct));
const css = getComputedStyle(document.documentElement);
const accent = css.getPropertyValue('--accent').trim();
const warn = css.getPropertyValue('--warn').trim();
const muted = css.getPropertyValue('--muted').trim();
const border = css.getPropertyValue('--border').trim();
const data = {
labels,
datasets: [
{ type: 'bar', label: 'Fleet km', data: km, backgroundColor: accent + 'cc', borderRadius: 3, yAxisID: 'y', order: 2 },
{ type: 'line', label: 'Idle %', data: idle, borderColor: warn, backgroundColor: warn, tension: .3, pointRadius: 2, yAxisID: 'y1', order: 1, spanGaps: true },
],
};
const scale = (id, pos, title, pct) => ({
position: pos, grid: { color: border, drawOnChartArea: id === 'y' }, border: { color: border },
ticks: { color: muted, callback: v => pct ? 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('y', 'left', 'km', false), y1: scale('y1', 'right', 'idle %', true) },
};
if (trendChart) { trendChart.data = data; trendChart.options = opts; trendChart.update(); }
else trendChart = new Chart($('trendChart'), { data, options: opts });
}
// ============================================================================
// RENDER — per-vehicle table
// ============================================================================
function renderVehicles(rows) {
rows = rows || [];
$('veh-count').textContent = rows.length ? `(${rows.length})` : '';
if (!rows.length) { $('veh-wrap').innerHTML = '< div class = "empty" > No trips in range.< / div > '; return; }
const body = rows.map(r => `
< tr >
< td class = "plate" > ${r.vehicle_number ?? '—'}< / td >
< td class = "dim" > ${r.cost_centre ?? '—'}< / td >
< td class = "dim" > ${r.assigned_city ?? '—'}< / td >
< td > ${intg(r.trips)}< / td >
< td > ${num(r.total_km, 1)}< / td >
< td > ${num(r.driving_hours, 1)}< / td >
< td > ${idlePill(r.idle_pct)}< / td >
< td > ${num(r.max_speed_kmh, 0)}< / td >
< / tr > `).join('');
$('veh-wrap').innerHTML = `< table >
< thead > < tr > < th > Vehicle< / th > < th > Cost centre< / th > < th > City< / th > < th > Trips< / th > < th > km< / th > < th > Drive h< / th > < th > Idle< / th > < th > Max km/h< / th > < / tr > < / thead >
< tbody > ${body}< / tbody > < / table > `;
}
// ============================================================================
// RENDER — driver behaviour
// ============================================================================
function renderDrivers(d) {
const rows = (d & & d.rows) || [];
$('drv-count').textContent = rows.length ? `(${rows.length})` : '';
if (!rows.length) {
const why = (d & & d.drivers_populated === false) ? 'Driver names not yet populated on devices.' : 'No driver activity in range.';
$('drv-wrap').innerHTML = `< div class = "empty" > ${why}< / div > `; return;
}
const body = rows.map(r => `
< tr >
< td class = "plate" > ${r.driver_name ?? '—'}< / td >
< td class = "dim" > ${r.assigned_city ?? '—'}< / td >
< td > ${intg(r.active_days)}< / td >
< td > ${num(r.total_km, 0)}< / td >
< td > ${intg(r.trips)}< / td >
< td > ${intg(r.events_80)}< / td >
< td > ${intg(r.events_100)}< / td >
< td > ${ratePill(r.speeding_per_100km, 0.5, 2.0)}< / td >
< td > ${ratePill(r.harsh_per_100km, 0.5, 2.0)}< / td >
< / tr > `).join('');
$('drv-wrap').innerHTML = `< table >
< thead > < tr > < th > Driver< / th > < th > City< / th > < th > Days< / th > < th > km< / th > < th > Trips< / th > < th > > 80< / th > < th > > 100< / th > < th > Speeding /100km< / th > < th > Harsh /100km< / th > < / tr > < / thead >
< tbody > ${body}< / tbody > < / table > `;
}
// ============================================================================
// RENDER — fuel
// ============================================================================
function renderFuel(d) {
const rows = (d & & d.rows) || [];
const ds = (d & & d.data_status) || {};
$('fuel-count').textContent = rows.length ? `(${rows.length})` : '';
let html = '';
if (!ds.actual_fuel_available & & !ds.estimated_fuel_available) {
html += `< div class = "banner" > No fuel figures yet.< ul > ${(ds.notes || []).map(n => `< li > ${n}< / li > `).join('')}< / ul > < / div > `;
}
const actual = rows.reduce((s, r) => s + Number(r.actual_fuel_l || 0), 0);
const est = rows.reduce((s, r) => s + Number(r.estimated_fuel_l || 0), 0);
const km = rows.reduce((s, r) => s + Number(r.total_km || 0), 0);
html += `< table >
< thead > < tr > < th > Metric< / th > < th > Value< / th > < / tr > < / thead >
< tbody >
< tr > < td > Total km< / td > < td > ${num(km, 0)}< / td > < / tr >
< tr > < td > Actual fuel (L)< / td > < td > ${actual > 0 ? num(actual, 1) : '—'}< / td > < / tr >
< tr > < td > Estimated fuel (L)< / td > < td > ${est > 0 ? num(est, 1) : '—'}< / td > < / tr >
< tr > < td > Trips w/ actual< / td > < td > ${intg(rows.reduce((s, r) => s + Number(r.trips_with_actual || 0), 0))}< / td > < / tr >
< / tbody > < / table > `;
$('fuel-wrap').innerHTML = html;
}
// ============================================================================
// LOAD ALL
// ============================================================================
async function loadAll() {
const q = qs();
$('main').classList.add('loading');
try {
const [summary, util, drivers, fuel] = await Promise.all([
api(`/analytics/fleet-summary?${q}`),
api(`/analytics/utilisation?${q}`),
api(`/analytics/driver-behaviour?${q}`),
api(`/analytics/fuel?${q}`),
]);
const fuelL = ((fuel & & fuel.rows) || []).reduce((s, r) => s + Number(r.actual_fuel_l || 0), 0);
2026-06-11 11:19:56 +00:00
lastTotals = summary.totals; lastFuelL = fuelL;
2026-06-10 10:00:31 +00:00
renderKpis(summary.totals, fuelL);
renderTrend(util.daily_trend);
renderVehicles(summary.rows);
renderDrivers(drivers);
renderFuel(fuel);
} catch (e) {
console.error(e);
$('main').querySelectorAll('.tbl-scroll, #fuel-wrap').forEach(el =>
el.innerHTML = `< div class = "banner error" > ${e.message || 'Failed to load. Is the API reachable?'}< / div > `);
} finally {
$('main').classList.remove('loading');
}
}
2026-06-11 11:19:56 +00:00
// ============================================================================
// TABS (Logistics ↔ Tickets)
// ============================================================================
// The header KPI strip is shared, so we cache the last logistics totals and
// re-render them when switching back from Tickets.
let lastTotals = null, lastFuelL = 0;
const ticketStats = {}; // populated once a tickets data source is wired
function renderTicketKpis() {
const k = [
['accent', ticketStats.open ?? '—', 'Open'],
['warn', ticketStats.in_progress ?? '—', 'In progress'],
['live', ticketStats.resolved ?? '—', 'Resolved'],
['', ticketStats.avg_resolution_h != null ? num(ticketStats.avg_resolution_h, 1) + 'h' : '—', 'Avg resolution'],
];
$('kpis').innerHTML = k.map(([c, v, l]) =>
`< div class = "kpi" > < b class = "${c}" > ${v}< / b > < span > ${l}< / span > < / div > `).join('');
}
let ticketsLoaded = false;
async function loadTickets() {
// Integration point. FleetOps is a credential-less static SPA, so ticket data
// must arrive through dashboard_api (proxied / presigned from the rustfs
// `tickets` bucket) — never by embedding S3 keys here. Wire the fetch + the
// renderers below once that endpoint exists. Until then, show empty states.
ticketsLoaded = true;
$('tk-count').textContent = '';
$('tk-wrap').innerHTML = '< div class = "empty" > No ticket data source connected yet.< / div > ';
$('tk-status').innerHTML = '< div class = "empty" > —< / div > ';
}
function switchTab(name) {
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === `view-${name}`));
if (name === 'tickets') {
renderTicketKpis();
if (!ticketsLoaded) loadTickets();
} else {
renderKpis(lastTotals, lastFuelL);
}
}
document.querySelectorAll('.tab').forEach(b =>
b.addEventListener('click', () => switchTab(b.dataset.tab)));
2026-06-10 10:00:31 +00:00
// ============================================================================
// BOOT
// ============================================================================
(async () => { await loadFilters(); await loadAll(); })();
< / script >
< / body >
< / html >