Spaces:
Sleeping
Sleeping
Report completeness on hosted: photo+breakdown+AI backfill from Box/sidecar; wkhtmltopdf PDF fallback; clean crew confirmation
9006385 | <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> | |
| ; | |
| 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> | |