PotholeIQ / case-report.html
Varun10000's picture
Reports: omit Supervisor Decision row until a decision exists
2541381
Raw
History Blame Contribute Delete
18 kB
<!DOCTYPE html>
<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>
'use strict';
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>