From 59620722cd9a864c3090bd876845962ab9b62b6d Mon Sep 17 00:00:00 2001 From: david kiania Date: Thu, 11 Jun 2026 16:24:16 +0300 Subject: [PATCH] feat(tickets): replace scaffold with FleetNow-style map (live vehicles + INC/CRQ layers) - #view-tickets is now a MapLibre map: live vehicle DOM markers (ported from FleetNow) + INC (red) / CRQ (blue) ticket circle layers from /webhook/tickets - Layers toggle with counts; open/all status filter; lazy-init + map.resize() - Header KPI strip shows INC/CRQ open + vehicles/moving on the Tickets tab - Logistics analytics tab unchanged Co-Authored-By: Claude Opus 4.8 --- src/index.html | 332 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 296 insertions(+), 36 deletions(-) diff --git a/src/index.html b/src/index.html index 601b09a..35dfb78 100644 --- a/src/index.html +++ b/src/index.html @@ -21,6 +21,9 @@ + + + @@ -240,26 +315,21 @@
-
-
- +
+
+
+
- -
-

Recent tickets

-
Loading…
+ - -
-

By status

-
Loading…
-
-
+
@@ -502,37 +572,25 @@ async function loadAll() { // 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 +const ticketStats = {}; // { inc, crq, vehicles, moving } — filled by the tickets map 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'], + ['', ticketStats.inc ?? '—', 'INC open'], + ['', ticketStats.crq ?? '—', 'CRQ open'], + ['accent', ticketStats.vehicles ?? '—', 'Vehicles'], + ['live', ticketStats.moving ?? '—', 'Moving'], ]; $('kpis').innerHTML = k.map(([c, v, l]) => `
${v}${l}
`).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 = '
No ticket data source connected yet.
'; - $('tk-status').innerHTML = '
'; -} - 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(); + initTicketsMap(); // lazy — builds once, then just resizes } else { renderKpis(lastTotals, lastFuelL); } @@ -540,6 +598,208 @@ function switchTab(name) { document.querySelectorAll('.tab').forEach(b => 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = '
'; + 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(`
+ ${escapeHtml(p.vehicle_number || '—')} ${label} + ${p.driver ? `
${escapeHtml(p.driver)}
` : ''} + ${p.cost_centre ? `
${escapeHtml(p.cost_centre)}${p.assigned_city ? ' · ' + escapeHtml(p.assigned_city) : ''}
` : ''} +
last fix ${escapeHtml(p.gps_time || '—')}
`).addTo(tkMap); +} + +function showTicketPopup(f) { + const p = f.properties || {}; + const t = (p.service_type || '').toUpperCase(); + tkPopup.setLngLat(f.geometry.coordinates).setHTML(`
+ ${escapeHtml(p.ticket_id || '—')} ${t} +
${escapeHtml(p.status || '—')}
+ ${p.cluster ? `
${escapeHtml(p.cluster)}${p.region ? ' · ' + escapeHtml(p.region) : ''}
` : ''} + ${p.owner ? `
${escapeHtml(p.owner)}
` : ''} + ${p.department ? `
${escapeHtml(p.department)}${p.sla_status ? ' · ' + escapeHtml(p.sla_status) : ''}
` : ''} + ${p.geo_source === 'cluster' ? '
approx — cluster location
' : ''} +
`).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) => + ``).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 // ============================================================================