PotholeIQ / command-center.html
Varun10000's picture
Ship Philly ward-boundary grid: commit geojson, visible dashed overlay + ward tooltips
2508db6
Raw
History Blame Contribute Delete
30.5 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PotholeIQ — Command Center</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
:root{
/* Box palette — white + blue only */
--bg:#f4f8fd; --panel:#ffffff; --panel2:#eef4fc; --line:#dbe6f5; --ink:#0b2545; --muted:#5b6b85;
--blue:#0061d5; --blue-deep:#003a85; --blue-mid:#3b86e6; --blue-soft:#8fbef0; --blue-pale:#c9e0f8;
/* severity = blue intensity scale (deep=critical … pale=low) */
--crit:#003a85; --high:#0061d5; --med:#4a93f0; --low:#a9cdf6;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',Inter,system-ui,Arial,sans-serif;background:var(--bg);color:var(--ink);padding:22px 26px 60px}
.top{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:20px}
.brand{display:flex;align-items:center;gap:12px}
.logo{width:40px;height:40px;border-radius:11px;background:var(--blue);display:flex;align-items:center;justify-content:center;font-size:20px;color:#fff}
h1{font-size:21px;font-weight:800;letter-spacing:.2px;color:var(--blue-deep)}
.sub{font-size:12.5px;color:var(--muted)}
.live{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--blue-deep);background:var(--panel2);border:1px solid var(--line);padding:5px 11px;border-radius:20px}
.live i{width:8px;height:8px;border-radius:50%;background:var(--blue);animation:p 1.6s infinite}
@keyframes p{0%{box-shadow:0 0 0 0 rgba(0,97,213,.5)}70%{box-shadow:0 0 0 7px rgba(0,97,213,0)}100%{box-shadow:0 0 0 0 rgba(0,97,213,0)}}
button{font:inherit;cursor:pointer;border:1px solid var(--line);background:var(--panel);color:var(--blue);padding:7px 14px;border-radius:9px;font-weight:600}
button:hover{border-color:var(--blue);background:var(--panel2)}
.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:14px;margin-bottom:18px}
.kpi{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:15px 16px;position:relative;overflow:hidden;box-shadow:0 1px 3px rgba(11,37,69,.04)}
.kpi .v{font-size:30px;font-weight:800;line-height:1;color:var(--blue-deep)}
.kpi .l{font-size:12px;color:var(--muted);margin-top:6px}
.kpi .bar{position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--blue)}
.grid2{display:grid;grid-template-columns:1.55fr 1fr;gap:16px;margin-bottom:18px}
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:18px}
.grid3 canvas{max-height:210px}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:16px;box-shadow:0 1px 3px rgba(11,37,69,.04)}
.panel h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:12px}
#map{height:380px;border-radius:12px;overflow:hidden;border:1px solid var(--line)}
.leaflet-popup-content{font-family:inherit}
.donut-wrap{display:flex;align-items:center;gap:18px}
.donut{width:150px;height:150px;border-radius:50%;flex:none}
.legend{display:flex;flex-direction:column;gap:8px;font-size:13px}
.legend .row{display:flex;align-items:center;gap:9px}
.legend .sw{width:12px;height:12px;border-radius:3px}
.pipe{margin-top:18px;display:flex;flex-direction:column;gap:9px}
.pipe .seg{display:flex;align-items:center;gap:10px;font-size:12.5px}
.pipe .track{flex:1;height:9px;background:var(--panel2);border-radius:6px;overflow:hidden}
.pipe .fill{height:100%;border-radius:6px;background:var(--blue)}
.pipe .n{width:26px;text-align:right;color:var(--muted)}
/* filter bar */
.cards-h{display:flex;align-items:center;justify-content:space-between;margin:8px 2px 12px;flex-wrap:wrap;gap:10px}
.cards-h h2{color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.08em}
.filters{display:flex;flex-wrap:wrap;gap:8px;align-items:center;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px 12px;margin-bottom:14px}
.fgroup{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.fgroup .glab{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-right:2px}
.chip{font-size:12px;padding:5px 11px;border-radius:20px;border:1px solid var(--line);background:var(--panel);color:var(--ink);cursor:pointer}
.chip:hover{border-color:var(--blue-soft)}
.chip.active{background:var(--blue);color:#fff;border-color:var(--blue)}
select,input[type=search]{font:inherit;font-size:12.5px;padding:6px 10px;border:1px solid var(--line);border-radius:9px;background:var(--panel);color:var(--ink)}
input[type=search]{min-width:180px}
.fsep{width:1px;align-self:stretch;background:var(--line);margin:0 2px}
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:16px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 1px 3px rgba(11,37,69,.05)}
.card .photo{height:160px;background:var(--panel2);position:relative;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:13px}
.card .photo img{width:100%;height:100%;object-fit:cover;display:block}
.sev-badge{position:absolute;top:10px;left:10px;font-size:11px;font-weight:800;padding:4px 10px;border-radius:20px;color:#fff;text-transform:uppercase;letter-spacing:.04em}
.score-chip{position:absolute;top:10px;right:10px;font-size:13px;font-weight:800;background:rgba(0,58,133,.82);padding:4px 10px;border-radius:20px;color:#fff}
.card .photo[onclick]{cursor:pointer}
.open-box{position:absolute;bottom:10px;right:10px;background:rgba(11,37,69,.85);color:#fff;font-size:10.5px;font-weight:700;padding:4px 9px;border-radius:7px;opacity:0;transition:opacity .15s;pointer-events:none}
.card .photo:hover .open-box{opacity:1}
.card .body{padding:14px 15px;display:flex;flex-direction:column;gap:10px}
.card .cid{font-weight:800;font-size:15px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:var(--blue-deep)}
.card .addr{font-size:12.5px;color:var(--muted)}
.tag{font-size:10.5px;padding:2px 8px;border-radius:6px;border:1px solid var(--line);color:var(--muted);background:var(--panel2)}
.tag.dup{color:var(--blue-deep);border-color:var(--blue-soft);background:var(--blue-pale)}
.tag.res{color:#fff;border-color:var(--blue);background:var(--blue)}
.bd{display:flex;flex-direction:column;gap:7px;margin-top:2px}
.bd .f{display:grid;grid-template-columns:78px 1fr 38px;align-items:center;gap:8px;font-size:11.5px}
.bd .f .lab{color:var(--muted)}
.bd .f .track{height:8px;background:var(--panel2);border-radius:5px;overflow:hidden}
.bd .f .fill{height:100%;border-radius:5px}
.bd .f .val{text-align:right;font-variant-numeric:tabular-nums;color:var(--ink)}
/* breakdown factors in blue shades */
.bd .f .fill.pn{background:var(--blue-deep)}.bd .f .fill.tr{background:var(--blue)}.bd .f .fill.dm{background:var(--blue-mid)}.bd .f .fill.we{background:var(--blue-soft)}
.summary{font-size:12px;color:var(--muted);border-top:1px solid var(--line);padding-top:9px;line-height:1.5}
.bd .f2{display:grid;grid-template-columns:92px 1fr;gap:10px;font-size:11.5px;align-items:baseline}
.bd .f2 .lab{color:var(--muted);font-weight:600}
.bd .f2 .dv{color:var(--ink);line-height:1.45}
.acts{display:flex;gap:6px;margin-top:11px;padding-top:11px;border-top:1px solid var(--line)}
.acts .act{flex:1;padding:8px 6px;border:none;border-radius:8px;font-size:11.5px;font-weight:700;cursor:pointer;transition:filter .15s}
.acts .act:hover{filter:brightness(.93)}
.acts .act:disabled{opacity:.55;cursor:default}
.acts .approve{background:var(--blue);color:#fff}
.acts .resolve{background:#16a366;color:#fff}
.acts .reject{background:#fff;color:#9b1c1c;border:1px solid #f0c5c5}
.acts .ghost{background:var(--panel2);color:var(--muted)}
.disp{display:flex;gap:6px;margin-top:8px}
.disp .crew-in{flex:1;min-width:0;padding:7px 9px;border:1px solid var(--line);border-radius:7px;font-size:11.5px;background:var(--bg);color:var(--ink)}
.disp .dbtn{padding:7px 11px;border:none;border-radius:7px;background:#0b3d91;color:#fff;font-size:11.5px;font-weight:700;cursor:pointer;white-space:nowrap}
.disp .dbtn:hover{filter:brightness(.92)}
.disp .dbtn:disabled{opacity:.55;cursor:default}
.nb-btn{margin-top:8px;padding:6px 10px;border:1px solid var(--line);background:var(--panel2);color:var(--blue-deep);font-size:11.5px;font-weight:700;border-radius:7px;cursor:pointer;width:100%}
.nb-btn:hover{border-color:var(--blue);background:#fff}
.modal{display:none;position:fixed;inset:0;background:rgba(11,37,69,.45);z-index:1000;align-items:center;justify-content:center;padding:20px}
.modal-box{background:var(--panel);border-radius:14px;max-width:540px;width:100%;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.3)}
.modal-head{display:flex;justify-content:space-between;align-items:center;padding:15px 18px;border-bottom:1px solid var(--line);font-weight:800;color:var(--ink);font-size:13.5px}
.modal-x{border:none;background:var(--panel2);width:30px;height:30px;border-radius:8px;cursor:pointer;color:var(--muted);font-size:14px}
.nb-list{padding:6px 14px 12px;overflow:auto}
.nb-row{display:flex;align-items:center;gap:12px;padding:10px 4px;border-bottom:1px solid var(--line)}
.nb-row:last-child{border-bottom:none}
.nb-dist{min-width:70px;font-weight:800;font-variant-numeric:tabular-nums;color:var(--ink);font-size:13px}
.nb-sev{font-size:10px;font-weight:800;color:#fff;padding:3px 8px;border-radius:12px;text-transform:uppercase;white-space:nowrap}
.nb-info{font-size:12px;color:var(--ink);line-height:1.4}
.nb-info .muted{color:var(--muted)}
.nb-dup{color:#9b1c1c;font-weight:800;font-size:10.5px}
.empty{text-align:center;color:var(--muted);padding:40px;border:1px dashed var(--line);border-radius:14px;grid-column:1/-1;background:var(--panel)}
@media(max-width:1000px){.kpis{grid-template-columns:repeat(2,1fr)}.grid2{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="top">
<div class="brand">
<div class="logo">🛣️</div>
<div>
<h1>PotholeIQ — Command Center</h1>
<div class="sub">Operations view · Philadelphia</div>
</div>
</div>
<div style="display:flex;gap:10px;align-items:center">
<span class="sub" id="who"></span>
<button onclick="load()">⟳ Refresh</button>
<button onclick="logout()">Sign out</button>
</div>
</div>
<div class="kpis" id="kpis"></div>
<div class="grid2">
<div class="panel">
<h2>Pothole Map — severity by location</h2>
<div id="map"></div>
</div>
<div class="panel">
<h2>Severity Distribution</h2>
<div class="donut-wrap"><div class="donut" id="donut"></div><div class="legend" id="legend"></div></div>
<div style="margin-top:18px"><h2>Status Pipeline</h2><div class="pipe" id="pipe"></div></div>
</div>
</div>
<div class="grid3">
<div class="panel"><h2>Cases by Ward</h2><canvas id="chWard"></canvas></div>
<div class="panel"><h2>Pothole Size</h2><canvas id="chSize"></canvas></div>
<div class="panel"><h2>Submissions Over Time</h2><canvas id="chTrend"></canvas></div>
</div>
<div class="cards-h">
<h2>Active Cases — severity breakdown</h2>
<span class="sub" id="count"></span>
</div>
<div class="filters" id="filters">
<div class="fgroup"><span class="glab">Severity</span><span id="sevChips"></span></div>
<div class="fsep"></div>
<div class="fgroup"><span class="glab">Status</span><span id="statChips"></span></div>
<div class="fsep"></div>
<div class="fgroup"><span class="glab">Ward</span><select id="wardSel"><option value="all">All</option></select></div>
<div class="fsep"></div>
<div class="fgroup"><input type="search" id="search" placeholder="Search case ID or address…" /></div>
</div>
<div class="cards" id="cards"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<div id="nearby-modal" class="modal" onclick="if(event.target===this)closeNearby()">
<div class="modal-box">
<div class="modal-head"><span id="nearby-title">Nearby potholes</span><button class="modal-x" onclick="closeNearby()"></button></div>
<div id="nearby-list" class="nb-list"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script>
// ── Supervisor auth gate ──────────────────────────────────────────────────
const SUPABASE_URL = 'https://knvoctdesiwogofcyced.supabase.co';
const SUPABASE_PUBLISHABLE = 'sb_publishable_pCMhOVA6YYg2948wi6ValQ_hhhC-xfK';
const sbAuth = supabase.createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE);
let ACCESS_TOKEN = '';
const REQUIRE_LOGIN = true; // supervisor login required (set false to open the dashboard)
async function logout(){ await sbAuth.auth.signOut(); location.href = 'login.html'; }
async function requireSupervisor(){
const { data } = await sbAuth.auth.getSession();
const s = data.session;
if (s && s.user?.app_metadata?.role === 'supervisor') {
ACCESS_TOKEN = s.access_token;
const who = document.getElementById('who'); if (who) who.textContent = s.user.email;
return true;
}
if (!REQUIRE_LOGIN) return true; // login disabled — load anyway
location.href = 'login.html'; return false; // login required — bounce to login
}
const authHeaders = () => ({ Authorization: `Bearer ${ACCESS_TOKEN}` });
</script>
<script>
const SEV = { critical:'#003a85', high:'#0061d5', medium:'#4a93f0', low:'#a9cdf6' };
const sevColor = s => SEV[(s||'').toLowerCase()] || '#8fbef0';
let map, caseLayer, allCases=[];
const filt = { sev:'all', status:'all', ward:'all', q:'' };
function kpi(v,l){return `<div class="kpi"><div class="bar"></div><div class="v">${v}</div><div class="l">${l}</div></div>`;}
function renderKpis(cs){
const open=cs.filter(c=>!['resolved','rejected'].includes((c.status||'').toLowerCase())).length;
const crit=cs.filter(c=>(c.severityLevel||'').toLowerCase()==='critical').length;
const res=cs.filter(c=>(c.status||'').toLowerCase()==='resolved').length;
const avg=cs.length?Math.round(cs.reduce((a,c)=>a+(c.severityScore||0),0)/cs.length):0;
document.getElementById('kpis').innerHTML=kpi(cs.length,'Total Cases')+kpi(open,'Open')+kpi(crit,'Critical')+kpi(res,'Resolved')+kpi(avg,'Avg Severity');
}
function renderDonut(cs){
const order=['critical','high','medium','low'];
const counts=order.map(k=>cs.filter(c=>(c.severityLevel||'').toLowerCase()===k).length);
const total=counts.reduce((a,b)=>a+b,0)||1; let acc=0,stops=[];
order.forEach((k,i)=>{const fr=counts[i]/total,a=acc*360,b=(acc+fr)*360;if(counts[i])stops.push(`${SEV[k]} ${a}deg ${b}deg`);acc+=fr;});
const dn=document.getElementById('donut');
dn.style.background=`conic-gradient(${stops.join(',')||'#dbe6f5 0 360deg'})`; dn.style.position='relative';
dn.innerHTML=`<div style="position:absolute;inset:26px;background:#fff;border-radius:50%;display:flex;flex-direction:column;align-items:center;justify-content:center"><div style="font-size:26px;font-weight:800;color:var(--blue-deep)">${total}</div><div style="font-size:10px;color:var(--muted)">cases</div></div>`;
document.getElementById('legend').innerHTML=order.map((k,i)=>`<div class="row"><span class="sw" style="background:${SEV[k]}"></span><span style="text-transform:capitalize">${k}</span><b style="margin-left:auto">${counts[i]}</b></div>`).join('');
}
function renderPipe(cs){
const stages=[['under review','Under Review'],['assigned','Assigned'],['In progress','In Progress'],['resolved','Resolved']];
const max=cs.length||1;
document.getElementById('pipe').innerHTML=stages.map(([k,lab])=>{const n=cs.filter(c=>(c.status||'').toLowerCase()===k.toLowerCase()).length;return `<div class="seg"><span style="width:92px;color:var(--muted)">${lab}</span><div class="track"><div class="fill" style="width:${n/max*100}%"></div></div><span class="n">${n}</span></div>`;}).join('');
}
function bar(lab,cls,o){const s=o.score??0,m=o.max||1;return `<div class="f"><span class="lab">${lab}</span><div class="track"><div class="fill ${cls}" style="width:${Math.min(100,s/m*100)}%"></div></div><span class="val">${s}/${m}</span></div>`;}
// Estimated municipal repair cost by pothole size (US 2026: cold patch ~$90–180, hot-mix ~$250–550, full-depth up to ~$800).
function estCost(size){const s=(size||'').toLowerCase();
if(s.includes('large')) return '$450–$800 · hot-mix, full-depth';
if(s.includes('medium')) return '$250–$450 · hot-mix patch';
if(s.includes('small')) return '$90–$180 · cold patch';
return '—';}
// Approve / Resolve / Reject / Reopen — writes the status to Box, then re-renders.
async function setStatus(caseId,status,btn){
const lbl=btn?btn.textContent:''; if(btn){btn.disabled=true;btn.textContent='…';}
try{
const r=await fetch('/api/box/set-status',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authHeaders()),body:JSON.stringify({caseId,status})}).then(x=>x.json());
if(!r.ok) throw new Error(r.error||r.reason||'update failed');
load();
}catch(e){ alert('Could not update '+caseId+': '+e.message); if(btn){btn.disabled=false;btn.textContent=lbl;} }
}
function card(c){
const sev=(c.severityLevel||'—'),col=sevColor(sev);
const img=c.photoFileId?`<img src="/api/box/files/${c.photoFileId}/content?access_token=${encodeURIComponent(ACCESS_TOKEN)}" onerror="this.style.display='none';this.parentElement.insertAdjacentHTML('beforeend','<span>📷 photo in Box</span>')"/>`:'<span>📷 no photo</span>';
const dup=(c.duplicateStatus||'').trim()==='duplicate'?`<span class="tag dup">⚠ duplicate ×${c.duplicateCount}</span>`:'';
const res=(c.status||'').toLowerCase()==='resolved'?`<span class="tag res">✓ resolved</span>`:`<span class="tag">${c.status||'—'}</span>`;
// Not-a-pothole flags: confirmed (supervisor rejected / AI manual_rejected) vs
// AI-suspected (aiSummary carries the AI's invalid-image verdict).
const rejected=(c.aiReviewStatus||'')==='manual_rejected'||(c.status||'').toLowerCase()==='rejected';
const aiSaysNot=/not appear to show|not a (road.?surface )?pothole/i.test(c.aiSummary||'');
const notPothole=rejected?`<span class="tag" style="color:#9b1c1c;border-color:#f0c5c5;background:#fdf0ef;font-weight:800">✕ Not a pothole</span>`
:(aiSaysNot?`<span class="tag" style="color:#92500c;border-color:#f0dbb0;background:#fdf6e7;font-weight:800">⚠ AI: possibly not a pothole</span>`:'');
const b=c.breakdown||{};
return `<div class="card"><div class="photo" ${c.folderId?`onclick="openBox('${c.folderId}')" title="Open case folder in Box"`:''}><span class="sev-badge" style="background:${col}">${sev}</span><span class="score-chip">${c.severityScore??'—'}/100</span>${img}${c.folderId?'<span class="open-box">Open in Box ↗</span>':''}</div>
<div class="body"><div class="cid">${c.caseId} ${dup} ${res} ${notPothole}</div><div class="addr">📍 Ward ${c.ward} · ${c.address||'—'}</div>
<div class="addr" style="margin-top:3px">🗓 Reported ${c.submittedAt?new Date(c.submittedAt).toLocaleDateString():'—'}</div>
<button class="nb-btn" onclick="showNearby('${c.caseId}')">📍 See nearby potholes</button>
<div class="bd">
<div class="f2"><span class="lab">Prior detection</span><span class="dv">${(b.priorNotice&&b.priorNotice.detail)||'—'}</span></div>
<div class="f2"><span class="lab">Traffic / road</span><span class="dv">${(b.traffic&&b.traffic.detail)||'—'}</span></div>
<div class="f2"><span class="lab">Weather risk</span><span class="dv">${(b.weather&&b.weather.detail)||'—'}</span></div>
<div class="f2"><span class="lab">Pothole size</span><span class="dv">${c.potholeSize||'—'}</span></div>
<div class="f2"><span class="lab">Est. repair cost</span><span class="dv">${estCost(c.potholeSize)}</span></div>
</div>
<div class="acts">${['resolved','rejected'].includes((c.status||'').toLowerCase())
? `<button class="act ghost" onclick="setStatus('${c.caseId}','under review',this)">↺ Reopen</button>`
: `<button class="act approve" onclick="setStatus('${c.caseId}','assigned',this)">✓ Approve</button><button class="act resolve" onclick="setStatus('${c.caseId}','resolved',this)">✓ Resolve</button><button class="act reject" onclick="setStatus('${c.caseId}','rejected',this)">✕ Reject</button>`}</div>
<div class="disp"><input type="email" class="crew-in" id="crew-${c.caseId}" placeholder="crew@email.com" /><button class="dbtn" onclick="dispatchCrew('${c.caseId}',this)">Send to crew</button></div>
<a class="nb-btn" style="margin-top:8px;display:block;text-align:center;text-decoration:none" href="crew-closeout.html?case=${encodeURIComponent(c.caseId)}" target="_blank">Open Crew Closeout Form</a>
</div></div>`;
}
function renderMap(cs,boundaries){
if(!map){
map=L.map('map',{zoomControl:true}).setView([39.99,-75.13],11);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',{attribution:'© OpenStreetMap © CARTO',maxZoom:19}).addTo(map);
if(boundaries&&boundaries.features&&boundaries.features.length){
L.geoJSON(boundaries,{style:{color:'#0061d5',weight:1.4,opacity:.75,fillColor:'#0061d5',fillOpacity:.05,dashArray:'4 3'},
onEachFeature:(f,layer)=>{const p=f.properties||{};const label=p.ward_num||p.WARD_NUM||p.ward||p.WARD||p.name||'';if(label)layer.bindTooltip('Ward '+label,{sticky:true,direction:'top',opacity:.85});}
}).addTo(map);
}
}
if(caseLayer)caseLayer.remove(); caseLayer=L.layerGroup().addTo(map);
cs.forEach(c=>{if(c.latitude&&c.longitude){L.circleMarker([c.latitude,c.longitude],{radius:9,color:'#fff',weight:2,fillColor:sevColor(c.severityLevel),fillOpacity:.95}).bindPopup(`<b>${c.caseId}</b><br>${c.severityLevel} · ${c.severityScore}/100<br>Ward ${c.ward}`).addTo(caseLayer);}});
}
// ---------- filtering ----------
function applyFilters(){
const f=allCases.filter(c=>{
if(filt.sev!=='all' && (c.severityLevel||'').toLowerCase()!==filt.sev) return false;
if(filt.status!=='all' && (c.status||'').toLowerCase()!==filt.status.toLowerCase()) return false;
if(filt.ward!=='all' && String(c.ward)!==filt.ward) return false;
if(filt.q){const q=filt.q.toLowerCase();if(!(`${c.caseId} ${c.address}`.toLowerCase().includes(q)))return false;}
return true;
});
document.getElementById('count').textContent=`${f.length} of ${allCases.length} cases`;
document.getElementById('cards').innerHTML=f.length?f.sort((a,b)=>(b.severityScore||0)-(a.severityScore||0)).map(card).join(''):'<div class="empty">No cases match these filters.</div>';
renderMap(f);
}
function buildChips(){
const sevs=[['all','All'],['critical','Critical'],['high','High'],['medium','Medium'],['low','Low']];
document.getElementById('sevChips').innerHTML=sevs.map(([v,l])=>`<span class="chip ${filt.sev===v?'active':''}" data-k="sev" data-v="${v}">${l}</span>`).join('');
const stats=[['all','All'],['under review','Review'],['assigned','Assigned'],['In progress','In Progress'],['resolved','Resolved'],['rejected','Rejected']];
document.getElementById('statChips').innerHTML=stats.map(([v,l])=>`<span class="chip ${filt.status===v?'active':''}" data-k="status" data-v="${v}">${l}</span>`).join('');
document.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>{filt[ch.dataset.k]=ch.dataset.v;buildChips();applyFilters();});
const wards=[...new Set(allCases.map(c=>String(c.ward)))].sort((a,b)=>(parseInt(a)||999)-(parseInt(b)||999));
document.getElementById('wardSel').innerHTML='<option value="all">All</option>'+wards.map(w=>`<option value="${w}" ${filt.ward===w?'selected':''}>Ward ${w}</option>`).join('');
document.getElementById('wardSel').onchange=e=>{filt.ward=e.target.value;applyFilters();};
document.getElementById('search').oninput=e=>{filt.q=e.target.value;applyFilters();};
}
// Click a case photo -> open that case's folder in Box.
function openBox(folderId){ if(folderId) window.open('https://sednademo.app.box.com/folder/'+folderId,'_blank','noopener'); }
// Nearby potholes: closest cases by GPS distance (flags any within the 80 m duplicate range).
function haversineM(la1,lo1,la2,lo2){const R=6371000,r=x=>x*Math.PI/180;const dla=r(la2-la1),dlo=r(lo2-lo1);const a=Math.sin(dla/2)**2+Math.cos(r(la1))*Math.cos(r(la2))*Math.sin(dlo/2)**2;return 2*R*Math.asin(Math.sqrt(a));}
function showNearby(caseId){
const c=allCases.find(x=>x.caseId===caseId);
if(!c||c.latitude==null||c.longitude==null){alert('No GPS location recorded for '+caseId+'.');return;}
const fmt=m=>m>=1000?(m/1000).toFixed(2)+' km':m+' m';
const near=allCases.filter(x=>x.caseId!==caseId&&x.latitude!=null&&x.longitude!=null)
.map(x=>({c:x,dist:Math.round(haversineM(c.latitude,c.longitude,x.latitude,x.longitude))}))
.sort((a,b)=>a.dist-b.dist).slice(0,12);
document.getElementById('nearby-title').textContent='Potholes near '+caseId+' — '+near.length+' closest';
document.getElementById('nearby-list').innerHTML = near.length
? near.map(n=>`<div class="nb-row"><span class="nb-dist">${fmt(n.dist)}${n.dist<=80?'<br><span class="nb-dup">⚠ duplicate</span>':''}</span><span class="nb-sev" style="background:${sevColor(n.c.severityLevel)}">${n.c.severityLevel||'—'}</span><div class="nb-info"><b>${n.c.caseId}</b> · ${n.c.status||'—'} · ${n.c.potholeSize||'—'}<br><span class="muted">📍 Ward ${n.c.ward} · ${n.c.address||'—'}</span></div></div>`).join('')
: '<div class="nb-info muted" style="padding:14px">No other potholes with a location on record.</div>';
document.getElementById('nearby-modal').style.display='flex';
}
function closeNearby(){document.getElementById('nearby-modal').style.display='none';}
// The Box Form (closeout) the crew fills after repair -> filling it triggers WF2.
const CLOSEOUT_FORM_LINK='https://sednademo.app.box.com/f/bb3c86290ee94d7b8263bb9a33718e20';
// Enter a crew email -> generate the Work Order, then draft an email to the crew + supervisor
// with the Work Order link AND the closeout form link.
async function dispatchCrew(caseId,btn){
const inp=document.getElementById('crew-'+caseId);
const email=inp?inp.value.trim():'';
if(!email||email.indexOf('@')<1){ alert('Enter a valid crew email.'); if(inp)inp.focus(); return; }
const c=allCases.find(x=>x.caseId===caseId)||{caseId:caseId};
const meta={caseId:c.caseId,address:c.address,ward:c.ward,district:c.district,maintenanceZone:c.maintenanceZone,
latitude:c.latitude,longitude:c.longitude,severityScore:c.severityScore,severityScore0100:c.severityScore,
severityLevel:c.severityLevel,potholeSize:c.potholeSize,weatherRisk:c.weatherRisk,aiReviewStatus:c.aiReviewStatus,
reporterContact:c.reporterContact,photoFileId:c.photoFileId,submittedAt:c.submittedAt,assignedCrew:email};
const lbl=btn?btn.textContent:''; if(btn){btn.disabled=true;btn.textContent='Sending…';}
try{
// Box Sign: backend (service account) emails the crew a "review & sign" work order — no mailto, no personal info
const r=await fetch('/api/box/dispatch-crew',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authHeaders()),body:JSON.stringify({caseId,crewEmail:email,metadata:meta})}).then(x=>x.json());
if(!r.ok) throw new Error(r.error||r.reason||'dispatch failed');
if(btn){btn.textContent='✓ Sent via Box Sign';}
if(inp){inp.value='';inp.placeholder='Sent to '+email;}
}catch(e){ alert('Could not send to crew ('+caseId+'): '+e.message); if(btn){btn.disabled=false;btn.textContent=lbl;} }
}
let _charts={};
function renderCharts(cs){
if(typeof Chart==='undefined') return;
const BLUE='#0061d5';
const ward={}; cs.forEach(c=>{const w='Ward '+(c.ward||'?'); ward[w]=(ward[w]||0)+1;});
const we=Object.entries(ward).sort((a,b)=>b[1]-a[1]).slice(0,8);
const size={small:0,medium:0,large:0}; cs.forEach(c=>{const s=(c.potholeSize||'').toLowerCase(); if(s in size) size[s]++;});
const day={}; cs.forEach(c=>{if(c.submittedAt){const d=new Date(c.submittedAt); if(!isNaN(d)){const k=d.toISOString().slice(0,10); day[k]=(day[k]||0)+1;}}});
const days=Object.keys(day).sort();
const mk=(id,cfg)=>{ if(_charts[id])_charts[id].destroy(); const el=document.getElementById(id); if(el)_charts[id]=new Chart(el,cfg); };
const barOpts={responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{precision:0}}}};
mk('chWard',{type:'bar',data:{labels:we.map(e=>e[0]),datasets:[{data:we.map(e=>e[1]),backgroundColor:BLUE,borderRadius:5}]},options:barOpts});
mk('chSize',{type:'doughnut',data:{labels:['Small','Medium','Large'],datasets:[{data:[size.small,size.medium,size.large],backgroundColor:['#8bb8f0','#3b86e8','#0b3d91']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom'}}}});
mk('chTrend',{type:'line',data:{labels:days,datasets:[{data:days.map(d=>day[d]),borderColor:BLUE,backgroundColor:'rgba(0,97,213,.12)',fill:true,tension:.3,pointRadius:2}]},options:barOpts});
}
async function load(){
// keep the supervisor session token fresh so the auto-refresh fetch doesn't 401 (and flicker to 0)
try { const { data } = await sbAuth.auth.getSession(); if (data && data.session) ACCESS_TOKEN = data.session.access_token; } catch(_){}
const [casesRes,bRes]=await Promise.all([
fetch('/api/box/cases',{headers:authHeaders()}).then(r=>r.ok?r.json():null).catch(()=>null),
fetch('/api/gis/boundaries').then(r=>r.json()).catch(()=>null),
]);
const cases = casesRes && Array.isArray(casesRes.cases) ? casesRes.cases : null;
// update on a good fetch; on a failed/empty refresh keep what we already have (don't blank the dashboard)
if (cases && (cases.length || !allCases.length)) allCases = cases;
renderKpis(allCases); renderDonut(allCases); renderPipe(allCases); renderMap(allCases,bRes);
renderCharts(allCases);
buildChips(); applyFilters();
}
(async()=>{ if (await requireSupervisor()){ load(); setInterval(load, 30000); } })(); // auto-refresh every 30s
</script>
</body>
</html>