fleetops/src/index.html

1266 lines
61 KiB
HTML
Raw Normal View History

<!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>
<!-- MapLibre GL — Tickets tab map (FleetNow-style live map + INC/CRQ layers). -->
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.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; }
.card.span3 { grid-column: span 3; }
@media (max-width: 1100px) { .card.span8, .card.span6, .card.span4 { grid-column: span 12; } .card.span3 { grid-column: span 6; } }
@media (max-width: 680px) { .card.span3 { grid-column: span 12; } }
/* Tickets grid: size cards to their content (no stretched empty space). */
#tk-main { align-items: start; }
.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; }
/* ── Tickets / INC dashboard map ─────────────────────────────────────── */
.map-wrap { position: relative; height: 62vh; min-height: 520px; }
#tk-map { position: absolute; inset: 0; border-radius: 8px; overflow: hidden; }
.map-ctl { position: absolute; z-index: 5; font: 600 11px system-ui; color: #fff; user-select: none; }
#tk-layers { right: 10px; top: 10px; }
.layers-toggle {
cursor: pointer; border: 1px solid var(--border); background: rgba(15,18,23,.92);
color: #fff; font: 600 11px system-ui; padding: 4px 10px; border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,.45);
}
.layers-toggle::before { content: '▣'; color: var(--accent); margin-right: 5px; }
.layers-body {
margin-top: 6px; background: rgba(15,18,23,.92); border: 1px solid var(--border);
border-radius: 8px; padding: 7px 9px; box-shadow: 0 4px 14px rgba(0,0,0,.5);
display: grid; gap: 5px; min-width: 160px;
}
#tk-layers.collapsed .layers-body { display: none; }
.layers-row { display: flex; align-items: center; gap: 7px; cursor: pointer; }
.layers-row input { accent-color: var(--accent); margin: 0; }
.layers-row .legend-dot { width: 11px; height: 11px; border-radius: 50%; flex: 0 0 auto; border: 1px solid rgba(255,255,255,.5); }
.layers-n { margin-left: auto; color: var(--muted); font-weight: 700; }
.legend-sep {
border-top: 1px solid var(--border); margin: 6px 0 2px; padding-top: 6px;
font-size: 9px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted);
}
/* INC metric strip */
.metric-row { display: flex; flex-wrap: wrap; gap: 26px; }
.metric { display: flex; flex-direction: column; min-width: 96px; }
.metric b { font-size: 22px; line-height: 1.1; font-variant-numeric: tabular-nums; }
.metric b.accent { color: var(--accent); }
.metric .lbl { font-size: 9.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; margin-top: 3px; }
.metric .sub { margin-top: 6px; font-size: 11px; display: flex; gap: 10px; flex-wrap: wrap; font-variant-numeric: tabular-nums; }
.metric .sub i { font-style: normal; }
.sla-breached { color: var(--danger); }
.sla-at_risk { color: var(--warn); }
.sla-ok { color: var(--live); }
.sla-unknown { color: var(--muted); }
/* INC overview row: metric tiles (left) + filters (right) on one balanced row */
.tk-overview-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px 28px; flex-wrap: wrap; }
.tk-overview-metrics { min-width: 0; }
.tk-filters { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; margin-left: auto; }
.tk-filters .ff select, .tk-filters .ff input { min-width: 130px; }
/* Live vehicle DOM marker (ported from FleetNow) */
.veh-marker { cursor: pointer; will-change: transform; }
.veh-inner { position: relative; width: 32px; height: 32px; transform: scale(var(--veh-scale, 1)); transform-origin: center center; }
.veh-pin {
width: 32px; height: 32px; border-radius: 50%; background: var(--c, var(--parked));
border: 2px solid rgba(255,255,255,.92); box-shadow: 0 2px 7px rgba(0,0,0,.5);
display: grid; place-items: center;
}
.veh-arrow {
width: 0; height: 0; border-left: 6px solid transparent; border-right: 6px solid transparent;
border-bottom: 12px solid #fff; transform: rotate(var(--dir, 0deg));
filter: drop-shadow(0 0 1px rgba(0,0,0,.65));
}
.veh-pin .idle-dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(255,255,255,.92); }
.veh-marker.parked .veh-pin { border-radius: 4px; transform: scale(0.5); transform-origin: center center; }
.veh-marker.offline .veh-pin { opacity: .5; border-color: rgba(255,255,255,.4); }
.veh-plate {
position: absolute; top: 33px; left: 50%; transform: translateX(-50%);
background: rgba(15,18,23,.92); color: #fff; font: 600 10px system-ui;
padding: 1px 6px; border-radius: 4px; white-space: nowrap; border: 1px solid var(--border);
}
.veh-marker.offline .veh-plate { color: var(--muted); }
/* MapLibre popup (warm, ported from FleetNow) */
.maplibregl-popup-content {
background: var(--panel) !important; color: var(--text) !important;
border: 1px solid var(--border); border-radius: 8px; padding: 11px 13px !important;
font: 12px/1.45 system-ui; box-shadow: 0 6px 18px rgba(0,0,0,.55);
}
.maplibregl-popup-tip { border-top-color: var(--panel) !important; }
.pop b { display: block; margin-bottom: 4px; font-size: 13px; }
.pop .muted { color: var(--muted); font-size: 11px; }
.pop .row { margin-top: 4px; }
.pop .badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 700; letter-spacing: .3px; text-transform: uppercase; }
.pop .badge.moving { background: rgba(45,212,167,.18); color: var(--live); }
.pop .badge.idling { background: rgba(240,169,59,.18); color: var(--warn); }
.pop .badge.parked { background: rgba(107,114,128,.22); color: #d1d5db; }
.pop .badge.offline { background: rgba(180,121,31,.25); color: var(--offline); }
.maplibregl-ctrl-attrib { background: rgba(30,35,46,.7) !important; }
.maplibregl-ctrl-attrib a { color: var(--accent); }
</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="fuel" type="button">Fuel Log</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 &amp; 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-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">
<main id="tk-main">
<div class="card span12">
<div class="tk-overview-row">
<div class="tk-overview-metrics">
<h2>INC overview <span class="count" id="tk-fresh"></span></h2>
<div class="metric-row" id="tk-metrics"><div class="empty">Loading…</div></div>
</div>
<div class="tk-filters">
<div class="ff">
<label for="tk-cluster">Cluster</label>
<select id="tk-cluster"><option value="">All clusters</option></select>
</div>
<div class="ff">
<label for="tk-status">Status</label>
<select id="tk-status"><option value="">All statuses</option></select>
</div>
<div class="ff">
<label for="tk-window">Window</label>
<select id="tk-window">
<option value="today" selected>Today</option>
<option value="week">This week</option>
<option value="month">This month</option>
<option value="custom">Custom range</option>
</select>
</div>
<div class="ff custom" id="tk-ff-start"><label for="tk-start">Start</label><input type="date" id="tk-start"></div>
<div class="ff custom" id="tk-ff-end"><label for="tk-end">End</label><input type="date" id="tk-end"></div>
<button class="btn" id="tk-apply" type="button">Apply</button>
<button class="btn ghost" id="tk-refresh" type="button" title="Reload"></button>
</div>
</div>
</div>
<div class="card span12">
<h2>Live INC map <span class="count">open (SLA-coloured) · closed overlay · vehicles</span></h2>
<div class="map-wrap">
<div id="tk-map"></div>
<div id="tk-layers" class="map-ctl collapsed">
<button type="button" class="layers-toggle" id="tk-layers-toggle">Layers</button>
<div class="layers-body" id="tk-layers-body"></div>
</div>
</div>
</div>
<div class="card span3">
<h2>Closures — daily</h2>
<div class="chart-wrap"><canvas id="tk-closureChart"></canvas></div>
</div>
<div class="card span3">
<h2>By status <span class="count" id="tk-status-count"></span></h2>
<div class="tbl-scroll" id="tk-status-wrap"><div class="empty">Loading…</div></div>
</div>
<div class="card span3">
<h2>Clusters — Nairobi <span class="count" id="tk-nbo-count"></span></h2>
<div class="tbl-scroll" id="tk-nbo-wrap"><div class="empty">Loading…</div></div>
</div>
<div class="card span3">
<h2>Clusters — Mombasa / Voi <span class="count" id="tk-coast-count"></span></h2>
<div class="tbl-scroll" id="tk-coast-wrap"><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');
// Which tab owns the shared header KPI strip right now — guards late async
// loaders (e.g. boot loadAll finishing after the user switched tabs) from
// clobbering another tab's header.
const activeTab = () => (document.querySelector('.tab.active')?.dataset.tab) || 'logistics';
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)));
// 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); }
}
$('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) {
if (activeTab() !== 'logistics') return; // don't clobber another tab's header
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>&gt;80</th><th>&gt;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');
}
}
// ============================================================================
// 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) {
if (activeTab() !== 'fuel') return;
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
// re-render them when switching back from Tickets.
let lastTotals = null, lastFuelL = 0;
// INC ticket metrics render into the INC overview card (renderIncMetrics) in the
// dashboard section below — driven by GET /webhook/inc-dashboard. The shared header
// KPI strip is intentionally left empty on the Tickets tab.
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') {
$('kpis').innerHTML = ''; // INC metrics live in the INC overview card, not the header
loadInc(); // (re)load INC data first — independent of the basemap
initIncMap(); // then build the map (lazy) / just resize if built
} else if (name === 'fuel') {
if (lastFuelTotals) renderFuelKpis(lastFuelTotals);
if (!fuelLoaded) loadFuel(); // lazy — first open
else if (fuelTrendChart) fuelTrendChart.resize();
} else {
renderKpis(lastTotals, lastFuelL);
}
}
document.querySelectorAll('.tab').forEach(b =>
b.addEventListener('click', () => switchTab(b.dataset.tab)));
// ============================================================================
// TICKETS — INC operations dashboard (open layer + windowed closed overlay)
// ============================================================================
// Reads the new dashboard_api endpoint (API_BASE):
// GET /webhook/inc-dashboard?cluster=&status=&window=&from=&to=
// → { window, open: GeoJSON, closed: GeoJSON, metrics, freshness }
// GET /webhook/live-positions → live FleetNow vehicle DOM markers (overlay)
// Only geocoded INC rows become map features; metrics count the full set. The
// map is lazy-initialised on first Tickets open; INC data refetched on Apply.
const BASEMAP = 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json';
const OFFLINE_THRESHOLD_MS = 24 * 3600 * 1000;
const STALE_GPS_MS = 10 * 60 * 1000;
const LIVE_POLL_MS = 15000;
const EMPTY_FC = { type: 'FeatureCollection', features: [] };
// SLA state → colour (open layer + legend); mirrors the warm-dark palette.
const SLA_COLORS = { breached: '#ef5b5b', at_risk: '#f0a93b', ok: '#2dd4a7', unknown: '#6b7280' };
const SLA_LABELS = { breached: 'Breached', at_risk: 'At risk', ok: 'OK', unknown: 'Unknown' };
const CLOSED_COLOR = '#94a3b8'; // muted slate — closed tickets (status irrelevant)
// Coast (Mombasa / Voi) cluster classifier — splits the cluster breakdown by name
// (the feed's region field is noisy; cluster names classify cleanly).
const COAST_HINTS = ['coast', 'mombasa', 'voi', 'nyali', 'mtwapa', 'kiembeni', 'vipingo',
'bombolulu', 'kizingo', 'kwale', 'shanzu', 'likoni', 'mariakani', 'bamburi', 'changamwe',
'diani', 'ukunda', 'malindi', 'kilifi', 'mtongwe'];
const clusterIsCoast = (name) => { const s = (name || '').toLowerCase(); return COAST_HINTS.some(k => s.includes(k)); };
const COST_CENTRE_COLORS = {
'isp': '#3b82f6', 'osp': '#E8954A', 'osp patrol': '#f97316', 'fds': '#22c55e',
'roll out': '#a855f7', 'general': '#fbbf24', 'regional': '#ec4899',
'planning': '#06b6d4', 'deliveries': '#84cc16', 'qehs': '#14b8a6', 'airtel': '#ef4444',
};
const CC_PALETTE = ['#2dd4a7', '#8b5cf6', '#f43f5e', '#0ea5e9', '#eab308', '#d946ef', '#10b981'];
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
function ccColor(cc) {
if (!cc) return '#9ca3af';
const key = String(cc).trim().toLowerCase();
if (COST_CENTRE_COLORS[key]) return COST_CENTRE_COLORS[key];
let h = 0; for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0;
return CC_PALETTE[Math.abs(h) % CC_PALETTE.length];
}
function pastel(hex, mix = 0.58) {
const h = hex.replace('#', '');
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
const t = (c) => Math.round(c + (255 - c) * mix);
return `rgb(${t(r)}, ${t(g)}, ${t(b)})`;
}
function plateTail(v) { const s = String(v || '').replace(/\s+/g, ''); return s ? s.slice(-4) : '—'; }
function vehState(p) {
if (!p) return 'offline';
const age = (typeof p.source_age_hours === 'number') ? p.source_age_hours * 3600000
: (p.gps_time_utc ? Date.now() - new Date(p.gps_time_utc).getTime() : 0);
if (age >= OFFLINE_THRESHOLD_MS) return 'offline';
if (p.acc_status !== '1') return 'parked';
if (age >= STALE_GPS_MS) return 'parked';
return 'active';
}
let tkMap = null, tkPopup = null, tkLivePoll = null, tkClosureChart = null;
const tkMarkers = new Map(); // imei → maplibregl.Marker
const tkLayerState = { open: true, closed: false, vehicles: true };
let incData = null, incDropdownsInit = false, vehCount = 0;
// ── INC helpers ───────────────────────────────────────────────────────────
const mttrFmt = (min) => (min == null || isNaN(min)) ? '—' : num(min / 60, 1) + ' h';
function eatShort(iso) {
if (!iso) return '—';
const d = new Date(iso); if (isNaN(d)) return '—';
return d.toLocaleString('en-GB', { timeZone: 'Africa/Nairobi', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
}
function addDay(ymd) { const [y, m, d] = ymd.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10); }
function incQs() {
const p = new URLSearchParams();
if ($('tk-cluster').value) p.set('cluster', $('tk-cluster').value);
if ($('tk-status').value) p.set('status', $('tk-status').value);
const w = $('tk-window').value;
if (w === 'custom') {
if ($('tk-start').value) p.set('from', $('tk-start').value + 'T00:00:00+03:00');
if ($('tk-end').value) p.set('to', addDay($('tk-end').value) + 'T00:00:00+03:00'); // inclusive end
} else {
p.set('window', w);
}
return p.toString();
}
// Draw a teardrop map-pin (head circle + point) at 2x for crispness; returns
// ImageData added to the map style. Recoloured per SLA state / closed.
function pinImageData(fill) {
const r = 22, sw = 4, cx = r + sw, cy = r + sw, tip = cy + r * 2.55;
const w = 2 * (r + sw), h = Math.ceil(tip + sw);
const cv = document.createElement('canvas'); cv.width = w; cv.height = h;
const ctx = cv.getContext('2d');
ctx.beginPath();
ctx.arc(cx, cy, r, Math.PI * 0.75, Math.PI * 0.25, false); // top ~270° of the head
ctx.lineTo(cx, tip); // taper to the tip
ctx.closePath();
ctx.fillStyle = fill; ctx.fill();
ctx.lineWidth = sw; ctx.strokeStyle = 'rgba(255,255,255,.95)'; ctx.lineJoin = 'round'; ctx.stroke();
ctx.beginPath(); ctx.arc(cx, cy, r * 0.4, 0, 2 * Math.PI); // inner white hole
ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.fill();
return ctx.getImageData(0, 0, w, h);
}
function addPinImages() {
const pins = {
'pin-breached': SLA_COLORS.breached, 'pin-at_risk': SLA_COLORS.at_risk,
'pin-ok': SLA_COLORS.ok, 'pin-unknown': SLA_COLORS.unknown, 'pin-closed': CLOSED_COLOR,
};
for (const [id, fill] of Object.entries(pins))
if (!tkMap.hasImage(id)) tkMap.addImage(id, pinImageData(fill), { pixelRatio: 2 });
}
function initIncMap() {
if (tkMap) { tkMap.resize(); return; } // already built — just fix sizing
// Filter / control listeners (attached once, with the map).
$('tk-window').addEventListener('change', () => {
const custom = $('tk-window').value === 'custom';
$('tk-ff-start').classList.toggle('show', custom);
$('tk-ff-end').classList.toggle('show', custom);
});
$('tk-apply').addEventListener('click', loadInc);
$('tk-refresh').addEventListener('click', loadInc);
$('tk-layers-toggle').addEventListener('click', () => $('tk-layers').classList.toggle('collapsed'));
tkMap = new maplibregl.Map({
container: 'tk-map', style: BASEMAP, center: [37.5, -1.1], zoom: 6,
attributionControl: { compact: true },
});
tkMap.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
tkPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 14 });
tkMap.on('load', () => {
addPinImages(); // teardrop pin icons (open by SLA, closed one slate colour)
// Closed overlay (windowed) — drawn UNDER the live open layer; status irrelevant
// so every closed ticket uses the single muted 'pin-closed', slightly smaller.
tkMap.addSource('inc-closed', { type: 'geojson', data: EMPTY_FC });
tkMap.addLayer({
id: 'inc-closed', type: 'symbol', source: 'inc-closed',
layout: {
visibility: tkLayerState.closed ? 'visible' : 'none',
'icon-image': 'pin-closed',
'icon-size': ['interpolate', ['linear'], ['zoom'], 6, 0.42, 11, 0.6, 16, 0.78],
'icon-anchor': 'bottom', 'icon-allow-overlap': true, 'icon-ignore-placement': true,
},
paint: { 'icon-opacity': 0.85 },
});
// Open layer (live) — pin colour = derived SLA state; larger for hierarchy.
tkMap.addSource('inc-open', { type: 'geojson', data: EMPTY_FC });
tkMap.addLayer({
id: 'inc-open', type: 'symbol', source: 'inc-open',
layout: {
visibility: tkLayerState.open ? 'visible' : 'none',
'icon-image': ['match', ['get', 'sla_state'],
'breached', 'pin-breached', 'at_risk', 'pin-at_risk', 'ok', 'pin-ok', 'pin-unknown'],
'icon-size': ['interpolate', ['linear'], ['zoom'], 6, 0.52, 11, 0.78, 16, 1],
'icon-anchor': 'bottom', 'icon-allow-overlap': true, 'icon-ignore-placement': true,
},
});
for (const id of ['inc-open', 'inc-closed']) {
tkMap.on('mouseenter', id, () => { tkMap.getCanvas().style.cursor = 'pointer'; });
tkMap.on('mouseleave', id, () => { tkMap.getCanvas().style.cursor = ''; tkPopup.remove(); });
tkMap.on('mousemove', id, (e) => showIncPopup(e.features[0], id === 'inc-closed'));
}
tkMap.on('zoom', updateVehScale); updateVehScale();
if (incData) { // INC data may have arrived before the basemap finished loading
tkMap.getSource('inc-open').setData(incData.open || EMPTY_FC);
tkMap.getSource('inc-closed').setData(incData.closed || EMPTY_FC);
}
loadLive();
tkLivePoll = setInterval(loadLive, LIVE_POLL_MS);
});
}
function updateVehScale() {
if (!tkMap) return;
const t = Math.max(0, Math.min(1, (tkMap.getZoom() - 5) / 9));
document.getElementById('tk-map').style.setProperty('--veh-scale', (0.42 + t * 0.78).toFixed(3));
}
// ── render: metric strip + tables + closure chart ───────────────────────────
function renderIncMetrics(m, freshness) {
m = m || {};
const so = (m.sla && m.sla.open) || {}, sc = (m.sla && m.sla.closed) || {}, cr = m.closure_rate || {};
const tiles = [
`<div class="metric"><b class="accent">${intg(m.open_now)}</b><span class="lbl">Open now</span></div>`,
`<div class="metric"><b>${intg(m.closed_in_window)}</b><span class="lbl">Closed in window</span></div>`,
`<div class="metric"><b class="sla-breached">${intg(so.breached)}</b><span class="lbl">Open breached</span>
<div class="sub"><i class="sla-at_risk">${intg(so.at_risk)} at-risk</i><i class="sla-ok">${intg(so.ok)} ok</i><i class="sla-unknown">${intg(so.unknown)} unkn</i></div></div>`,
`<div class="metric"><b><span class="sla-ok">${intg(sc.compliant)}</span> / <span class="sla-breached">${intg(sc.breached)}</span></b><span class="lbl">Closed compliant / breached</span></div>`,
`<div class="metric"><b>${mttrFmt(m.avg_mttr_min)}</b><span class="lbl">Avg MTTR</span></div>`,
`<div class="metric"><b>${num(cr.per_day_avg, 1)}</b><span class="lbl">Closures / day</span></div>`,
];
$('tk-metrics').innerHTML = tiles.join('');
const fr = freshness && freshness.inc;
$('tk-fresh').textContent = fr ? `updated ${eatShort(fr.ingested_at)} · ${intg(fr.records_ingested)} records` : '';
}
function incTable(obj) {
const rows = Object.entries(obj || {}).filter(([k]) => k !== '').sort((a, b) => b[1] - a[1]);
if (!rows.length) return { n: 0, html: '<div class="empty">No data.</div>' };
const body = rows.map(([k, v]) => `<tr><td class="plate">${escapeHtml(k)}</td><td>${intg(v)}</td></tr>`).join('');
return { n: rows.length, html: `<table><thead><tr><th>Name</th><th>Count</th></tr></thead><tbody>${body}</tbody></table>` };
}
function renderIncTables(m) {
m = m || {};
const s = incTable(m.by_status);
$('tk-status-wrap').innerHTML = s.html; $('tk-status-count').textContent = s.n ? `(${s.n})` : '';
// Split the cluster breakdown into Nairobi vs Mombasa/Voi (Coast) by cluster name.
const nbo = {}, coast = {};
for (const [name, n] of Object.entries(m.by_cluster || {})) (clusterIsCoast(name) ? coast : nbo)[name] = n;
const cn = incTable(nbo), cc = incTable(coast);
$('tk-nbo-wrap').innerHTML = cn.html; $('tk-nbo-count').textContent = cn.n ? `(${cn.n})` : '';
$('tk-coast-wrap').innerHTML = cc.html; $('tk-coast-count').textContent = cc.n ? `(${cc.n})` : '';
}
function renderClosureChart(cr) {
const series = (cr && cr.series) || [];
const labels = series.map(d => d.day), data = series.map(d => Number(d.count || 0));
const css = getComputedStyle(document.documentElement);
const accent = css.getPropertyValue('--accent').trim();
const muted = css.getPropertyValue('--muted').trim();
const border = css.getPropertyValue('--border').trim();
const cfg = {
data: { labels, datasets: [{ type: 'bar', label: 'Closures', data, backgroundColor: accent + 'cc', borderRadius: 3 }] },
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: border }, border: { color: border }, ticks: { color: muted, maxRotation: 0, autoSkip: true } },
y: { grid: { color: border }, border: { color: border }, ticks: { color: muted, precision: 0 }, beginAtZero: true },
},
},
};
if (tkClosureChart) { tkClosureChart.data = cfg.data; tkClosureChart.options = cfg.options; tkClosureChart.update(); }
else tkClosureChart = new Chart($('tk-closureChart'), cfg);
}
// Populate the Cluster / Status filters from the first unfiltered response.
function initIncDropdowns(m) {
const fill = (id, obj) => {
const el = $(id);
Object.keys(obj || {}).filter(k => k !== '').sort().forEach(k => el.add(new Option(k, k)));
};
fill('tk-cluster', m.by_cluster);
fill('tk-status', m.by_status);
}
async function loadInc() {
$('tk-main').classList.add('loading');
try {
const j = await api(`/webhook/inc-dashboard?${incQs()}`);
incData = j;
if (!incDropdownsInit && j.metrics) { initIncDropdowns(j.metrics); incDropdownsInit = true; }
renderIncMetrics(j.metrics, j.freshness);
renderIncTables(j.metrics);
renderClosureChart(j.metrics && j.metrics.closure_rate);
if (tkMap && tkMap.getSource('inc-open')) tkMap.getSource('inc-open').setData(j.open || EMPTY_FC);
if (tkMap && tkMap.getSource('inc-closed')) tkMap.getSource('inc-closed').setData(j.closed || EMPTY_FC);
buildIncLayers();
} catch (e) {
console.error(e);
$('tk-metrics').innerHTML = `<div class="banner error">${e.message || 'Failed to load the INC dashboard. Is the API reachable?'}</div>`;
} finally {
$('tk-main').classList.remove('loading');
}
}
async function loadLive() {
try {
const r = await fetch(`${API_BASE}/webhook/live-positions`, { headers: { 'Accept': 'application/json' } });
const j = await r.json();
const feats = (j.geojson && j.geojson.features) || [];
const seen = new Set();
for (const f of feats) {
const p = f.properties || {}; if (!p.imei) continue;
seen.add(p.imei); upsertVeh(p, f.geometry.coordinates);
}
for (const [imei, m] of tkMarkers) if (!seen.has(imei)) { m.remove(); tkMarkers.delete(imei); }
const s = j.summary || {};
vehCount = s.vehicle_count ?? feats.length;
buildIncLayers();
} catch (e) { console.warn('live', e); }
}
function upsertVeh(p, coords) {
const state = vehState(p);
const base = ccColor(p.cost_centre);
const color = state === 'offline' ? '#374151' : state === 'parked' ? pastel(base) : base;
const speed = Number(p.speed || 0), dir = Number(p.direction || 0);
let m = tkMarkers.get(p.imei);
if (!m) {
const el = document.createElement('div');
el.className = 'veh-marker';
el.innerHTML = '<div class="veh-inner"><div class="veh-pin"><span class="glyph"></span></div><div class="veh-plate"></div></div>';
el.addEventListener('mouseenter', () => showVehPopup(p, coords));
el.addEventListener('mouseleave', () => tkPopup.remove());
m = new maplibregl.Marker({ element: el, anchor: 'center' }).setLngLat(coords).addTo(tkMap);
tkMarkers.set(p.imei, m);
} else { m.setLngLat(coords); }
const el = m.getElement();
el.classList.remove('active', 'parked', 'offline'); el.classList.add(state);
el.style.display = tkLayerState.vehicles ? '' : 'none';
el.querySelector('.veh-pin').style.setProperty('--c', color);
const glyph = el.querySelector('.glyph');
if (state === 'active' && speed > 0) {
glyph.className = 'glyph veh-arrow'; glyph.innerHTML = ''; glyph.style.setProperty('--dir', dir + 'deg');
} else if (state === 'parked') {
glyph.className = 'glyph'; glyph.innerHTML = ''; glyph.style.removeProperty('--dir');
} else {
glyph.className = 'glyph idle-dot'; glyph.innerHTML = ''; glyph.style.removeProperty('--dir');
}
el.querySelector('.veh-plate').textContent = plateTail(p.vehicle_number);
el.style.zIndex = state === 'active' ? 3 : (state === 'parked' ? 2 : 1);
}
function showVehPopup(p, coords) {
const state = vehState(p), speed = Number(p.speed || 0);
const label = state === 'offline' ? 'offline' : state === 'parked' ? 'parked'
: speed > 0 ? `moving · ${speed.toFixed(0)} kmh` : 'idling';
const cls = state === 'active' && speed > 0 ? 'moving' : state === 'active' ? 'idling' : state;
tkPopup.setLngLat(coords).setHTML(`<div class="pop">
<b>${escapeHtml(p.vehicle_number || '—')} <span class="badge ${cls}">${label}</span></b>
${p.driver ? `<div class="row">${escapeHtml(p.driver)}</div>` : ''}
${p.cost_centre ? `<div class="row muted">${escapeHtml(p.cost_centre)}${p.assigned_city ? ' · ' + escapeHtml(p.assigned_city) : ''}</div>` : ''}
<div class="row muted">last fix ${escapeHtml(p.gps_time || '—')}</div></div>`).addTo(tkMap);
}
function showIncPopup(f, closed) {
const p = f.properties || {};
const lines = [`<div class="row">${escapeHtml(p.normalized_status || '—')}</div>`];
if (p.cluster) lines.push(`<div class="row muted">${escapeHtml(p.cluster)}${p.region ? ' · ' + escapeHtml(p.region) : ''}</div>`);
const who = p.assigned_team || p.owner;
if (who) lines.push(`<div class="row muted">${escapeHtml(who)}</div>`);
if (closed) {
lines.push(`<div class="row muted">closed ${escapeHtml(eatShort(p.closed_at))} · MTTR ${mttrFmt(p.mttr)}</div>`);
if (p.sla_status) lines.push(`<div class="row muted">${escapeHtml(p.sla_status)}</div>`);
} else {
const st = p.sla_state || 'unknown';
lines.push(`<div class="row"><span class="badge" style="color:${SLA_COLORS[st] || '#fff'}">${SLA_LABELS[st] || st}</span>${p.hours_open != null ? ' · ' + num(p.hours_open, 0) + 'h open' : ''}</div>`);
}
if (p.geo_source === 'cluster') lines.push('<div class="row muted" style="font-size:10px">approx — cluster location</div>');
tkPopup.setLngLat(f.geometry.coordinates).setHTML(`<div class="pop">
<b>${escapeHtml(p.ticket_id || '—')} <span class="badge" style="color:${closed ? CLOSED_COLOR : (SLA_COLORS[p.sla_state] || '#fff')}">${closed ? 'CLOSED' : 'OPEN'}</span></b>
${lines.join('')}</div>`).addTo(tkMap);
}
function buildIncLayers() {
const m = (incData && incData.metrics) || {};
const rows = [
{ id: 'open', label: 'Open INC', color: SLA_COLORS.breached, n: m.open_now ?? 0 },
{ id: 'closed', label: 'Closed INC', color: CLOSED_COLOR, n: m.closed_in_window ?? 0 },
{ id: 'vehicles', label: 'Vehicles', color: '#E8954A', n: vehCount },
];
let html = rows.map((r) =>
`<label class="layers-row"><input type="checkbox" data-lyr="${r.id}"${tkLayerState[r.id] ? ' checked' : ''}>
<span class="legend-dot" style="background:${r.color}"></span><span>${r.label}</span><span class="layers-n">${intg(r.n)}</span></label>`).join('');
html += '<div class="legend-sep">Open SLA</div>' + ['breached', 'at_risk', 'ok', 'unknown'].map((s) =>
`<div class="layers-row"><span class="legend-dot" style="background:${SLA_COLORS[s]}"></span><span>${SLA_LABELS[s]}</span></div>`).join('');
$('tk-layers-body').innerHTML = html;
$('tk-layers-body').querySelectorAll('input[type=checkbox]').forEach((cb) =>
cb.addEventListener('change', () => {
const id = cb.getAttribute('data-lyr'); tkLayerState[id] = cb.checked;
if (id === 'vehicles') {
for (const [, m] of tkMarkers) m.getElement().style.display = cb.checked ? '' : 'none';
} else if (tkMap && tkMap.getLayer('inc-' + id)) {
tkMap.setLayoutProperty('inc-' + id, 'visibility', cb.checked ? 'visible' : 'none');
}
}));
}
// ============================================================================
// BOOT
// ============================================================================
(async () => { await loadFilters(); await loadAll(); })();
</script>
</body>
</html>