merge: tickets map (live vehicles + INC/CRQ layers) into staging

This commit is contained in:
david kiania 2026-06-11 16:24:16 +03:00
commit 6bfb72751f

View file

@ -21,6 +21,9 @@
<link rel="preconnect" href="https://cdn.jsdelivr.net" /> <link rel="preconnect" href="https://cdn.jsdelivr.net" />
<script src="/env.js"></script> <script src="/env.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.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> <style>
:root { :root {
/* Shared with FleetNow (warm dark ops palette) */ /* Shared with FleetNow (warm dark ops palette) */
@ -175,6 +178,78 @@
.banner ul { margin: 6px 0 0; padding-left: 18px; } .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); } .banner.error { background: var(--error-bg); border-color: rgba(239,91,91,.45); color: var(--danger); }
.loading { opacity: .45; pointer-events: none; } .loading { opacity: .45; pointer-events: none; }
/* ── Tickets map (FleetNow-style) ────────────────────────────────────── */
.map-wrap { position: relative; height: calc(100vh - 50px); }
#tk-map { position: absolute; inset: 0; }
.map-ctl { position: absolute; z-index: 5; font: 600 11px system-ui; color: #fff; user-select: none; }
#tk-layers { right: 10px; top: 10px; }
#tk-statusbar {
left: 10px; top: 10px; background: rgba(15,18,23,.92); border: 1px solid var(--border);
border-radius: 6px; padding: 5px 9px; box-shadow: 0 2px 8px rgba(0,0,0,.45);
}
#tk-statusbar label { display: flex; align-items: center; gap: 7px; color: var(--muted); }
#tk-statusbar select {
background: var(--bg); color: var(--text); border: 1px solid var(--border);
border-radius: 5px; padding: 4px 7px; font: 600 11px system-ui;
}
.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; }
/* 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> </style>
</head> </head>
<body> <body>
@ -240,26 +315,21 @@
</section> </section>
<section class="view" id="view-tickets"> <section class="view" id="view-tickets">
<main id="main-tickets"> <div class="map-wrap">
<div class="card span12"> <div id="tk-map"></div>
<div class="banner" id="tk-banner"> <div id="tk-statusbar" class="map-ctl">
Tickets data source not connected yet — this tab is scaffolded and ready to wire. <label>Status
<ul> <select id="tk-status">
<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> <option value="open" selected>Open (actionable)</option>
</ul> <option value="all">All statuses</option>
</div> </select>
</label>
</div> </div>
<div id="tk-layers" class="map-ctl collapsed">
<div class="card span8"> <button type="button" class="layers-toggle" id="tk-layers-toggle">Layers</button>
<h2>Recent tickets <span class="count" id="tk-count"></span></h2> <div class="layers-body" id="tk-layers-body"></div>
<div class="tbl-scroll" id="tk-wrap"><div class="empty">Loading…</div></div>
</div> </div>
</div>
<div class="card span4">
<h2>By status</h2>
<div id="tk-status"><div class="empty">Loading…</div></div>
</div>
</main>
</section> </section>
</div> </div>
</div> </div>
@ -502,37 +572,25 @@ async function loadAll() {
// The header KPI strip is shared, so we cache the last logistics totals and // The header KPI strip is shared, so we cache the last logistics totals and
// re-render them when switching back from Tickets. // re-render them when switching back from Tickets.
let lastTotals = null, lastFuelL = 0; let lastTotals = null, lastFuelL = 0;
const ticketStats = {}; // populated once a tickets data source is wired const ticketStats = {}; // { inc, crq, vehicles, moving } — filled by the tickets map
function renderTicketKpis() { function renderTicketKpis() {
const k = [ const k = [
['accent', ticketStats.open ?? '—', 'Open'], ['', ticketStats.inc ?? '—', 'INC open'],
['warn', ticketStats.in_progress ?? '—', 'In progress'], ['', ticketStats.crq ?? '—', 'CRQ open'],
['live', ticketStats.resolved ?? '—', 'Resolved'], ['accent', ticketStats.vehicles ?? '—', 'Vehicles'],
['', ticketStats.avg_resolution_h != null ? num(ticketStats.avg_resolution_h, 1) + 'h' : '—', 'Avg resolution'], ['live', ticketStats.moving ?? '—', 'Moving'],
]; ];
$('kpis').innerHTML = k.map(([c, v, l]) => $('kpis').innerHTML = k.map(([c, v, l]) =>
`<div class="kpi"><b class="${c}">${v}</b><span>${l}</span></div>`).join(''); `<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) { function switchTab(name) {
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab === 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}`)); document.querySelectorAll('.view').forEach(v => v.classList.toggle('active', v.id === `view-${name}`));
if (name === 'tickets') { if (name === 'tickets') {
renderTicketKpis(); renderTicketKpis();
if (!ticketsLoaded) loadTickets(); initTicketsMap(); // lazy — builds once, then just resizes
} else { } else {
renderKpis(lastTotals, lastFuelL); renderKpis(lastTotals, lastFuelL);
} }
@ -540,6 +598,208 @@ function switchTab(name) {
document.querySelectorAll('.tab').forEach(b => document.querySelectorAll('.tab').forEach(b =>
b.addEventListener('click', () => switchTab(b.dataset.tab))); b.addEventListener('click', () => switchTab(b.dataset.tab)));
// ============================================================================
// TICKETS MAP — FleetNow-style live map + INC/CRQ ticket layers
// ============================================================================
// Reads the same read-API as FleetNow/Logistics (API_BASE):
// GET /webhook/live-positions → vehicle DOM markers (ported from FleetNow)
// GET /webhook/tickets → INC (red) + CRQ (blue) circle layers
// Tickets are geocoded server-side (cluster gazetteer, migration 21); only
// rows with a geom come back. Map is lazy-initialised on first Tickets open.
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 TICKET_COLORS = { inc: '#ef5b5b', crq: '#3b82f6' };
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;
const tkMarkers = new Map(); // imei → maplibregl.Marker
const tkLayerState = { vehicles: true, inc: true, crq: true };
let tkStatusOpenOnly = true;
function initTicketsMap() {
if (tkMap) { tkMap.resize(); return; } // already built — just fix sizing
tkMap = new maplibregl.Map({
container: 'tk-map', style: BASEMAP, center: [37.5, -3.0], zoom: 5.2,
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', () => {
tkMap.addSource('tk-tickets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
for (const t of ['inc', 'crq']) {
tkMap.addLayer({
id: 'tk-' + t, type: 'circle', source: 'tk-tickets',
filter: ['==', ['get', 'service_type'], t],
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 6, 4, 11, 6, 16, 9],
'circle-color': TICKET_COLORS[t],
'circle-stroke-color': '#fff', 'circle-stroke-width': 1.5, 'circle-opacity': 0.9,
},
});
tkMap.on('mouseenter', 'tk-' + t, () => { tkMap.getCanvas().style.cursor = 'pointer'; });
tkMap.on('mouseleave', 'tk-' + t, () => { tkMap.getCanvas().style.cursor = ''; tkPopup.remove(); });
tkMap.on('mousemove', 'tk-' + t, (e) => showTicketPopup(e.features[0]));
}
tkMap.on('zoom', updateVehScale); updateVehScale();
loadTickets();
loadLive();
tkLivePoll = setInterval(loadLive, LIVE_POLL_MS);
});
$('tk-status').addEventListener('change', () => {
tkStatusOpenOnly = $('tk-status').value === 'open';
loadTickets();
});
$('tk-layers-toggle').addEventListener('click', () => $('tk-layers').classList.toggle('collapsed'));
}
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));
}
async function loadTickets() {
try {
const r = await fetch(`${API_BASE}/webhook/tickets?open_only=${tkStatusOpenOnly}`,
{ headers: { 'Accept': 'application/json' } });
const j = await r.json();
const gj = j.geojson || { type: 'FeatureCollection', features: [] };
if (tkMap.getSource('tk-tickets')) tkMap.getSource('tk-tickets').setData(gj);
const s = j.summary || {};
ticketStats.inc = s.inc ?? 0; ticketStats.crq = s.crq ?? 0;
renderTicketKpis(); buildTkLayers();
} catch (e) { console.warn('tickets', e); }
}
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 || {};
ticketStats.vehicles = s.vehicle_count ?? feats.length;
ticketStats.moving = s.moving ?? 0;
renderTicketKpis(); buildTkLayers();
} 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 showTicketPopup(f) {
const p = f.properties || {};
const t = (p.service_type || '').toUpperCase();
tkPopup.setLngLat(f.geometry.coordinates).setHTML(`<div class="pop">
<b>${escapeHtml(p.ticket_id || '—')} <span class="badge" style="color:${TICKET_COLORS[p.service_type] || '#fff'}">${t}</span></b>
<div class="row">${escapeHtml(p.status || '—')}</div>
${p.cluster ? `<div class="row muted">${escapeHtml(p.cluster)}${p.region ? ' · ' + escapeHtml(p.region) : ''}</div>` : ''}
${p.owner ? `<div class="row muted">${escapeHtml(p.owner)}</div>` : ''}
${p.department ? `<div class="row muted">${escapeHtml(p.department)}${p.sla_status ? ' · ' + escapeHtml(p.sla_status) : ''}</div>` : ''}
${p.geo_source === 'cluster' ? '<div class="row muted" style="font-size:10px">approx — cluster location</div>' : ''}
</div>`).addTo(tkMap);
}
function buildTkLayers() {
const rows = [
{ id: 'vehicles', label: 'Vehicles', color: '#E8954A', n: tkMarkers.size },
{ id: 'inc', label: 'INC incidents', color: TICKET_COLORS.inc, n: ticketStats.inc ?? 0 },
{ id: 'crq', label: 'CRQ installs', color: TICKET_COLORS.crq, n: ticketStats.crq ?? 0 },
];
$('tk-layers-body').innerHTML = 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">${r.n}</span></label>`).join('');
$('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.getLayer('tk-' + id)) {
tkMap.setLayoutProperty('tk-' + id, 'visibility', cb.checked ? 'visible' : 'none');
}
}));
}
// ============================================================================ // ============================================================================
// BOOT // BOOT
// ============================================================================ // ============================================================================