PotholeIQ / crew-closeout.html
Varun10000's picture
Report completeness on hosted: photo+breakdown+AI backfill from Box/sidecar; wkhtmltopdf PDF fallback; clean crew confirmation
9006385
Raw
History Blame Contribute Delete
23.6 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Crew Closeout — 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&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css?v=20260604a" />
<style>
body { padding-top: 64px; }
.closeout-shell {
min-height: calc(100vh - 64px);
background: linear-gradient(180deg, #F7F9FC 0%, #ECF2FB 100%);
padding: 36px 20px 72px;
}
.closeout-wrap {
max-width: 1100px;
margin: 0 auto;
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.closeout-panel {
background: #fff;
border: 1px solid #E4E9F0;
border-radius: 18px;
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.08);
}
.closeout-panel__head {
padding: 22px 24px 18px;
border-bottom: 1px solid #E8EDF5;
}
.closeout-panel__title {
font-size: 20px;
font-weight: 800;
color: #162038;
margin-bottom: 6px;
}
.closeout-panel__sub {
font-size: 13px;
color: #6B7280;
line-height: 1.6;
}
.closeout-panel__body {
padding: 22px 24px 24px;
}
.detail-stack {
display: grid;
gap: 12px;
}
.detail-card {
border: 1px solid #E7ECF4;
border-radius: 14px;
padding: 14px 16px;
background: #FAFCFF;
}
.detail-card__label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #7A8396;
margin-bottom: 4px;
}
.detail-card__value {
font-size: 14px;
font-weight: 600;
color: #1A2340;
line-height: 1.5;
word-break: break-word;
}
.closeout-form {
display: grid;
gap: 18px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.field-group {
display: grid;
gap: 8px;
}
.field-group label {
font-size: 12px;
font-weight: 700;
color: #46506A;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.field-input,
.field-textarea,
.field-select {
width: 100%;
border: 1.5px solid #D8E0ED;
border-radius: 12px;
background: #fff;
color: #122033;
font: inherit;
padding: 12px 14px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.field-textarea { min-height: 130px; resize: vertical; }
.field-input:focus,
.field-textarea:focus,
.field-select:focus {
outline: none;
border-color: #0061D5;
box-shadow: 0 0 0 3px rgba(0, 97, 213, 0.12);
}
.field-hint {
font-size: 12px;
color: #768195;
line-height: 1.5;
}
.cost-total-box {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
border-radius: 14px;
border: 1.5px solid #FDE2A7;
background: linear-gradient(135deg, #FFFBF0, #FFF6E0);
}
.cost-total-box__label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9A6608;
}
.cost-total-box__hint {
font-size: 12px;
color: #B07B2A;
margin-top: 3px;
}
.cost-total-box__amount {
font-size: 26px;
font-weight: 800;
letter-spacing: -0.02em;
color: #92500C;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.status-banner {
display: none;
margin-top: 18px;
padding: 14px 16px;
border-radius: 14px;
font-size: 14px;
font-weight: 600;
line-height: 1.6;
}
.status-banner.ok {
display: block;
background: #EAFAF2;
border: 1px solid #BFE8D1;
color: #166C43;
}
.status-banner.err {
display: block;
background: #FEF2F2;
border: 1px solid #F6C5C5;
color: #A12828;
}
.action-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.btn-solid {
border: none;
border-radius: 12px;
padding: 12px 18px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.btn-solid.primary {
background: #0061D5;
color: #fff;
box-shadow: 0 10px 24px rgba(0, 97, 213, 0.2);
}
.btn-solid.secondary {
background: #EEF3FB;
color: #24436B;
}
.btn-solid:hover { transform: translateY(-1px); }
@media (max-width: 900px) {
.closeout-wrap { grid-template-columns: 1fr; }
.form-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<nav class="nav-header">
<div class="nav-header__inner">
<div class="nav-header__logo">
<div class="nav-header__logo-icon">P</div>
<span>PotholeIQ — Crew Closeout</span>
</div>
</div>
</nav>
<main class="closeout-shell">
<div class="closeout-wrap">
<section class="closeout-panel">
<div class="closeout-panel__head">
<div class="closeout-panel__title">Assigned Case Snapshot</div>
<div class="closeout-panel__sub">Select an assigned case, verify the location and severity context, then submit the field proof and closeout details.</div>
</div>
<div class="closeout-panel__body">
<div class="field-group" style="margin-bottom:18px;">
<label for="closeout-case">Case</label>
<select id="closeout-case" class="field-select"></select>
</div>
<div class="detail-stack" id="case-detail-stack">
<div class="detail-card">
<div class="detail-card__label">Status</div>
<div class="detail-card__value">No case selected yet.</div>
</div>
</div>
</div>
</section>
<section class="closeout-panel">
<div class="closeout-panel__head">
<div class="closeout-panel__title">Crew Completion Form</div>
<div class="closeout-panel__sub">This records the repair proof outside Box, then saves the after-photo, before/after report, and audit artifacts back into the case folder in Box.</div>
</div>
<div class="closeout-panel__body">
<form class="closeout-form" id="closeout-form">
<div class="form-grid">
<div class="field-group">
<label for="crew-name">Crew Name</label>
<input id="crew-name" class="field-input" type="text" placeholder="Crew Alpha / Crew 2" />
</div>
<div class="field-group">
<label for="repair-method">Repair Method</label>
<select id="repair-method" class="field-select">
<option value="Cold Patch">Cold Patch</option>
<option value="Hot Mix">Hot Mix</option>
<option value="Full Depth Repair">Full Depth Repair</option>
<option value="Temporary Safety Patch">Temporary Safety Patch</option>
</select>
</div>
</div>
<div class="form-grid">
<div class="field-group">
<label for="materials-used">Materials Used</label>
<input id="materials-used" class="field-input" type="text" placeholder="2 cold-mix bags, 1 sealant can" />
</div>
<div class="field-group">
<label for="labor-hours">Labor Hours</label>
<input id="labor-hours" class="field-input" type="number" min="0" step="0.25" placeholder="2.5" />
</div>
</div>
<div class="form-grid">
<div class="field-group">
<label for="labor-rate">Labor Rate ($/hr)</label>
<input id="labor-rate" class="field-input" type="number" min="0" step="0.01" placeholder="45.00" value="45" />
</div>
<div class="field-group">
<label for="materials-cost">Materials Cost ($)</label>
<input id="materials-cost" class="field-input" type="number" min="0" step="0.01" placeholder="120.00" />
</div>
</div>
<div class="cost-total-box">
<div>
<div class="cost-total-box__label">Total Spend (labor + materials)</div>
<div class="cost-total-box__hint" id="cost-breakdown-hint">Enter labor hours, rate, and materials cost.</div>
</div>
<div class="cost-total-box__amount" id="total-spend-display">$0.00</div>
</div>
<div class="field-group">
<label for="resolution-notes">Resolution Notes</label>
<textarea id="resolution-notes" class="field-textarea" placeholder="Describe the repair, safety observations, and anything the supervisor should know."></textarea>
</div>
<div class="form-grid">
<div class="field-group">
<label for="crew-signature">Crew Signature</label>
<input id="crew-signature" class="field-input" type="text" placeholder="Crew lead full name / badge" />
</div>
<div class="field-group">
<label for="after-photo">After Photo</label>
<input id="after-photo" class="field-input" type="file" accept="image/*" />
<div class="field-hint">This uploads directly into the case folder in Box before the closeout record is saved.</div>
</div>
</div>
<div class="field-group">
<label for="notify-email">Also Email Report To <span style="font-weight:500;text-transform:none;color:#768195;">— optional, comma-separated</span></label>
<input id="notify-email" class="field-input" type="text" placeholder="extra.person@city.gov, another@city.gov" autocomplete="off" />
<div class="field-hint">Every closeout already shares the report with the <strong>default recipients</strong> configured for the team (via Box). Add any extra one-off recipients here, separated by commas. <strong>One-time invite:</strong> each person is invited once to the Box <strong>“Closeout Reports”</strong> folder, then keeps standing access — turn on that folder’s notifications in Box to get one clean email per closeout. Requires Box to be connected.</div>
</div>
<div class="action-row">
<button type="submit" class="btn-solid primary" id="submit-closeout">Submit Closeout & Resolve Case</button>
</div>
</form>
<div id="closeout-status" class="status-banner"></div>
</div>
</section>
</div>
</main>
<script src="backend-api.js?v=20260604a"></script>
<script>
'use strict';
const CASES_KEY = 'submitted_cases';
function $(id) {
return document.getElementById(id);
}
function readLocalCases() {
try {
return JSON.parse(localStorage.getItem(CASES_KEY) || '[]');
} catch {
return [];
}
}
function writeLocalCases(cases) {
localStorage.setItem(CASES_KEY, JSON.stringify(Array.isArray(cases) ? cases : []));
}
function caseDetailField(label, value) {
return `
<div class="detail-card">
<div class="detail-card__label">${label}</div>
<div class="detail-card__value">${value || '—'}</div>
</div>`;
}
function eligibleCloseoutCases(cases) {
return (Array.isArray(cases) ? cases : [])
.filter((item) => item && item.caseId)
.filter((item) => !['Resolved', 'Rejected'].includes(String(item.status || '')))
.filter((item) => item.report?.supervisor?.isPothole !== false);
}
function renderCaseOptions(cases, selectedCaseId = '') {
const options = ['<option value="">Select an assigned case</option>'];
cases.forEach((item) => {
const selected = item.caseId === selectedCaseId ? ' selected' : '';
options.push(`<option value="${item.caseId}"${selected}>${item.caseId}${item.report?.address || item.report?.areaLabel || 'Unknown address'}</option>`);
});
$('closeout-case').innerHTML = options.join('');
}
function updateCaseSummary(caseRecord) {
if (!caseRecord) {
$('case-detail-stack').innerHTML = caseDetailField('Status', 'No case selected yet.');
return;
}
const report = caseRecord.report || {};
$('case-detail-stack').innerHTML = [
caseDetailField('Status', caseRecord.status || 'Unknown'),
caseDetailField('Address', report.address || 'Unknown'),
caseDetailField('Ward / District', `${report.ward || 'Pending'} / ${report.district || 'Pending'}`),
caseDetailField('Maintenance Zone', report.maintenanceZone || 'Pending'),
caseDetailField('Severity', `${report.enrichment?.severityLevel || report.workflowSeverityLevel || 'Pending'} (${report.enrichment?.severityScore ?? report.workflowSeverityScore ?? 'N/A'})`),
caseDetailField('Assigned Crew', report.dispatchPlan?.crewName || report.crewName || report.assignedTo || '—'),
].join('');
$('crew-name').value = report.dispatchPlan?.crewName || report.crewName || report.assignedTo || '';
$('repair-method').value = report.repairMethod || 'Cold Patch';
$('materials-used').value = report.materialsUsed || '';
$('labor-hours').value = report.laborHours || '';
$('labor-rate').value = report.laborRate || '45';
$('materials-cost').value = report.materialsCost || '';
$('resolution-notes').value = report.resolutionNotes || '';
$('crew-signature').value = report.crewSignature || '';
recomputeSpend();
}
function setStatus(message, tone = 'ok') {
const el = $('closeout-status');
el.className = `status-banner ${tone}`;
el.textContent = message;
}
function computeSpend() {
const hours = parseFloat($('labor-hours').value) || 0;
const rate = parseFloat($('labor-rate').value) || 0;
const materials = parseFloat($('materials-cost').value) || 0;
const labor = hours * rate;
return { hours, rate, materials, labor, total: labor + materials };
}
function recomputeSpend() {
const s = computeSpend();
$('total-spend-display').textContent = `$${s.total.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
$('cost-breakdown-hint').textContent = s.total > 0
? `Labor ${s.hours}h × $${s.rate.toFixed(2)} = $${s.labor.toFixed(2)} · Materials $${s.materials.toFixed(2)}`
: 'Enter labor hours, rate, and materials cost.';
}
// Map a Box case (flat, from /api/box/cases) to the { caseId, status, report } shape.
function boxCaseToRecord(c) {
return {
caseId: c.caseId,
status: c.status || 'Assigned',
ts: c.submittedAt || Date.now(),
report: {
address: c.address || '', ward: c.ward || '', district: c.district || '',
maintenanceZone: c.maintenanceZone || '',
location: (c.latitude != null && c.longitude != null) ? { lat: c.latitude, lng: c.longitude } : null,
photoFileId: c.photoFileId || '', aiResult: c.potholeSize || '', potholeSize: c.potholeSize || '',
reporterContact: c.reporterContact || '', submittedAt: c.submittedAt || '',
enrichment: { severityScore: c.severityScore, severityLevel: c.severityLevel, severityBreakdown: c.breakdown || {} },
},
};
}
async function fetchBoxCases() {
try {
const r = await fetch('/api/box/cases').then((x) => x.json());
return Array.isArray(r.cases) ? r.cases.map(boxCaseToRecord) : [];
} catch (_) { return []; }
}
// Load every case (local/DB + Box) so the form can open to any case via ?case=.
let CASES_BY_ID = {};
async function loadCases() {
let remoteCases = [];
try {
remoteCases = await window.PotholeBackend.getCases();
} catch (error) {
console.warn('[CrewCloseout] remote case load failed:', error);
}
const merged = window.PotholeBackend.mergeCaseLists(readLocalCases(), remoteCases);
const have = new Set(merged.map((c) => c.caseId));
(await fetchBoxCases()).forEach((bc) => { if (!have.has(bc.caseId)) merged.push(bc); });
CASES_BY_ID = Object.fromEntries(merged.map((c) => [c.caseId, c]));
return merged;
}
async function handleSubmit(event) {
event.preventDefault();
const caseId = $('closeout-case').value;
if (!caseId) {
setStatus('Select an assigned case before submitting closeout.', 'err');
return;
}
const crewName = $('crew-name').value.trim();
const resolutionNotes = $('resolution-notes').value.trim();
const crewSignature = $('crew-signature').value.trim();
if (!crewName || !resolutionNotes || !crewSignature) {
setStatus('Crew name, resolution notes, and crew signature are required.', 'err');
return;
}
const submitBtn = $('submit-closeout');
submitBtn.disabled = true;
submitBtn.textContent = 'Submitting closeout…';
try {
// Ensure the case exists in the backend store (Box-only cases aren't there yet),
// so the after-photo upload + closeout can find it. The server backfills the rest.
const selected = CASES_BY_ID[caseId];
if (selected) {
await window.PotholeBackend.upsertCase({
caseId,
status: selected.status || 'Assigned',
report: selected.report || {},
}).catch(() => {});
}
let afterPhotoUpload = null;
const photoFile = $('after-photo').files && $('after-photo').files[0];
if (photoFile) {
const ext = (photoFile.name.split('.').pop() || 'jpg').toLowerCase();
const safeName = `${caseId}_after_photo.${ext}`;
afterPhotoUpload = await window.PotholeBackend.uploadCaseFile(caseId, safeName, photoFile, photoFile.type || 'image/jpeg');
}
const spend = computeSpend();
const notifyEmail = $('notify-email').value.trim();
if (notifyEmail) localStorage.setItem('closeout_notify_email', notifyEmail);
const response = await window.PotholeBackend.submitCrewCloseout(caseId, {
crewName,
repairMethod: $('repair-method').value,
materialsUsed: $('materials-used').value.trim(),
laborHours: $('labor-hours').value.trim(),
laborRate: $('labor-rate').value.trim(),
materialsCost: $('materials-cost').value.trim(),
totalCost: spend.total.toFixed(2),
notifyEmail,
resolutionNotes,
crewSignature,
afterPhotoFileId: afterPhotoUpload?.fileId || '',
afterPhotoFileName: afterPhotoUpload?.fileName || '',
});
const updatedCase = response?.case;
if (!updatedCase) {
throw new Error('Closeout did not return an updated case.');
}
const allCases = window.PotholeBackend.mergeCaseLists(readLocalCases(), [updatedCase]);
writeLocalCases(allCases);
renderCaseOptions(eligibleCloseoutCases(allCases));
updateCaseSummary(updatedCase);
$('closeout-form').reset();
$('closeout-case').value = '';
// Crew-facing confirmation: keep it simple — no internal sharing/notification
// details on the crew page.
const statusEl = $('closeout-status');
statusEl.className = 'status-banner ok';
statusEl.innerHTML = `✓ Closeout submitted for <strong>${caseId}</strong>. The case has been marked <strong>Resolved</strong> and your repair report has been filed. Thank you.`;
} catch (error) {
console.error('[CrewCloseout] submit failed:', error);
setStatus(`Could not submit closeout: ${error.message}`, 'err');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Submit Closeout & Resolve Case';
}
}
async function init() {
const allCases = await loadCases();
const requestedCaseId = new URLSearchParams(window.location.search).get('case') || '';
if (requestedCaseId) {
// Locked mode: this form is for exactly the pothole that was sent to the crew.
// Show only that case — no picker, no defaulting to some other case.
let theCase = CASES_BY_ID[requestedCaseId] || null;
// Not in the local/list caches (fresh hosted restart, or the Box case-list
// API requires supervisor auth) -> fetch the single case directly. That
// endpoint is public and reads live Box metadata as the source of truth.
if (!theCase) {
try { theCase = await window.PotholeBackend.getCase(requestedCaseId); } catch (_) { theCase = null; }
if (theCase) {
theCase.report = theCase.report || {};
// Severity arrives flat from the live-Box lookup; the summary panel
// reads it from report.enrichment — normalize so it displays.
if (!theCase.report.enrichment && (theCase.report.severityLevel || theCase.report.severityScore != null)) {
theCase.report.enrichment = {
severityLevel: theCase.report.severityLevel,
severityScore: theCase.report.severityScore,
};
}
CASES_BY_ID[requestedCaseId] = theCase;
}
}
if (!theCase) {
// Still show WHICH case this form was for, even though it wasn't found.
$('closeout-case').innerHTML = `<option value="${requestedCaseId}" selected>${requestedCaseId}</option>`;
$('closeout-case').disabled = true;
$('case-detail-stack').innerHTML = caseDetailField('Status', `Case "${requestedCaseId}" was not found.`);
$('submit-closeout').disabled = true;
} else {
renderCaseOptions([theCase], requestedCaseId);
$('closeout-case').value = requestedCaseId;
$('closeout-case').disabled = true; // crew can't switch to another case
updateCaseSummary(theCase);
}
} else {
// No case in the link: don't auto-pick one. Prompt to open from a specific case.
const eligible = eligibleCloseoutCases(allCases);
renderCaseOptions(eligible, '');
updateCaseSummary(null);
$('closeout-case').addEventListener('change', (event) => {
updateCaseSummary(CASES_BY_ID[event.target.value] || null);
});
}
['labor-hours', 'labor-rate', 'materials-cost'].forEach((id) => {
$(id).addEventListener('input', recomputeSpend);
});
recomputeSpend();
$('notify-email').value = localStorage.getItem('closeout_notify_email') || '';
$('closeout-form').addEventListener('submit', handleSubmit);
}
init().catch((error) => {
console.error('[CrewCloseout] init failed:', error);
setStatus(`Could not initialize crew closeout: ${error.message}`, 'err');
});
</script>
</body>
</html>