fleet-platform/web/index-live.html
kianiadee 9393491869
Some checks are pending
build / lint-test (push) Waiting to run
build / build-push (push) Blocked by required conditions
Trip panel UI: click vehicle → side panel, trip list, animated playback
Click any vehicle on the map to open a 360px slide-in panel showing:
  - reporting time (first ACC_ON of the day)
  - day totals: trip count, distance, drive/idle/stop minutes
  - per-trip rows with start/end/duration/distance/idling, click to
    select; selected trip renders its polyline + animates a marker
    along it over 10 seconds
  - end-reason badge per trip (work stop, reporting silence, long gap,
    day end) with colour-coded accent
  - date picker (defaults to today EAT)
  - CSV download button → /trips.csv?date=...

Map clicks query rendered features across circle/arrow/label layers and
take the topmost — single click handler, no per-layer duplicates. The
existing hover popup remains untouched.

Wraps #map in #map-container so the panel can absolute-position over
the right side without disturbing the existing left-aside grid layout.
authClient gets a getToken() helper so the CSV download path can attach
the Authorization header for a plain fetch (apiFetch returns JSON only).
2026-05-27 14:14:06 +03:00

239 lines
10 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Live Fleet · rahamafresh</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
<style>
:root {
--bg: #0f172a;
--panel: #1e293b;
--text: #f1f5f9;
--muted: #94a3b8;
--accent: #10b981;
--warn: #f59e0b;
--bad: #ef4444;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; }
body { display: grid; grid-template-rows: auto 1fr; }
header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 16px; background: var(--panel); border-bottom: 1px solid #0b1220;
}
header h1 { margin: 0; font-size: 14px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
header .right { display: flex; gap: 16px; align-items: center; font-size: 13px; color: var(--muted); }
main { display: grid; grid-template-columns: 320px 1fr; min-height: 0; }
aside { padding: 12px; overflow: auto; border-right: 1px solid #0b1220; background: var(--panel); }
#map { width: 100%; height: 100%; }
.tile-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 16px; }
.tile { background: #0b1220; padding: 10px; border-radius: 6px; }
.tile-label { font-size: 11px; text-transform: uppercase; color: var(--muted); letter-spacing: 0.04em; }
.tile-value { font-size: 22px; font-weight: 600; margin-top: 2px; }
h3 { font-size: 11px; text-transform: uppercase; color: var(--muted); margin: 16px 0 8px; letter-spacing: 0.06em; }
.slo { display: grid; grid-template-columns: 1fr auto auto; gap: 6px; padding: 6px 8px; border-radius: 4px; font-size: 12px; margin-bottom: 4px; background: #0b1220; }
.slo-name { color: var(--muted); }
.slo-status { text-transform: uppercase; font-size: 10px; letter-spacing: 0.06em; }
.slo-green .slo-status { color: var(--accent); }
.slo-red .slo-status { color: var(--bad); }
.slo-unknown .slo-status { color: var(--muted); }
.slo-empty { color: var(--muted); font-size: 12px; }
/* hover popup matching the dark theme */
.fleet-popup .maplibregl-popup-content {
background: #1e293b !important;
color: var(--text) !important;
padding: 14px 16px !important;
border-radius: 8px !important;
min-width: 240px;
box-shadow: 0 12px 32px rgba(0,0,0,0.55);
border: 1px solid #0b1220;
}
.fleet-popup .maplibregl-popup-tip { display: none; }
.popup-card { font-family: inherit; }
.popup-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
.popup-plate { font-size: 16px; font-weight: 700; letter-spacing: 0.01em; }
.popup-pill {
font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase;
padding: 3px 8px; border-radius: 4px; font-weight: 600;
}
.pill-moving { background: rgba(16,185,129,0.18); color: var(--accent); }
.pill-parked { background: rgba(148,163,184,0.15); color: var(--muted); }
.pill-offline { background: rgba(148,163,184,0.15); color: var(--muted); }
.pill-unknown { background: rgba(148,163,184,0.15); color: var(--muted); }
.popup-driver { color: var(--text); font-size: 13.5px; margin: 4px 0 2px; font-weight: 500; }
.popup-meta { color: var(--muted); font-size: 12px; margin: 2px 0 4px; }
.popup-address { color: var(--text); font-size: 13.5px; margin: 6px 0 8px; font-weight: 500; }
.popup-row { color: #cbd5e1; font-size: 12.5px; margin: 4px 0; }
form.filters { display: grid; gap: 8px; }
form.filters input, form.filters select { width: 100%; background: #0b1220; color: var(--text); border: 1px solid #0b1220; border-radius: 4px; padding: 6px 8px; font-size: 12px; }
form.filters label { font-size: 11px; text-transform: uppercase; color: var(--muted); letter-spacing: 0.06em; }
button.logout { background: transparent; color: var(--muted); border: 1px solid var(--muted); padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; }
button.logout:hover { color: var(--text); border-color: var(--text); }
/* trip panel */
#map-container { position: relative; height: 100%; }
#map { position: absolute; inset: 0; }
.trip-panel {
position: absolute; top: 0; right: 0; bottom: 0;
width: 360px;
background: var(--panel);
border-left: 1px solid #0b1220;
color: var(--text);
display: flex; flex-direction: column;
transform: translateX(100%);
transition: transform 0.22s ease;
z-index: 2;
overflow: hidden;
box-shadow: -8px 0 24px rgba(0,0,0,0.35);
}
.trip-panel.open { transform: translateX(0); }
.trip-panel-header {
display: flex; justify-content: space-between; align-items: flex-start;
padding: 14px 16px 10px; border-bottom: 1px solid #0b1220;
}
.trip-plate { font-size: 18px; font-weight: 700; letter-spacing: 0.01em; }
.trip-driver { font-size: 12px; color: var(--muted); margin-top: 2px; }
.trip-close {
background: transparent; color: var(--muted); border: 0;
font-size: 24px; cursor: pointer; line-height: 1; padding: 0 4px;
}
.trip-close:hover { color: var(--text); }
.trip-controls {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 16px; gap: 8px; border-bottom: 1px solid #0b1220;
}
.trip-controls label { font-size: 11px; text-transform: uppercase; color: var(--muted); letter-spacing: 0.06em; display: flex; align-items: center; gap: 8px; }
.trip-controls input[type="date"] {
background: #0b1220; color: var(--text);
border: 1px solid #0b1220; border-radius: 4px;
padding: 4px 6px; font-size: 12px; font-family: inherit;
color-scheme: dark;
}
.trip-csv {
background: transparent; color: var(--muted);
border: 1px solid var(--muted); border-radius: 4px;
padding: 4px 10px; cursor: pointer;
font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase;
}
.trip-csv:hover { color: var(--text); border-color: var(--text); }
.trip-totals {
padding: 12px 16px; font-size: 12.5px; color: var(--muted);
border-bottom: 1px solid #0b1220; line-height: 1.5;
}
.trip-totals strong { color: var(--text); font-weight: 600; }
.trip-totals .quality { font-size: 11px; margin-top: 6px; }
.trip-list { overflow: auto; flex: 1; }
.trip-list-empty { padding: 16px; color: var(--muted); font-size: 12.5px; }
.trip-row {
padding: 10px 16px;
border-bottom: 1px solid #0b1220;
cursor: pointer;
}
.trip-row:hover { background: #0b1220; }
.trip-row.selected {
background: #0b1220;
border-left: 3px solid var(--accent);
padding-left: 13px;
}
.trip-row-top {
display: flex; justify-content: space-between;
font-size: 13px; font-weight: 600;
}
.trip-row-meta {
display: flex; gap: 10px; flex-wrap: wrap;
font-size: 11.5px; color: var(--muted); margin-top: 4px;
}
.trip-row-reason {
display: inline-block; margin-top: 6px;
font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--muted);
}
.trip-row-reason.work-stop { color: var(--accent); }
.trip-row-reason.nofix-stop { color: var(--warn); }
.trip-row-reason.long-gap { color: var(--bad); }
</style>
</head>
<body>
<header>
<h1>fleet-platform · live</h1>
<div class="right">
<span id="clock"></span>
<button class="logout" onclick="window.fleetLogout()">Sign out</button>
</div>
</header>
<main>
<aside>
<h3>Fleet now</h3>
<div id="summary" class="tile-grid"></div>
<h3>SLOs</h3>
<div id="slos"></div>
<h3>Filters</h3>
<form class="filters" id="filters">
<label for="f-cost">Cost centre</label>
<input id="f-cost" name="cost_centre" placeholder="e.g. Nairobi-North" />
<label for="f-city">Assigned city</label>
<input id="f-city" name="assigned_city" placeholder="e.g. Nairobi" />
</form>
</aside>
<div id="map-container">
<div id="map"></div>
<aside id="trip-panel" class="trip-panel" aria-hidden="true">
<div class="trip-panel-header">
<div>
<div class="trip-plate" id="trip-plate"></div>
<div class="trip-driver" id="trip-driver"></div>
</div>
<button class="trip-close" id="trip-close" aria-label="Close trip panel">×</button>
</div>
<div class="trip-controls">
<label>Date <input type="date" id="trip-date" /></label>
<button class="trip-csv" id="trip-csv" type="button">CSV</button>
</div>
<div class="trip-totals" id="trip-totals">Click a vehicle to see its trips.</div>
<div class="trip-list" id="trip-list"></div>
</aside>
</div>
</main>
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<script type="module">
import { authClient, apiFetch, initMap, renderView, initFilters, clockEAT, initTripPanel } from '/fleet-core.js';
if (!authClient.requireSession()) { /* redirected */ }
window.fleetLogout = () => { authClient.logout(); window.location.href = '/login.html'; };
clockEAT('clock');
const map = initMap('map');
const summaryEl = document.getElementById('summary');
const slosEl = document.getElementById('slos');
let currentFilters = {};
async function refresh() {
try {
const params = Object.keys(currentFilters).length ? { filters: currentFilters } : {};
const payload = await apiFetch('/api/views/live', { params });
renderView(map, payload, { summaryRoot: summaryEl, sloRoot: slosEl });
} catch (err) {
console.error('refresh.failed', err);
}
}
initFilters(document.getElementById('filters'), (filters) => {
currentFilters = filters;
refresh();
});
initTripPanel(map, document.getElementById('trip-panel'));
refresh();
setInterval(refresh, 15000);
</script>
</body>
</html>