Spaces:
Sleeping
Sleeping
| <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> | |