feat(tickets map): closures-by-engineer panel, drill-down + dispatcher popup details
- New "Closures by engineer" leaderboard panel (metrics.by_owner): engineer, closed, breached, avg MTTR. Clicking an engineer toggles a drill-down that filters the closed map pins to only their closures (and ensures the closed layer is visible). - Popups now carry the details dispatchers need: open popups show location_name and the true coordinates (copyable; the fan-out keeps the real coord even when the pin is offset); closed popups show "closed by <engineer>". Backed by fn_inc_dashboard migration 12 (owner case-normalized server-side). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
a32735bca3
commit
51a84b66c6
1 changed files with 49 additions and 5 deletions
|
|
@ -439,6 +439,11 @@
|
||||||
<div class="chart-wrap"><canvas id="tk-closureChart"></canvas></div>
|
<div class="chart-wrap"><canvas id="tk-closureChart"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card span3">
|
||||||
|
<h2>Closures by engineer <span class="count" id="tk-owner-count"></span></h2>
|
||||||
|
<div class="tbl-scroll" id="tk-owner-wrap"><div class="empty">Loading…</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card span3">
|
<div class="card span3">
|
||||||
<h2>By status <span class="count" id="tk-status-count"></span></h2>
|
<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 class="tbl-scroll" id="tk-status-wrap"><div class="empty">Loading…</div></div>
|
||||||
|
|
@ -952,6 +957,7 @@ function vehState(p) {
|
||||||
let tkMap = null, tkPopup = null, tkLivePoll = null, tkClosureChart = null;
|
let tkMap = null, tkPopup = null, tkLivePoll = null, tkClosureChart = null;
|
||||||
const tkMarkers = new Map(); // imei → maplibregl.Marker
|
const tkMarkers = new Map(); // imei → maplibregl.Marker
|
||||||
const tkLayerState = { open: true, closed: true, vehicles: true };
|
const tkLayerState = { open: true, closed: true, vehicles: true };
|
||||||
|
let tkOwnerFilter = null; // when set, the closed layer is filtered to this engineer (drill-down)
|
||||||
let incData = null, incDropdownsInit = false, vehCount = 0;
|
let incData = null, incDropdownsInit = false, vehCount = 0;
|
||||||
|
|
||||||
// ── INC helpers ───────────────────────────────────────────────────────────
|
// ── INC helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
@ -1039,7 +1045,9 @@ function fanOutColocated(openFC, closedFC) {
|
||||||
coords = [c[0] + (r * Math.cos(a)) / Math.max(Math.cos(latRad), 0.2), c[1] + r * Math.sin(a)];
|
coords = [c[0] + (r * Math.cos(a)) / Math.max(Math.cos(latRad), 0.2), c[1] + r * Math.sin(a)];
|
||||||
}
|
}
|
||||||
(it.layer === 'open' ? outOpen : outClosed).push(
|
(it.layer === 'open' ? outOpen : outClosed).push(
|
||||||
{ type: 'Feature', properties: it.f.properties, geometry: { type: 'Point', coordinates: coords } });
|
{ type: 'Feature',
|
||||||
|
properties: { ...it.f.properties, __lng: c[0], __lat: c[1] }, // keep TRUE coords for popups
|
||||||
|
geometry: { type: 'Point', coordinates: coords } });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { open: { type: 'FeatureCollection', features: outOpen },
|
return { open: { type: 'FeatureCollection', features: outOpen },
|
||||||
|
|
@ -1047,7 +1055,12 @@ function fanOutColocated(openFC, closedFC) {
|
||||||
}
|
}
|
||||||
function renderIncMap() {
|
function renderIncMap() {
|
||||||
if (!tkMap || !incData) return;
|
if (!tkMap || !incData) return;
|
||||||
const { open, closed } = fanOutColocated(incData.open, incData.closed);
|
let closedFC = incData.closed || EMPTY_FC;
|
||||||
|
if (tkOwnerFilter) { // drill-down: only the selected engineer's closures
|
||||||
|
closedFC = { type: 'FeatureCollection',
|
||||||
|
features: (closedFC.features || []).filter((f) => (f.properties || {}).owner === tkOwnerFilter) };
|
||||||
|
}
|
||||||
|
const { open, closed } = fanOutColocated(incData.open, closedFC);
|
||||||
if (tkMap.getSource('inc-open')) tkMap.getSource('inc-open').setData(open);
|
if (tkMap.getSource('inc-open')) tkMap.getSource('inc-open').setData(open);
|
||||||
if (tkMap.getSource('inc-closed')) tkMap.getSource('inc-closed').setData(closed);
|
if (tkMap.getSource('inc-closed')) tkMap.getSource('inc-closed').setData(closed);
|
||||||
}
|
}
|
||||||
|
|
@ -1153,6 +1166,33 @@ function renderIncTables(m) {
|
||||||
const cn = incTable(nbo), cc = incTable(coast);
|
const cn = incTable(nbo), cc = incTable(coast);
|
||||||
$('tk-nbo-wrap').innerHTML = cn.html; $('tk-nbo-count').textContent = cn.n ? `(${cn.n})` : '';
|
$('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})` : '';
|
$('tk-coast-wrap').innerHTML = cc.html; $('tk-coast-count').textContent = cc.n ? `(${cc.n})` : '';
|
||||||
|
renderIncOwners(m.by_owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closures-by-engineer leaderboard (metrics.by_owner — owner case-normalized in SQL).
|
||||||
|
// Clicking a row toggles a drill-down that filters the closed map pins to that engineer.
|
||||||
|
function renderIncOwners(arr) {
|
||||||
|
arr = arr || [];
|
||||||
|
$('tk-owner-count').textContent = arr.length ? `(${arr.length})` : '';
|
||||||
|
const wrap = $('tk-owner-wrap');
|
||||||
|
if (!arr.length) { wrap.innerHTML = '<div class="empty">No closures in window.</div>'; return; }
|
||||||
|
const body = arr.map((o) => {
|
||||||
|
const hl = (o.owner === tkOwnerFilter) ? ' style="background:rgba(232,149,74,.14)"' : '';
|
||||||
|
const mttrH = (o.avg_mttr_min != null) ? num(o.avg_mttr_min / 60, 1) : '—';
|
||||||
|
return `<tr data-owner="${escapeHtml(o.owner)}"${hl} style="cursor:pointer">
|
||||||
|
<td class="plate">${escapeHtml(o.owner)}</td><td>${intg(o.closed)}</td>
|
||||||
|
<td>${intg(o.breached || 0)}</td><td>${mttrH}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
wrap.innerHTML = `<table><thead><tr><th>Engineer</th><th>Closed</th><th>Breach</th><th>MTTR h</th></tr></thead><tbody>${body}</tbody></table>`;
|
||||||
|
wrap.querySelectorAll('tr[data-owner]').forEach((tr) => tr.addEventListener('click', () => {
|
||||||
|
const o = tr.getAttribute('data-owner');
|
||||||
|
tkOwnerFilter = (tkOwnerFilter === o) ? null : o; // toggle off if re-clicked
|
||||||
|
if (tkOwnerFilter && tkMap && !tkLayerState.closed) { // make sure closures are visible
|
||||||
|
tkLayerState.closed = true; tkMap.setLayoutProperty('inc-closed', 'visibility', 'visible'); buildIncLayers();
|
||||||
|
}
|
||||||
|
renderIncOwners(arr); // re-highlight selection
|
||||||
|
renderIncMap(); // re-filter the closed pins
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderClosureChart(cr) {
|
function renderClosureChart(cr) {
|
||||||
|
|
@ -1271,16 +1311,20 @@ function showIncPopup(f, closed) {
|
||||||
const p = f.properties || {};
|
const p = f.properties || {};
|
||||||
const lines = [`<div class="row">${escapeHtml(p.normalized_status || '—')}</div>`];
|
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>`);
|
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 (p.location_name) lines.push(`<div class="row muted">${escapeHtml(p.location_name)}</div>`);
|
||||||
if (who) lines.push(`<div class="row muted">${escapeHtml(who)}</div>`);
|
|
||||||
if (closed) {
|
if (closed) {
|
||||||
|
if (p.owner) lines.push(`<div class="row muted">closed by ${escapeHtml(p.owner)}</div>`);
|
||||||
lines.push(`<div class="row muted">closed ${escapeHtml(eatShort(p.closed_at))} · MTTR ${mttrFmt(p.mttr)}</div>`);
|
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>`);
|
if (p.sla_status) lines.push(`<div class="row muted">${escapeHtml(p.sla_status)}</div>`);
|
||||||
} else {
|
} else {
|
||||||
|
const who = p.assigned_team || p.owner;
|
||||||
|
if (who) lines.push(`<div class="row muted">${escapeHtml(who)}</div>`);
|
||||||
const st = p.sla_state || 'unknown';
|
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>`);
|
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>');
|
// true location for the dispatcher (copyable; the pin itself may be fanned out)
|
||||||
|
if (p.__lat != null && p.__lng != null)
|
||||||
|
lines.push(`<div class="row muted" style="font-size:10px">${num(p.__lat, 5)}, ${num(p.__lng, 5)}${p.geo_source === 'cluster' ? ' · approx (cluster)' : ''}</div>`);
|
||||||
tkPopup.setLngLat(f.geometry.coordinates).setHTML(`<div class="pop">
|
tkPopup.setLngLat(f.geometry.coordinates).setHTML(`<div class="pop">
|
||||||
<b>${escapeHtml(p.ticket_id || '—')} <span class="badge" style="color:${closed ? closedColor(p.sla_status) : (SLA_COLORS[p.sla_state] || '#fff')}">${closed ? 'CLOSED' : 'OPEN'}</span></b>
|
<b>${escapeHtml(p.ticket_id || '—')} <span class="badge" style="color:${closed ? closedColor(p.sla_status) : (SLA_COLORS[p.sla_state] || '#fff')}">${closed ? 'CLOSED' : 'OPEN'}</span></b>
|
||||||
${lines.join('')}</div>`).addTo(tkMap);
|
${lines.join('')}</div>`).addTo(tkMap);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue