Spaces:
Running on Zero
Running on Zero
Siddharth Ravikumar
feat: re-enable Run AI Analytics for user cases in TraceSceneFinal, keeping reference cases disabled
a023831 | /** | |
| * TraceScene β Frontend Application | |
| * | |
| * Single-page application for accident case management, | |
| * photo upload, AI analysis, and report viewing. | |
| */ | |
| const API_BASE = '/api'; | |
| // ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let currentView = 'landing'; | |
| let currentCaseId = null; | |
| let currentCaseData = null; // Store full case data for editing | |
| let selectedFiles = []; | |
| let additionalFiles = []; // For add photos modal | |
| // ββ Init ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initNavigation(); | |
| initDropZone(); | |
| initButtons(); | |
| initAccordions(); | |
| loadHealth(); | |
| loadCases(); | |
| }); | |
| // ββ Accordions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function initAccordions() { | |
| document.querySelectorAll('.accordion-header').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const item = btn.closest('.accordion-item'); | |
| const isActive = item.classList.contains('active'); | |
| // Close all other accordions | |
| document.querySelectorAll('.accordion-item').forEach(i => { | |
| i.classList.remove('active'); | |
| i.querySelector('.accordion-header').setAttribute('aria-expanded', 'false'); | |
| }); | |
| // Toggle current | |
| if (!isActive) { | |
| item.classList.add('active'); | |
| btn.setAttribute('aria-expanded', 'true'); | |
| } | |
| }); | |
| }); | |
| } | |
| // ββ Navigation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function initNavigation() { | |
| document.querySelectorAll('.nav-item[data-view]').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const view = btn.dataset.view; | |
| switchView(view); | |
| document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| }); | |
| }); | |
| } | |
| function switchView(viewName) { | |
| document.querySelectorAll('.view').forEach(v => { | |
| v.classList.remove('active'); | |
| v.classList.add('hidden'); | |
| }); | |
| const target = document.getElementById(`view-${viewName}`); | |
| if (target) { | |
| target.classList.remove('hidden'); | |
| target.classList.add('active'); | |
| } | |
| currentView = viewName; | |
| if (viewName === 'dashboard') loadCases(); | |
| if (viewName === 'rules') loadRules(); | |
| } | |
| // ββ Buttons βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function initButtons() { | |
| // Dashboard | |
| document.getElementById('btn-refresh-cases')?.addEventListener('click', loadCases); | |
| document.getElementById('btn-empty-new-case')?.addEventListener('click', () => { | |
| switchView('new-case'); | |
| document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active')); | |
| document.querySelector('[data-view="new-case"]')?.classList.add('active'); | |
| }); | |
| // New Case | |
| document.getElementById('btn-create-case')?.addEventListener('click', createCase); | |
| // Case Detail | |
| document.getElementById('btn-back-dashboard')?.addEventListener('click', () => switchView('dashboard')); | |
| document.getElementById('btn-run-analysis')?.addEventListener('click', runAnalysis); | |
| document.getElementById('btn-view-report')?.addEventListener('click', viewReport); | |
| document.getElementById('btn-edit-case')?.addEventListener('click', openEditModal); | |
| document.getElementById('btn-add-photos-inline')?.addEventListener('click', openAddPhotosModal); | |
| // Edit Modal | |
| document.getElementById('btn-close-edit-modal')?.addEventListener('click', closeEditModal); | |
| document.getElementById('btn-cancel-edit')?.addEventListener('click', closeEditModal); | |
| document.getElementById('btn-save-case')?.addEventListener('click', saveCaseChanges); | |
| // Photos Modal | |
| document.getElementById('btn-close-photos-modal')?.addEventListener('click', closePhotosModal); | |
| document.getElementById('btn-cancel-photos')?.addEventListener('click', closePhotosModal); | |
| document.getElementById('btn-upload-more')?.addEventListener('click', uploadMorePhotos); | |
| // Form logic | |
| document.getElementById('case-number')?.addEventListener('input', validateForm); | |
| initEditDropZone(); | |
| } | |
| function initEditDropZone() { | |
| const dropZone = document.getElementById('edit-drop-zone'); | |
| const fileInput = document.getElementById('edit-file-input'); | |
| if (!dropZone || !fileInput) return; | |
| dropZone.addEventListener('click', () => fileInput.click()); | |
| dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); }); | |
| dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); | |
| dropZone.addEventListener('drop', e => { | |
| e.preventDefault(); | |
| dropZone.classList.remove('drag-over'); | |
| handleAdditionalFiles(Array.from(e.dataTransfer.files)); | |
| }); | |
| fileInput.addEventListener('change', () => { | |
| handleAdditionalFiles(Array.from(fileInput.files)); | |
| }); | |
| } | |
| function validateForm() { | |
| const caseNum = document.getElementById('case-number')?.value.trim(); | |
| const btn = document.getElementById('btn-create-case'); | |
| if (btn) btn.disabled = !caseNum; | |
| } | |
| // ββ Health βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadHealth() { | |
| try { | |
| const resp = await fetch(`${API_BASE}/health`); | |
| const data = await resp.json(); | |
| document.getElementById('status-model').textContent = data.model_loaded ? 'β Ready' : 'β Not loaded'; | |
| document.getElementById('status-model').style.color = data.model_loaded ? '#22c55e' : '#ef4444'; | |
| document.getElementById('status-device').textContent = data.device || 'β'; | |
| document.getElementById('status-rules').textContent = `${data.rules_loaded} rules`; | |
| } catch { | |
| document.getElementById('status-model').textContent = 'β Offline'; | |
| document.getElementById('status-model').style.color = '#ef4444'; | |
| } | |
| } | |
| // ββ Cases βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadCases() { | |
| try { | |
| const resp = await fetch(`${API_BASE}/cases`); | |
| const data = await resp.json(); | |
| renderCases(data.cases || []); | |
| } catch (e) { | |
| showToast('Failed to load cases', 'error'); | |
| } | |
| } | |
| function renderCases(cases) { | |
| const grid = document.getElementById('cases-grid'); | |
| const empty = document.getElementById('empty-state-dashboard'); | |
| if (!cases.length) { | |
| grid.innerHTML = ''; | |
| grid.style.display = 'none'; | |
| empty.style.display = 'flex'; | |
| return; | |
| } | |
| grid.style.display = 'grid'; | |
| empty.style.display = 'none'; | |
| const referenceCases = cases.filter(c => c.is_reference); | |
| const userCases = cases.filter(c => !c.is_reference); | |
| let html = ''; | |
| if (referenceCases.length) { | |
| html += `<h3 class="grid-header">Reference cases</h3>`; | |
| html += referenceCases.map(c => renderCaseCard(c)).join(''); | |
| } | |
| if (userCases.length) { | |
| html += `<h3 class="grid-header" style="margin-top: 1.5rem;">My cases</h3>`; | |
| html += userCases.map(c => renderCaseCard(c)).join(''); | |
| } | |
| grid.innerHTML = html; | |
| } | |
| function renderCaseCard(c) { | |
| return ` | |
| <div class="case-card ${c.is_reference ? 'reference-card' : ''}" onclick="openCase(${c.id})"> | |
| ${!c.is_reference ? ` | |
| <button class="btn-delete-card" onclick="event.stopPropagation(); deleteCase(${c.id}, '${escHtml(c.case_number)}')" title="Delete Case"> | |
| <i class="fa-solid fa-trash-can"></i> | |
| </button>` : ''} | |
| <div class="card-header"> | |
| <span class="case-number">${escHtml(c.case_number)}</span> | |
| <span class="status-badge ${c.status}"> | |
| <i class="fa-solid fa-circle"></i> ${c.status} | |
| </span> | |
| </div> | |
| <div class="case-meta"> | |
| ${c.officer_name ? `<span><i class="fa-solid fa-user-shield"></i> ${escHtml(c.officer_name)}</span>` : ''} | |
| ${c.location ? `<span><i class="fa-solid fa-location-dot"></i> ${escHtml(c.location)}</span>` : ''} | |
| ${c.incident_date ? `<span><i class="fa-solid fa-calendar"></i> ${c.incident_date}</span>` : ''} | |
| </div> | |
| <div class="card-footer"> | |
| <span class="photo-count"><i class="fa-solid fa-camera"></i> ${c.photo_count || 0} photos</span> | |
| <span style="font-size:0.72rem;color:var(--text-muted)">${c.is_reference ? 'Static Reference' : formatDate(c.created_at)}</span> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| async function deleteCase(id, caseNum) { | |
| if (!confirm(`Are you sure you want to delete Case ${caseNum}? This will permanently remove all photos and analysis data.`)) { | |
| return; | |
| } | |
| try { | |
| const resp = await fetch(`${API_BASE}/cases/${id}`, { method: 'DELETE' }); | |
| if (!resp.ok) throw new Error('Delete failed'); | |
| showToast(`Case ${caseNum} deleted`, 'success'); | |
| loadCases(); | |
| } catch (e) { | |
| showToast(e.message, 'error'); | |
| } | |
| } | |
| // ββ Create Case βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function createCase() { | |
| const caseNumber = document.getElementById('case-number').value.trim(); | |
| if (!caseNumber) return showToast('Case number is required', 'error'); | |
| const formData = new FormData(); | |
| formData.append('case_number', caseNumber); | |
| formData.append('officer_name', document.getElementById('officer-name').value.trim()); | |
| formData.append('location', document.getElementById('incident-location').value.trim()); | |
| formData.append('incident_date', document.getElementById('incident-date').value); | |
| formData.append('notes', document.getElementById('officer-notes').value.trim()); | |
| try { | |
| const resp = await fetch(`${API_BASE}/cases`, { method: 'POST', body: formData }); | |
| if (!resp.ok) { | |
| const err = await resp.json(); | |
| throw new Error(err.detail || 'Failed to create case'); | |
| } | |
| const data = await resp.json(); | |
| const caseId = data.id; | |
| showToast('Case created', 'success'); | |
| // Upload photos if selected | |
| if (selectedFiles.length > 0) { | |
| await uploadPhotos(caseId); | |
| } | |
| // Clear form | |
| document.getElementById('case-number').value = ''; | |
| document.getElementById('officer-name').value = ''; | |
| document.getElementById('incident-location').value = ''; | |
| document.getElementById('incident-date').value = ''; | |
| document.getElementById('officer-notes').value = ''; | |
| selectedFiles = []; | |
| document.getElementById('photo-preview').classList.add('hidden'); | |
| // Open the case detail | |
| openCase(caseId); | |
| } catch (e) { | |
| showToast(e.message, 'error'); | |
| } | |
| } | |
| async function uploadPhotos(caseId) { | |
| const progressSection = document.getElementById('upload-progress'); | |
| const progressFill = document.getElementById('upload-progress-fill'); | |
| const statusText = document.getElementById('upload-status-text'); | |
| progressSection.classList.remove('hidden'); | |
| statusText.textContent = `Uploading ${selectedFiles.length} photos...`; | |
| const formData = new FormData(); | |
| selectedFiles.forEach(f => formData.append('files', f)); | |
| try { | |
| progressFill.style.width = '50%'; | |
| const resp = await fetch(`${API_BASE}/cases/${caseId}/photos`, { | |
| method: 'POST', | |
| body: formData, | |
| }); | |
| if (!resp.ok) throw new Error('Upload failed'); | |
| const data = await resp.json(); | |
| progressFill.style.width = '100%'; | |
| statusText.textContent = `${data.count} photos uploaded β`; | |
| showToast(`${data.count} photos uploaded`, 'success'); | |
| return data; | |
| } catch (e) { | |
| statusText.textContent = 'Upload failed'; | |
| showToast('Photo upload failed', 'error'); | |
| throw e; | |
| } | |
| } | |
| // ββ Drop Zone βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function initDropZone() { | |
| const dropZone = document.getElementById('drop-zone'); | |
| const fileInput = document.getElementById('file-input'); | |
| if (!dropZone || !fileInput) return; | |
| dropZone.addEventListener('click', () => fileInput.click()); | |
| dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); }); | |
| dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); | |
| dropZone.addEventListener('drop', e => { | |
| e.preventDefault(); | |
| dropZone.classList.remove('drag-over'); | |
| handleFiles(Array.from(e.dataTransfer.files)); | |
| }); | |
| fileInput.addEventListener('change', () => { | |
| handleFiles(Array.from(fileInput.files)); | |
| }); | |
| } | |
| function handleFiles(files) { | |
| const imageFiles = files.filter(f => f.type.startsWith('image/')); | |
| if (!imageFiles.length) return showToast('No image files found', 'error'); | |
| selectedFiles = imageFiles.slice(0, 20); // Max 20 | |
| // Show previews | |
| const preview = document.getElementById('photo-preview'); | |
| const thumbs = document.getElementById('photo-thumbnails'); | |
| const count = document.getElementById('photo-count'); | |
| preview.classList.remove('hidden'); | |
| count.textContent = `${selectedFiles.length} photo${selectedFiles.length > 1 ? 's' : ''} selected`; | |
| thumbs.innerHTML = ''; | |
| selectedFiles.forEach(file => { | |
| const div = document.createElement('div'); | |
| div.className = 'photo-thumbnail'; | |
| const img = document.createElement('img'); | |
| img.src = URL.createObjectURL(file); | |
| div.appendChild(img); | |
| thumbs.appendChild(div); | |
| }); | |
| validateForm(); | |
| } | |
| // ββ Open Case Detail ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function openCase(caseId) { | |
| currentCaseId = caseId; | |
| switchView('case-detail'); | |
| try { | |
| const resp = await fetch(`${API_BASE}/cases/${caseId}`); | |
| const data = await resp.json(); | |
| currentCaseData = data.case; | |
| renderCaseDetail(data); | |
| } catch (e) { | |
| showToast('Failed to load case', 'error'); | |
| } | |
| } | |
| // ββ Edit Case βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function openEditModal() { | |
| if (!currentCaseData) return; | |
| document.getElementById('edit-officer-name').value = currentCaseData.officer_name || ''; | |
| document.getElementById('edit-incident-location').value = currentCaseData.location || ''; | |
| document.getElementById('edit-incident-date').value = currentCaseData.incident_date || ''; | |
| document.getElementById('edit-officer-notes').value = currentCaseData.notes || ''; | |
| document.getElementById('modal-edit-case').classList.remove('hidden'); | |
| } | |
| function closeEditModal() { | |
| document.getElementById('modal-edit-case').classList.add('hidden'); | |
| } | |
| async function saveCaseChanges() { | |
| if (!currentCaseId) return; | |
| const formData = new FormData(); | |
| formData.append('officer_name', document.getElementById('edit-officer-name').value.trim()); | |
| formData.append('location', document.getElementById('edit-incident-location').value.trim()); | |
| formData.append('incident_date', document.getElementById('edit-incident-date').value); | |
| formData.append('notes', document.getElementById('edit-officer-notes').value.trim()); | |
| try { | |
| const resp = await fetch(`${API_BASE}/cases/${currentCaseId}`, { | |
| method: 'PUT', | |
| body: formData | |
| }); | |
| if (!resp.ok) throw new Error('Failed to update case'); | |
| showToast('Case updated', 'success'); | |
| closeEditModal(); | |
| openCase(currentCaseId); // Refresh | |
| } catch (e) { | |
| showToast(e.message, 'error'); | |
| } | |
| } | |
| // ββ Add Photos ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function openAddPhotosModal() { | |
| additionalFiles = []; | |
| document.getElementById('edit-photo-preview').classList.add('hidden'); | |
| document.getElementById('edit-upload-progress').classList.add('hidden'); | |
| document.getElementById('btn-upload-more').disabled = true; | |
| document.getElementById('modal-add-photos').classList.remove('hidden'); | |
| } | |
| function closePhotosModal() { | |
| document.getElementById('modal-add-photos').classList.add('hidden'); | |
| } | |
| function handleAdditionalFiles(files) { | |
| const imageFiles = files.filter(f => f.type.startsWith('image/')); | |
| if (!imageFiles.length) return showToast('No image files found', 'error'); | |
| additionalFiles = imageFiles.slice(0, 20); | |
| const preview = document.getElementById('edit-photo-preview'); | |
| const thumbs = document.getElementById('edit-photo-thumbnails'); | |
| const count = document.getElementById('edit-photo-count'); | |
| preview.classList.remove('hidden'); | |
| count.textContent = `${additionalFiles.length} photo${additionalFiles.length > 1 ? 's' : ''} selected`; | |
| thumbs.innerHTML = ''; | |
| additionalFiles.forEach(file => { | |
| const div = document.createElement('div'); | |
| div.className = 'photo-thumbnail'; | |
| const img = document.createElement('img'); | |
| img.src = URL.createObjectURL(file); | |
| div.appendChild(img); | |
| thumbs.appendChild(div); | |
| }); | |
| document.getElementById('btn-upload-more').disabled = additionalFiles.length === 0; | |
| } | |
| async function uploadMorePhotos() { | |
| if (!currentCaseId || !additionalFiles.length) return; | |
| const progressSection = document.getElementById('edit-upload-progress'); | |
| const progressFill = document.getElementById('edit-upload-progress-fill'); | |
| const statusText = document.getElementById('edit-upload-status-text'); | |
| progressSection.classList.remove('hidden'); | |
| document.getElementById('btn-upload-more').disabled = true; | |
| const formData = new FormData(); | |
| additionalFiles.forEach(f => formData.append('files', f)); | |
| try { | |
| progressFill.style.width = '50%'; | |
| const resp = await fetch(`${API_BASE}/cases/${currentCaseId}/photos`, { | |
| method: 'POST', | |
| body: formData, | |
| }); | |
| if (!resp.ok) throw new Error('Upload failed'); | |
| progressFill.style.width = '100%'; | |
| statusText.textContent = `Upload complete β`; | |
| showToast('Photos added successfully', 'success'); | |
| setTimeout(() => { | |
| closePhotosModal(); | |
| openCase(currentCaseId); // Refresh | |
| }, 1000); | |
| } catch (e) { | |
| statusText.textContent = 'Upload failed'; | |
| showToast('Photo upload failed', 'error'); | |
| document.getElementById('btn-upload-more').disabled = false; | |
| } | |
| } | |
| function renderCaseDetail(data) { | |
| const c = data.case; | |
| // Header | |
| document.getElementById('case-detail-title').textContent = `Case: ${c.case_number}`; | |
| // Info bar | |
| document.getElementById('detail-case-number').textContent = c.case_number; | |
| document.getElementById('detail-officer').textContent = c.officer_name || 'N/A'; | |
| document.getElementById('detail-location').textContent = c.location || 'N/A'; | |
| document.getElementById('detail-date').textContent = c.incident_date || 'N/A'; | |
| document.getElementById('detail-status').textContent = c.status; | |
| const statusChip = document.getElementById('detail-status-chip'); | |
| statusChip.className = `info-chip status-chip ${c.status}`; | |
| // Enable/disable buttons | |
| const btnAnalysis = document.getElementById('btn-run-analysis'); | |
| const btnReport = document.getElementById('btn-view-report'); | |
| const btnEdit = document.getElementById('btn-edit-case'); | |
| const btnAddPhotos = document.getElementById('btn-add-photos-inline'); | |
| if (c.is_reference) { | |
| btnAnalysis.disabled = true; | |
| btnAnalysis.title = "AI Analytics is disabled for reference cases"; | |
| if (btnEdit) btnEdit.disabled = true; | |
| if (btnAddPhotos) btnAddPhotos.disabled = true; | |
| } else { | |
| btnAnalysis.disabled = !data.photos?.length; | |
| btnAnalysis.title = data.photos?.length ? "" : "Upload photos to run analysis"; | |
| if (btnEdit) btnEdit.disabled = false; | |
| if (btnAddPhotos) btnAddPhotos.disabled = false; | |
| } | |
| btnReport.disabled = c.status !== 'complete'; | |
| if (data.photos?.length) { | |
| photosGrid.innerHTML = data.photos.map(p => { | |
| const imgSrc = `/${p.filepath}`; | |
| return ` | |
| <div class="detail-photo"> | |
| <img src="${imgSrc}" alt="${escHtml(p.filename)}" onerror="this.onerror=null; this.src='/static/placeholder.jpg';" loading="lazy"> | |
| </div> | |
| `; | |
| }).join(''); | |
| } else { | |
| photosGrid.innerHTML = '<p class="placeholder-text">No photos uploaded yet.</p>'; | |
| } | |
| // Analyses | |
| const analysisContent = document.getElementById('analysis-content'); | |
| if (data.analyses?.length) { | |
| analysisContent.innerHTML = data.analyses.map(a => ` | |
| <div class="analysis-photo-section" style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border);"> | |
| <div class="analysis-photo-label" style="font-weight: 600; margin-bottom: 1rem;">π· ${escHtml(a.filename || 'Photo')}</div> | |
| <div class="markdown-body"> | |
| ${marked.parse(a.raw_analysis || '')} | |
| </div> | |
| </div> | |
| `).join(''); | |
| } else { | |
| analysisContent.innerHTML = '<p class="placeholder-text">Run analysis to see AI observations.</p>'; | |
| } | |
| // Violations | |
| const violationsList = document.getElementById('violations-list'); | |
| const violationBadge = document.getElementById('violation-badge'); | |
| violationBadge.textContent = data.violations?.length || 0; | |
| if (data.violations?.length) { | |
| violationsList.innerHTML = data.violations.map(v => ` | |
| <div class="violation-card ${v.severity || 'MEDIUM'}"> | |
| <div class="violation-header"> | |
| <span class="violation-title">${escHtml(v.rule_title)}</span> | |
| <span class="severity-tag ${v.severity || 'MEDIUM'}">${v.severity || 'MEDIUM'}</span> | |
| </div> | |
| <div class="violation-meta"> | |
| <span><i class="fa-solid fa-hashtag"></i> ${v.rule_id}</span> | |
| <span><i class="fa-solid fa-percent"></i> ${Math.round(v.confidence * 100)}% confidence</span> | |
| ${v.party_label ? `<span><i class="fa-solid fa-car"></i> ${escHtml(v.party_label)}</span>` : ''} | |
| </div> | |
| ${v.evidence_summary ? `<div class="violation-evidence">${escHtml(v.evidence_summary)}</div>` : ''} | |
| </div> | |
| `).join(''); | |
| } else { | |
| violationsList.innerHTML = '<p class="placeholder-text">No violations detected yet.</p>'; | |
| } | |
| // Fault Analysis | |
| renderFaultAnalysis(data.fault_analysis, data.parties); | |
| } | |
| function renderFaultAnalysis(fault, parties) { | |
| const content = document.getElementById('fault-content'); | |
| if (!fault) { | |
| content.innerHTML = '<p class="placeholder-text">Run analysis to determine fault.</p>'; | |
| return; | |
| } | |
| let html = ''; | |
| // Fault distribution bars | |
| if (fault.fault_distribution_json) { | |
| let dist = {}; | |
| try { | |
| dist = typeof fault.fault_distribution_json === 'string' | |
| ? JSON.parse(fault.fault_distribution_json) | |
| : fault.fault_distribution_json; | |
| } catch { dist = {}; } | |
| const entries = Object.entries(dist); | |
| if (entries.length) { | |
| html += '<div class="fault-party-bars">'; | |
| entries.forEach(([label, pct], i) => { | |
| html += ` | |
| <div class="party-bar"> | |
| <div class="party-bar-label"> | |
| <span class="party-name">${escHtml(label)}</span> | |
| <span class="party-pct">${pct}%</span> | |
| </div> | |
| <div class="party-bar-track"> | |
| <div class="party-bar-fill ${i === 0 ? 'primary' : 'secondary'}" | |
| style="width: ${pct}%"></div> | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| html += '</div>'; | |
| } | |
| } | |
| // Probable cause | |
| if (fault.probable_cause) { | |
| html += ` | |
| <div class="fault-cause"> | |
| <h4><i class="fa-solid fa-magnifying-glass-chart"></i> Probable Cause</h4> | |
| <p>${escHtml(fault.probable_cause)}</p> | |
| </div> | |
| `; | |
| } | |
| // Confidence | |
| if (fault.overall_confidence != null) { | |
| const pct = Math.round(fault.overall_confidence * 100); | |
| html += ` | |
| <div class="confidence-meter"> | |
| <span>Confidence:</span> | |
| <div class="meter-bar"> | |
| <div class="meter-fill" style="width: ${pct}%"></div> | |
| </div> | |
| <span>${pct}%</span> | |
| </div> | |
| `; | |
| } | |
| // Summary | |
| if (fault.analysis_summary) { | |
| html += `<p style="margin-top:0.8rem;font-size:0.82rem;color:var(--text-secondary)"> | |
| ${escHtml(fault.analysis_summary)}</p>`; | |
| } | |
| content.innerHTML = html || '<p class="placeholder-text">No fault analysis available.</p>'; | |
| } | |
| // ββ Run Analysis ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // ββ Report ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function viewReport() { | |
| if (!currentCaseId) return; | |
| switchView('report'); | |
| try { | |
| const resp = await fetch(`${API_BASE}/cases/${currentCaseId}/report`); | |
| const report = await resp.json(); | |
| renderReport(report); | |
| } catch (e) { | |
| showToast('Failed to load report', 'error'); | |
| } | |
| } | |
| function renderReport(report) { | |
| const content = document.getElementById('report-content'); | |
| let html = ` | |
| <div class="report-header"> | |
| <h2><i class="fa-solid fa-shield-halved"></i> ${report.report_type || 'Incident Report'}</h2> | |
| <p class="report-subtitle">Case ${escHtml(report.case?.case_number || '')} β’ Generated by AI</p> | |
| </div> | |
| <div class="report-disclaimer"> | |
| <i class="fa-solid fa-triangle-exclamation"></i> ${escHtml(report.disclaimer || '')} | |
| </div> | |
| `; | |
| // Stats | |
| const stats = report.statistics || {}; | |
| html += ` | |
| <div class="report-stat-grid"> | |
| <div class="report-stat"> | |
| <div class="stat-number">${stats.total_photos || 0}</div> | |
| <div class="stat-label">Photos</div> | |
| </div> | |
| <div class="report-stat"> | |
| <div class="stat-number">${stats.total_violations || 0}</div> | |
| <div class="stat-label">Violations</div> | |
| </div> | |
| <div class="report-stat"> | |
| <div class="stat-number">${stats.critical_violations || 0}</div> | |
| <div class="stat-label">Critical</div> | |
| </div> | |
| <div class="report-stat"> | |
| <div class="stat-number">${stats.parties_identified || 0}</div> | |
| <div class="stat-label">Parties</div> | |
| </div> | |
| </div> | |
| `; | |
| // Case Info | |
| html += `<h3>Case Information</h3>`; | |
| html += `<p><strong>Case Number:</strong> ${escHtml(report.case?.case_number || 'N/A')}</p>`; | |
| html += `<p><strong>Officer:</strong> ${escHtml(report.case?.officer_name || 'N/A')}</p>`; | |
| html += `<p><strong>Location:</strong> ${escHtml(report.case?.location || 'N/A')}</p>`; | |
| html += `<p><strong>Date:</strong> ${report.case?.incident_date || 'N/A'}</p>`; | |
| if (report.case?.notes) html += `<p><strong>Notes:</strong> ${escHtml(report.case.notes)}</p>`; | |
| // Scene Summary | |
| if (report.scene_summary) { | |
| html += `<h3>Scene Analysis</h3>`; | |
| html += `<p style="white-space:pre-wrap">${escHtml(report.scene_summary)}</p>`; | |
| } | |
| // Parties | |
| if (report.parties?.length) { | |
| html += `<h3>Parties Involved</h3>`; | |
| report.parties.forEach(p => { | |
| html += `<h4>${escHtml(p.label)}</h4>`; | |
| html += `<p>Type: ${p.vehicle_type || 'Unknown'} β’ Color: ${p.vehicle_color || 'Unknown'}</p>`; | |
| }); | |
| } | |
| // Violations | |
| if (report.violations?.list?.length) { | |
| html += `<h3>Traffic Violations Detected</h3>`; | |
| report.violations.list.forEach(v => { | |
| html += ` | |
| <div class="violation-card ${v.severity}" style="margin-bottom:0.5rem"> | |
| <div class="violation-header"> | |
| <span class="violation-title">${escHtml(v.title)}</span> | |
| <span class="severity-tag ${v.severity}">${v.severity}</span> | |
| </div> | |
| <p style="font-size:0.82rem;color:var(--text-secondary);margin-top:0.3rem"> | |
| Party: ${escHtml(v.party)} β’ Confidence: ${Math.round(v.confidence * 100)}% | |
| </p> | |
| ${v.evidence ? `<p style="font-size:0.78rem;color:var(--text-muted);font-style:italic">${escHtml(v.evidence)}</p>` : ''} | |
| </div> | |
| `; | |
| }); | |
| } | |
| // Fault Analysis | |
| if (report.fault_analysis?.determined) { | |
| html += `<h3>Fault Analysis</h3>`; | |
| html += `<p><strong>Primary Fault:</strong> ${escHtml(report.fault_analysis.primary_fault_party || 'Undetermined')}</p>`; | |
| if (report.fault_analysis.fault_distribution) { | |
| html += `<p><strong>Distribution:</strong> `; | |
| html += Object.entries(report.fault_analysis.fault_distribution) | |
| .map(([k, v]) => `${k}: ${v}%`).join(' β’ '); | |
| html += `</p>`; | |
| } | |
| html += `<p><strong>Confidence:</strong> ${Math.round((report.fault_analysis.overall_confidence || 0) * 100)}%</p>`; | |
| if (report.fault_analysis.probable_cause) { | |
| html += `<h4>Probable Cause</h4>`; | |
| html += `<p>${escHtml(report.fault_analysis.probable_cause)}</p>`; | |
| } | |
| } | |
| content.innerHTML = html; | |
| } | |
| // ββ Rules βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadRules() { | |
| try { | |
| const resp = await fetch(`${API_BASE}/rules`); | |
| const data = await resp.json(); | |
| renderRules(data); | |
| } catch (e) { | |
| showToast('Failed to load rules', 'error'); | |
| } | |
| } | |
| function renderRules(data) { | |
| const content = document.getElementById('rules-content'); | |
| if (!data.categories?.length) { | |
| content.innerHTML = '<p class="placeholder-text">No rules loaded.</p>'; | |
| return; | |
| } | |
| content.innerHTML = data.categories.map(cat => ` | |
| <div class="rule-category"> | |
| <div class="rule-category-header"> | |
| <i class="fa-solid fa-gavel"></i> | |
| ${escHtml(cat.name)} | |
| <span class="rule-count">${cat.rule_count} rules</span> | |
| </div> | |
| <div class="rule-list"> | |
| ${cat.rules.map(r => ` | |
| <div class="rule-item"> | |
| <span class="rule-id">${r.id}</span> | |
| <span>${escHtml(r.title)}</span> | |
| <span class="severity-tag ${r.severity}" style="margin-left:auto">${r.severity}</span> | |
| </div> | |
| `).join('')} | |
| </div> | |
| </div> | |
| `).join(''); | |
| } | |
| // ββ Toast βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function showToast(message, type = 'info') { | |
| const container = document.getElementById('toast-container'); | |
| const toast = document.createElement('div'); | |
| toast.className = `toast ${type}`; | |
| toast.textContent = message; | |
| container.appendChild(toast); | |
| setTimeout(() => toast.remove(), 4000); | |
| } | |
| // ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function escHtml(str) { | |
| if (!str) return ''; | |
| const div = document.createElement('div'); | |
| div.textContent = str; | |
| return div.innerHTML; | |
| } | |
| function formatDate(dateStr) { | |
| if (!dateStr) return ''; | |
| try { | |
| const d = new Date(dateStr); | |
| return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); | |
| } catch { return dateStr; } | |
| } | |