-
+
@@ -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]) =>
`
+
+
+
-
-
-
+
Recent tickets
-Loading…
+
+
-
-
-
-
-
+ By status
-Loading…
${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 = '
+ ${escapeHtml(p.vehicle_number || '—')} ${label}
+ ${p.driver ? `
`).addTo(tkMap);
+}
+
+function showTicketPopup(f) {
+ const p = f.properties || {};
+ const t = (p.service_type || '').toUpperCase();
+ tkPopup.setLngLat(f.geometry.coordinates).setHTML(`${escapeHtml(p.driver)}
` : ''}
+ ${p.cost_centre ? `${escapeHtml(p.cost_centre)}${p.assigned_city ? ' · ' + escapeHtml(p.assigned_city) : ''}
` : ''}
+ last fix ${escapeHtml(p.gps_time || '—')}
+ ${escapeHtml(p.ticket_id || '—')} ${t}
+
`).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
// ============================================================================
${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
' : ''}
+