- Wrap the existing analytics dashboard as the Logistics tab - Add a scaffolded Tickets tab (per-tab KPIs, recent-tickets + by-status cards, informative empty state) - Shared header KPI strip swaps per tab; tickets load lazily on first open - Ticket data source left as a dashboard_api integration point — no S3 credentials embedded in the static SPA Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
548 lines
24 KiB
HTML
548 lines
24 KiB
HTML
<!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;
|
|
grid-template-rows: auto 1fr; /* header · content (tabs/filters live inside each view) */
|
|
}
|
|
|
|
/* ── 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; }
|
|
|
|
/* ── 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; }
|
|
|
|
/* ── 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>
|
|
<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>
|
|
<div id="kpis"></div>
|
|
<div class="spacer"></div>
|
|
<div class="clock"><span class="label">EAT</span><b id="clock-time">—</b></div>
|
|
</header>
|
|
|
|
<div class="content">
|
|
<section class="view active" id="view-logistics">
|
|
<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>
|
|
</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>
|
|
</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);
|
|
lastTotals = summary.totals; lastFuelL = fuelL;
|
|
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');
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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)));
|
|
|
|
// ============================================================================
|
|
// BOOT
|
|
// ============================================================================
|
|
(async () => { await loadFilters(); await loadAll(); })();
|
|
</script>
|
|
</body>
|
|
</html>
|