Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Before / After Report β PotholeIQ</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" /> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --ink-950:#0c1626; --ink-700:#344256; --ink-500:#64748b; --ink-400:#8a97a8; | |
| --border:#e2e8f0; --brand:#0061d5; --mint:#0e9f6e; --amber:#b45309; --red:#dc2626; | |
| } | |
| body { font-family:'Inter',system-ui,sans-serif; background:#eef2f7; color:var(--ink-950); -webkit-font-smoothing:antialiased; } | |
| /* Toolbar (not printed) */ | |
| .toolbar { | |
| position:sticky; top:0; z-index:10; | |
| display:flex; align-items:center; justify-content:space-between; gap:16px; | |
| padding:14px 24px; background:rgba(12,22,38,0.96); color:#fff; | |
| backdrop-filter:blur(10px); | |
| } | |
| .toolbar__brand { display:flex; align-items:center; gap:10px; font-weight:800; font-size:14px; } | |
| .toolbar__brand-icon { width:30px; height:30px; border-radius:8px; background:linear-gradient(135deg,#0b66d6,#083f8c); display:flex; align-items:center; justify-content:center; font-weight:900; } | |
| .toolbar__actions { display:flex; gap:10px; } | |
| .tbtn { padding:9px 16px; border-radius:9px; font:inherit; font-size:13px; font-weight:700; cursor:pointer; border:1px solid rgba(255,255,255,0.18); background:rgba(255,255,255,0.08); color:#fff; text-decoration:none; transition:background .15s ease; } | |
| .tbtn:hover { background:rgba(255,255,255,0.16); } | |
| .tbtn.primary { background:linear-gradient(135deg,#1273e6,#0a55b4); border-color:transparent; } | |
| .page { max-width:920px; margin:24px auto 60px; padding:0 20px; } | |
| .sheet { background:#fff; border:1px solid var(--border); border-radius:16px; box-shadow:0 20px 50px rgba(15,23,42,0.10); overflow:hidden; } | |
| .rhead { padding:28px 32px; border-bottom:1px solid var(--border); background:linear-gradient(135deg,#f8fbff,#eef4fb); } | |
| .rhead__eyebrow { font-size:11px; font-weight:800; letter-spacing:0.12em; text-transform:uppercase; color:var(--brand); margin-bottom:6px; } | |
| .rhead__title { font-size:26px; font-weight:900; letter-spacing:-0.03em; line-height:1.1; } | |
| .rhead__meta { margin-top:8px; font-size:13px; color:var(--ink-500); display:flex; flex-wrap:wrap; gap:14px; } | |
| .rhead__meta strong { color:var(--ink-700); } | |
| .status-pill { display:inline-flex; align-items:center; gap:6px; padding:5px 12px; border-radius:999px; font-size:12px; font-weight:800; } | |
| .summary { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; background:var(--border); border-bottom:1px solid var(--border); } | |
| .summary__cell { background:#fff; padding:16px 20px; } | |
| .summary__label { font-size:10px; font-weight:800; text-transform:uppercase; letter-spacing:0.08em; color:var(--ink-400); margin-bottom:5px; } | |
| .summary__value { font-size:20px; font-weight:800; letter-spacing:-0.02em; } | |
| .section { padding:24px 32px; border-bottom:1px solid var(--border); } | |
| .section__label { font-size:11px; font-weight:800; text-transform:uppercase; letter-spacing:0.1em; color:var(--ink-400); margin-bottom:14px; display:flex; align-items:center; gap:10px; } | |
| .section__label::after { content:''; flex:1; height:1px; background:var(--border); } | |
| .ba-grid { display:grid; grid-template-columns:1fr 1fr; gap:18px; } | |
| .ba-col__tag { font-size:11px; font-weight:800; text-transform:uppercase; letter-spacing:0.08em; margin-bottom:8px; } | |
| .ba-col__tag.before { color:var(--amber); } | |
| .ba-col__tag.after { color:var(--mint); } | |
| .ba-photo { width:100%; height:230px; border-radius:12px; object-fit:cover; border:1px solid var(--border); background:#f1f5f9; display:block; } | |
| .ba-photo__ph { width:100%; height:230px; border-radius:12px; border:1.5px dashed var(--border); background:linear-gradient(135deg,#f8fafc,#eef2f7); display:flex; align-items:center; justify-content:center; text-align:center; color:var(--ink-400); font-size:12px; font-weight:600; padding:16px; } | |
| .kv { display:grid; grid-template-columns:1fr 1fr; gap:10px; } | |
| .kv__item { border:1px solid var(--border); border-radius:11px; padding:11px 13px; background:#fbfdff; } | |
| .kv__label { font-size:10px; font-weight:800; text-transform:uppercase; letter-spacing:0.07em; color:var(--ink-400); margin-bottom:4px; } | |
| .kv__value { font-size:13px; font-weight:700; color:var(--ink-950); line-height:1.4; word-break:break-word; } | |
| .cost-table { width:100%; border-collapse:collapse; font-size:13px; } | |
| .cost-table td { padding:11px 14px; border-bottom:1px solid var(--border); } | |
| .cost-table td:last-child { text-align:right; font-variant-numeric:tabular-nums; font-weight:700; white-space:nowrap; } | |
| .cost-table tr.total td { border-top:2px solid var(--ink-950); border-bottom:none; font-weight:900; font-size:15px; } | |
| .cost-table tr.total td:last-child { color:var(--amber); } | |
| .cost-table .muted { color:var(--ink-500); font-weight:600; } | |
| .variance-pos { color:var(--red); } .variance-neg { color:var(--mint); } | |
| .notes { font-size:13.5px; line-height:1.7; color:var(--ink-700); background:#f8fafc; border:1px solid var(--border); border-radius:12px; padding:16px 18px; white-space:pre-wrap; } | |
| .sign { display:grid; grid-template-columns:1fr 1fr; gap:24px; padding:24px 32px; } | |
| .sign__line { border-top:1.5px solid var(--ink-700); padding-top:8px; font-size:12px; color:var(--ink-500); } | |
| .sign__line strong { display:block; color:var(--ink-950); font-size:13px; } | |
| .rfoot { padding:16px 32px; font-size:11px; color:var(--ink-400); text-align:center; } | |
| .loading { padding:80px 20px; text-align:center; color:var(--ink-500); font-weight:600; } | |
| @media (max-width:680px){ .summary{grid-template-columns:1fr 1fr;} .ba-grid,.kv,.sign{grid-template-columns:1fr;} } | |
| @media print { | |
| body { background:#fff; } | |
| .toolbar { display:none; } | |
| .page { margin:0; max-width:none; padding:0; } | |
| .sheet { border:none; border-radius:0; box-shadow:none; } | |
| .section, .summary, .rhead { break-inside:avoid; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="toolbar"> | |
| <div class="toolbar__brand"><div class="toolbar__brand-icon">P</div> PotholeIQ</div> | |
| <div class="toolbar__actions"> | |
| <a class="tbtn" href="ops-review.html">β Ops Review</a> | |
| <button class="tbtn primary" onclick="window.print()">Print / Save PDF</button> | |
| </div> | |
| </div> | |
| <div class="page"> | |
| <div id="report-root" class="sheet"> | |
| <div class="loading" id="loading">Loading case reportβ¦</div> | |
| </div> | |
| </div> | |
| <script src="backend-api.js?v=20260604b"></script> | |
| <script> | |
| ; | |
| const CASES_KEY = 'submitted_cases'; | |
| const params = new URLSearchParams(location.search); | |
| const caseId = (params.get('case') || '').trim(); | |
| const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; }; | |
| const money = (v) => `$${num(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; | |
| const esc = (s) => String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| const dash = (v) => (v === 0 || v) ? v : 'β'; | |
| function estimateRepairCost(size, severityScore, address) { | |
| const base = { small: 140, medium: 260, large: 480 }[String(size || '').trim().toLowerCase()] || 0; | |
| if (!base) return { base: 0, severity: 0, traffic: 0, total: 0 }; | |
| const score = Math.max(0, Math.min(100, num(severityScore) || 50)); | |
| const severity = Math.round((score / 100) * 80); | |
| const traffic = /(broad st|market st|chestnut st|ridge ave|main road|ring road|highway|expressway)/i.test(String(address || '')) ? Math.round(base * 0.12) : 0; | |
| return { base, severity, traffic, total: Math.round((base + severity + traffic) / 5) * 5 }; | |
| } | |
| function priorityFromScore(score) { | |
| const s = num(score); | |
| if (s >= 80) return { label:'Critical', desc:'Immediate (<12h)', color:'#dc2626', bg:'#fef2f2' }; | |
| if (s >= 60) return { label:'High', desc:'Next 24h', color:'#b45309', bg:'#fff8e6' }; | |
| if (s >= 40) return { label:'Medium', desc:'Next 72h', color:'#0061d5', bg:'#eff4ff' }; | |
| return { label:'Low', desc:'Scheduled (<7d)', color:'#0e9f6e', bg:'#e7f8f1' }; | |
| } | |
| const STATUS_TONE = { | |
| Resolved:{c:'#0e9f6e',bg:'#e7f8f1'}, 'In Progress':{c:'#b45309',bg:'#fff3e6'}, | |
| Assigned:{c:'#6d28d9',bg:'#f1edfc'}, 'Under Review':{c:'#b45309',bg:'#fff8e6'}, | |
| Submitted:{c:'#0061d5',bg:'#eaf1fb'}, Rejected:{c:'#9b1c1c',bg:'#fdf0ef'} | |
| }; | |
| async function loadCase() { | |
| if (window.PotholeBackend?.getCase) { | |
| try { const c = await window.PotholeBackend.getCase(caseId); if (c) return c; } catch (e) { /* fall through */ } | |
| } | |
| try { | |
| const local = JSON.parse(localStorage.getItem(CASES_KEY) || '[]'); | |
| return local.find((x) => x && x.caseId === caseId) || null; | |
| } catch { return null; } | |
| } | |
| function photoCell(fileId, fileName, fallbackLabel) { | |
| const id = `ph-${Math.random().toString(36).slice(2)}`; | |
| if (fileId) { | |
| // Try to load from Box; fall back to placeholder on error. | |
| setTimeout(() => { | |
| fetch(`/api/box/files/${encodeURIComponent(fileId)}/content`) | |
| .then((r) => { if (!r.ok) throw new Error(); return r.blob(); }) | |
| .then((b) => { const el = document.getElementById(id); if (el) el.outerHTML = `<img class="ba-photo" src="${URL.createObjectURL(b)}" alt="${esc(fileName||'')}" />`; }) | |
| .catch(() => {}); | |
| }, 0); | |
| return `<div class="ba-photo__ph" id="${id}">Loading imageβ¦<br>${esc(fileName || fileId)}</div>`; | |
| } | |
| return `<div class="ba-photo__ph">${esc(fallbackLabel)}${fileName ? `<br>${esc(fileName)}` : ''}</div>`; | |
| } | |
| function kv(label, value) { | |
| return `<div class="kv__item"><div class="kv__label">${esc(label)}</div><div class="kv__value">${esc(dash(value))}</div></div>`; | |
| } | |
| function render(rec) { | |
| const r = rec.report || {}; | |
| const sup = r.supervisor || {}; | |
| const enr = r.enrichment || {}; | |
| const bd = enr.severityBreakdown || {}; | |
| const loc = r.location || {}; | |
| const size = sup.size || r.potholeSize || r.aiResult || r.aiPrediction || 'Unknown'; | |
| const sevScore = enr.severityScore ?? r.workflowSeverityScore ?? ''; | |
| const sevLevel = enr.severityLevel || r.workflowSeverityLevel || 'Pending'; | |
| const pr = priorityFromScore(sevScore); | |
| const laborHours = num(r.laborHours), laborRate = num(r.laborRate), materialsCost = num(r.materialsCost); | |
| const laborCost = laborHours * laborRate; | |
| const actualTotal = num(r.totalCost) || (laborCost + materialsCost); | |
| const est = estimateRepairCost(size, sevScore, r.address); | |
| const variance = est.total ? actualTotal - est.total : 0; | |
| const variancePct = est.total ? ((variance / est.total) * 100).toFixed(1) + '%' : 'N/A'; | |
| const st = STATUS_TONE[rec.status] || STATUS_TONE.Submitted; | |
| const roadScore = typeof r.aiRoadScore === 'number' ? (r.aiRoadScore * 100).toFixed(1) + '%' : 'β'; | |
| const dup = enr.duplicate?.isDuplicate ? `Duplicate (${enr.duplicate.duplicateCount || 0} linked)` : 'New case'; | |
| const supDecision = sup.verified ? (sup.isPothole ? 'Approved' : 'Rejected') : 'Pending'; | |
| document.getElementById('report-root').innerHTML = ` | |
| <div class="rhead"> | |
| <div class="rhead__eyebrow">Pothole Repair Β· Before / After Report</div> | |
| <div class="rhead__title">${esc(rec.caseId)}</div> | |
| <div class="rhead__meta"> | |
| <span><strong>Status:</strong> <span class="status-pill" style="background:${st.bg};color:${st.c};">${esc(rec.status || 'Resolved')}</span></span> | |
| <span><strong>Address:</strong> ${esc(r.address || 'Unknown')}</span> | |
| <span><strong>Generated:</strong> ${new Date().toLocaleString()}</span> | |
| </div> | |
| </div> | |
| <div class="summary"> | |
| <div class="summary__cell"><div class="summary__label">AI Size</div><div class="summary__value">${esc(size)}</div></div> | |
| <div class="summary__cell"><div class="summary__label">Severity</div><div class="summary__value">${dash(sevScore)}${sevScore!=='' ? '/100' : ''}</div></div> | |
| <div class="summary__cell"><div class="summary__label">Priority</div><div class="summary__value" style="color:${pr.color}">${pr.label}</div></div> | |
| <div class="summary__cell"><div class="summary__label">Total Spend</div><div class="summary__value" style="color:var(--amber)">${money(actualTotal)}</div></div> | |
| </div> | |
| <div class="section"> | |
| <div class="section__label">Before & After</div> | |
| <div class="ba-grid"> | |
| <div> | |
| <div class="ba-col__tag before">β Before β Reported Condition</div> | |
| ${photoCell(r.photoFileId, r.photoFileName, 'Reported photo not archived')} | |
| </div> | |
| <div> | |
| <div class="ba-col__tag after">β After β Completed Repair</div> | |
| ${photoCell(r.afterPhotoFileId, r.afterPhotoFileName, 'After-photo not uploaded')} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <div class="section__label">Report & Location</div> | |
| <div class="kv"> | |
| ${kv('Submitted', r.submittedAt ? new Date(r.submittedAt).toLocaleString() : (rec.ts ? new Date(rec.ts).toLocaleString() : 'β'))} | |
| ${kv('Reporter', r.anonymous ? 'Anonymous' : (r.reporterName || 'β'))} | |
| ${kv('Ward / District', `${r.ward || 'Pending'} / ${r.district || 'Pending'}`)} | |
| ${kv('Maintenance Zone', r.maintenanceZone || 'Pending')} | |
| ${kv('Coordinates', (loc.lat != null && loc.lng != null) ? `${Number(loc.lat).toFixed(5)}, ${Number(loc.lng).toFixed(5)}` : 'β')} | |
| ${kv('Reporter Contact', r.anonymous ? 'β' : (r.reporterContact || 'β'))} | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <div class="section__label">AI Assessment & Severity (Before)</div> | |
| <div class="kv"> | |
| ${kv('AI Classification', r.aiResult || r.aiPrediction || 'Unknown')} | |
| ${kv('Road-likeness', roadScore)} | |
| ${kv('Severity Level', `${sevLevel}${sevScore!=='' ? ` (${sevScore}/100)` : ''}`)} | |
| ${kv('Resolution Priority', `${pr.label} β ${pr.desc}`)} | |
| ${kv('311 Prior Notice', `${dash(bd.priorNotice?.score)} / ${bd.priorNotice?.max ?? 30}`)} | |
| ${kv('Traffic & Road Class', `${dash(bd.traffic?.score)} / ${bd.traffic?.max ?? 25}`)} | |
| ${kv('Damage Type', `${dash(bd.damage?.score)} / ${bd.damage?.max ?? 25}`)} | |
| ${kv('Weather Risk', `${dash(bd.weather?.score)} / ${bd.weather?.max ?? 10}`)} | |
| ${kv('Duplicate Status', dup)} | |
| ${sup.verified ? kv('Supervisor Decision', `${supDecision}${sup.by ? ` Β· ${sup.by}` : ''}`) : ''} | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <div class="section__label">Repair Completion (After)</div> | |
| <div class="kv"> | |
| ${kv('Resolved At', r.resolvedAt ? new Date(r.resolvedAt).toLocaleString() : 'Pending')} | |
| ${kv('Crew', r.crewName || r.dispatchPlan?.crewName || r.assignedTo || 'Pending')} | |
| ${kv('Repair Method', r.repairMethod || 'Not specified')} | |
| ${kv('Materials Used', r.materialsUsed || 'Not specified')} | |
| ${kv('Labor Hours', r.laborHours ? `${r.laborHours} h` : 'Not specified')} | |
| ${kv('Crew Signature', r.crewSignature || 'Not provided')} | |
| </div> | |
| </div> | |
| <div class="section"> | |
| <div class="section__label">Cost β Estimated vs. Actual</div> | |
| <table class="cost-table"> | |
| <tr><td>Estimated repair cost <span class="muted">(base ${money(est.base)} + severity ${money(est.severity)} + traffic ${money(est.traffic)})</span></td><td>${money(est.total)}</td></tr> | |
| <tr><td>Actual labor <span class="muted">(${laborHours} h Γ ${money(laborRate)}/hr)</span></td><td>${money(laborCost)}</td></tr> | |
| <tr><td>Actual materials</td><td>${money(materialsCost)}</td></tr> | |
| <tr class="total"><td>Actual total spend</td><td>${money(actualTotal)}</td></tr> | |
| <tr><td>Variance vs. estimate</td><td class="${variance > 0 ? 'variance-pos' : 'variance-neg'}">${variance >= 0 ? '+' : ''}${money(variance)} (${variancePct})</td></tr> | |
| </table> | |
| </div> | |
| <div class="section"> | |
| <div class="section__label">Field Notes</div> | |
| <div class="notes">${esc(r.resolutionNotes || 'No field notes provided.')}</div> | |
| </div> | |
| <div class="sign"> | |
| <div class="sign__line"><strong>${esc(r.crewSignature || '________________')}</strong>Crew Lead β Repair Completed</div> | |
| <div class="sign__line"><strong>${esc(sup.by || '________________')}</strong>Supervisor β Verified & Closed</div> | |
| </div> | |
| <div class="rfoot">PotholeIQ Β· Department of Streets β Road Maintenance Division Β· Case ${esc(rec.caseId)}</div> | |
| `; | |
| } | |
| (async function init() { | |
| if (!caseId) { | |
| document.getElementById('report-root').innerHTML = '<div class="loading">No case specified. Open this report from a case (e.g. case-report.html?case=POT-β¦).</div>'; | |
| return; | |
| } | |
| const rec = await loadCase(); | |
| if (!rec) { | |
| document.getElementById('report-root').innerHTML = `<div class="loading">Case β${esc(caseId)}β was not found.</div>`; | |
| return; | |
| } | |
| render(rec); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |