refactor(tickets): bigger map, split clusters by region, drop header KPI strip

- Remove the redundant header KPI strip on the Tickets tab (metrics live in the
  INC overview card); header stays empty there.
- Enlarge the map (62vh, min 520px) and size the grid cards to their content
  (#tk-main align-items:start) so there's no stretched empty space under the
  Closures / By-status cards.
- Split the single 26-row "By cluster" list into "Clusters — Nairobi" and
  "Clusters — Mombasa / Voi", classified by cluster name (coast keyword set);
  bottom row is now four compact span3 cards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
david kiania 2026-06-16 12:23:09 +03:00
parent 0ed9b6a252
commit 1ec25b70be

View file

@ -138,7 +138,11 @@
.card.span8 { grid-column: span 8; } .card.span8 { grid-column: span 8; }
.card.span6 { grid-column: span 6; } .card.span6 { grid-column: span 6; }
.card.span4 { grid-column: span 4; } .card.span4 { grid-column: span 4; }
@media (max-width: 1100px) { .card.span8, .card.span6, .card.span4 { grid-column: span 12; } } .card.span3 { grid-column: span 3; }
@media (max-width: 1100px) { .card.span8, .card.span6, .card.span4 { grid-column: span 12; } .card.span3 { grid-column: span 6; } }
@media (max-width: 680px) { .card.span3 { grid-column: span 12; } }
/* Tickets grid: size cards to their content (no stretched empty space). */
#tk-main { align-items: start; }
.card h2 { .card h2 {
margin: 0 0 12px; font-size: 12px; text-transform: uppercase; letter-spacing: .8px; margin: 0 0 12px; font-size: 12px; text-transform: uppercase; letter-spacing: .8px;
color: var(--muted); font-weight: 700; display: flex; align-items: center; gap: 8px; color: var(--muted); font-weight: 700; display: flex; align-items: center; gap: 8px;
@ -180,7 +184,7 @@
.loading { opacity: .45; pointer-events: none; } .loading { opacity: .45; pointer-events: none; }
/* ── Tickets / INC dashboard map ─────────────────────────────────────── */ /* ── Tickets / INC dashboard map ─────────────────────────────────────── */
.map-wrap { position: relative; height: 540px; } .map-wrap { position: relative; height: 62vh; min-height: 520px; }
#tk-map { position: absolute; inset: 0; border-radius: 8px; overflow: hidden; } #tk-map { position: absolute; inset: 0; border-radius: 8px; overflow: hidden; }
.map-ctl { position: absolute; z-index: 5; font: 600 11px system-ui; color: #fff; user-select: none; } .map-ctl { position: absolute; z-index: 5; font: 600 11px system-ui; color: #fff; user-select: none; }
#tk-layers { right: 10px; top: 10px; } #tk-layers { right: 10px; top: 10px; }
@ -421,19 +425,24 @@
</div> </div>
</div> </div>
<div class="card span4"> <div class="card span3">
<h2>Closures — daily</h2> <h2>Closures — daily</h2>
<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 span4"> <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>
</div> </div>
<div class="card span4"> <div class="card span3">
<h2>By cluster <span class="count" id="tk-cluster-count"></span></h2> <h2>Clusters — Nairobi <span class="count" id="tk-nbo-count"></span></h2>
<div class="tbl-scroll" id="tk-cluster-wrap"><div class="empty">Loading…</div></div> <div class="tbl-scroll" id="tk-nbo-wrap"><div class="empty">Loading…</div></div>
</div>
<div class="card span3">
<h2>Clusters — Mombasa / Voi <span class="count" id="tk-coast-count"></span></h2>
<div class="tbl-scroll" id="tk-coast-wrap"><div class="empty">Loading…</div></div>
</div> </div>
</main> </main>
</section> </section>
@ -846,14 +855,15 @@ $('fuf-refresh').addEventListener('click', loadFuel);
// 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;
// INC ticket KPIs + metrics are rendered by the INC dashboard section below // INC ticket metrics render into the INC overview card (renderIncMetrics) in the
// (renderIncKpis / renderIncMetrics), driven by GET /webhook/inc-dashboard. // dashboard section below — driven by GET /webhook/inc-dashboard. The shared header
// KPI strip is intentionally left empty on the Tickets tab.
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') {
if (incData) renderIncKpis(incData.metrics); $('kpis').innerHTML = ''; // INC metrics live in the INC overview card, not the header
loadInc(); // (re)load INC data first — independent of the basemap loadInc(); // (re)load INC data first — independent of the basemap
initIncMap(); // then build the map (lazy) / just resize if built initIncMap(); // then build the map (lazy) / just resize if built
} else if (name === 'fuel') { } else if (name === 'fuel') {
@ -885,6 +895,12 @@ const EMPTY_FC = { type: 'FeatureCollection', features: [] };
const SLA_COLORS = { breached: '#ef5b5b', at_risk: '#f0a93b', ok: '#2dd4a7', unknown: '#6b7280' }; const SLA_COLORS = { breached: '#ef5b5b', at_risk: '#f0a93b', ok: '#2dd4a7', unknown: '#6b7280' };
const SLA_LABELS = { breached: 'Breached', at_risk: 'At risk', ok: 'OK', unknown: 'Unknown' }; const SLA_LABELS = { breached: 'Breached', at_risk: 'At risk', ok: 'OK', unknown: 'Unknown' };
const CLOSED_COLOR = '#94a3b8'; // muted slate — closed tickets (status irrelevant) const CLOSED_COLOR = '#94a3b8'; // muted slate — closed tickets (status irrelevant)
// Coast (Mombasa / Voi) cluster classifier — splits the cluster breakdown by name
// (the feed's region field is noisy; cluster names classify cleanly).
const COAST_HINTS = ['coast', 'mombasa', 'voi', 'nyali', 'mtwapa', 'kiembeni', 'vipingo',
'bombolulu', 'kizingo', 'kwale', 'shanzu', 'likoni', 'mariakani', 'bamburi', 'changamwe',
'diani', 'ukunda', 'malindi', 'kilifi', 'mtongwe'];
const clusterIsCoast = (name) => { const s = (name || '').toLowerCase(); return COAST_HINTS.some(k => s.includes(k)); };
const COST_CENTRE_COLORS = { const COST_CENTRE_COLORS = {
'isp': '#3b82f6', 'osp': '#E8954A', 'osp patrol': '#f97316', 'fds': '#22c55e', 'isp': '#3b82f6', 'osp': '#E8954A', 'osp patrol': '#f97316', 'fds': '#22c55e',
@ -1040,20 +1056,7 @@ function updateVehScale() {
document.getElementById('tk-map').style.setProperty('--veh-scale', (0.42 + t * 0.78).toFixed(3)); document.getElementById('tk-map').style.setProperty('--veh-scale', (0.42 + t * 0.78).toFixed(3));
} }
// ── render: header KPIs + metric strip + tables + closure chart ───────────── // ── render: metric strip + tables + closure chart ───────────────────────────
function renderIncKpis(m) {
if (activeTab() !== 'tickets') return;
m = m || {}; const so = (m.sla && m.sla.open) || {};
const k = [
['accent', intg(m.open_now), 'INC open'],
['warn', intg(so.breached), 'Breached'],
['', intg(m.closed_in_window), 'Closed (win)'],
['live', mttrFmt(m.avg_mttr_min), 'Avg MTTR'],
];
$('kpis').innerHTML = k.map(([c, v, l]) =>
`<div class="kpi"><b class="${c}">${v}</b><span>${l}</span></div>`).join('');
}
function renderIncMetrics(m, freshness) { function renderIncMetrics(m, freshness) {
m = m || {}; m = m || {};
const so = (m.sla && m.sla.open) || {}, sc = (m.sla && m.sla.closed) || {}, cr = m.closure_rate || {}; const so = (m.sla && m.sla.open) || {}, sc = (m.sla && m.sla.closed) || {}, cr = m.closure_rate || {};
@ -1079,9 +1082,14 @@ function incTable(obj) {
} }
function renderIncTables(m) { function renderIncTables(m) {
m = m || {}; m = m || {};
const s = incTable(m.by_status), c = incTable(m.by_cluster); const s = incTable(m.by_status);
$('tk-status-wrap').innerHTML = s.html; $('tk-status-count').textContent = s.n ? `(${s.n})` : ''; $('tk-status-wrap').innerHTML = s.html; $('tk-status-count').textContent = s.n ? `(${s.n})` : '';
$('tk-cluster-wrap').innerHTML = c.html; $('tk-cluster-count').textContent = c.n ? `(${c.n})` : ''; // Split the cluster breakdown into Nairobi vs Mombasa/Voi (Coast) by cluster name.
const nbo = {}, coast = {};
for (const [name, n] of Object.entries(m.by_cluster || {})) (clusterIsCoast(name) ? coast : nbo)[name] = n;
const cn = incTable(nbo), cc = incTable(coast);
$('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})` : '';
} }
function renderClosureChart(cr) { function renderClosureChart(cr) {
@ -1122,7 +1130,6 @@ async function loadInc() {
const j = await api(`/webhook/inc-dashboard?${incQs()}`); const j = await api(`/webhook/inc-dashboard?${incQs()}`);
incData = j; incData = j;
if (!incDropdownsInit && j.metrics) { initIncDropdowns(j.metrics); incDropdownsInit = true; } if (!incDropdownsInit && j.metrics) { initIncDropdowns(j.metrics); incDropdownsInit = true; }
renderIncKpis(j.metrics);
renderIncMetrics(j.metrics, j.freshness); renderIncMetrics(j.metrics, j.freshness);
renderIncTables(j.metrics); renderIncTables(j.metrics);
renderClosureChart(j.metrics && j.metrics.closure_rate); renderClosureChart(j.metrics && j.metrics.closure_rate);