PotholeIQ / deploy-crew.html
Varun10000's picture
PotholeIQ: initial deployable build (Box + Supabase, HF Spaces Docker)
45e997f
Raw
History Blame Contribute Delete
89 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Crew Dispatch & Routing Command Center</title>
<meta name="description" content="Operations control dashboard for routing, assigning, and dispatching road maintenance crew groups." />
<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@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<script src="gis.js"></script>
<style>
/* ════════════════════════════════════════════════════
1. COLOR VARIABLES & GENERAL RESET
════════════════════════════════════════════════════ */
:root {
--bg-dark: #070D1A;
--panel-bg: rgba(13, 21, 37, 0.85);
--panel-border: rgba(255, 255, 255, 0.08);
--text-main: #FFFFFF;
--text-muted: #94A3B8;
--text-dim: #64748B;
--color-blue: #3B82F6;
--color-blue-glow: rgba(59, 130, 246, 0.35);
--color-accent: #8B5CF6;
--radius-lg: 16px;
--radius-md: 10px;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-family);
background-color: var(--bg-dark);
color: var(--text-main);
min-height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
/* ════════════════════════════════════════════════════
2. PREMIUM NAVIGATION BAR
════════════════════════════════════════════════════ */
.console-nav {
background: rgba(10, 17, 31, 0.95);
border-bottom: 1px solid var(--panel-border);
height: 64px;
display: flex;
align-items: center;
position: relative;
z-index: 1000;
backdrop-filter: blur(12px);
flex-shrink: 0;
}
.console-nav__inner {
width: 100%;
max-width: 1500px;
margin: 0 auto;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.console-nav__brand {
display: flex;
align-items: center;
gap: 12px;
}
.console-nav__icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #0061D5, #004FA8);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(0, 97, 213, 0.35);
}
.console-nav__title {
font-size: 15px;
font-weight: 800;
color: var(--text-main);
letter-spacing: -0.01em;
}
.console-nav__sub {
font-size: 11px;
color: var(--text-muted);
margin-top: 1px;
}
.console-nav__right {
display: flex;
align-items: center;
gap: 16px;
}
.nav-links {
display: flex;
gap: 6px;
}
.nav-link {
padding: 8px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
text-decoration: none;
transition: all 0.2s ease;
}
.nav-link:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-main);
}
.nav-link.active {
background: rgba(59, 130, 246, 0.15);
color: #60A5FA;
font-weight: 600;
border: 1px solid rgba(59, 130, 246, 0.25);
}
.console-conn-badge {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: rgba(16, 185, 129, 0.1);
color: #34D399;
border: 1px solid rgba(52, 211, 153, 0.25);
}
.console-conn-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
animation: pulse-dot 1.8s infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.85); }
}
/* ════════════════════════════════════════════════════
3. MAIN LAYOUT GRID
════════════════════════════════════════════════════ */
.dashboard-container {
flex: 1;
display: grid;
grid-template-columns: 360px 1fr;
position: relative;
height: calc(100vh - 64px);
overflow: hidden;
}
@media (max-width: 960px) {
.dashboard-container {
grid-template-columns: 1fr;
grid-template-rows: 350px 1fr;
}
}
/* ── Left Sidebar (Command Panel) ── */
.sidebar {
background: #0D1527;
border-right: 1px solid var(--panel-border);
display: flex;
flex-direction: column;
overflow-y: auto;
position: relative;
z-index: 10;
}
.sidebar-section {
padding: 20px 24px;
border-bottom: 1px solid var(--panel-border);
}
.sidebar-section:last-child {
border-bottom: none;
flex: 1;
}
.sidebar-title {
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
/* Metric Grid */
.metrics-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.metric-card {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: var(--radius-md);
padding: 12px 14px;
position: relative;
overflow: hidden;
}
.metric-card::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--color-blue);
}
.metric-card.accent::before {
background: var(--color-accent);
}
.metric-label {
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 4px;
}
.metric-val {
font-size: 20px;
font-weight: 800;
color: var(--text-main);
}
/* Deployed Crews Stop Schedules */
.crew-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.crew-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: var(--radius-md);
overflow: hidden;
transition: all 0.2s ease;
}
.crew-card:hover {
border-color: rgba(255,255,255,0.12);
}
.crew-header {
padding: 10px 14px;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.crew-info {
display: flex;
align-items: center;
gap: 8px;
}
.crew-color-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
box-shadow: 0 0 8px currentColor;
}
.crew-name {
font-size: 13px;
font-weight: 700;
color: var(--text-main);
}
.crew-meta {
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
background: rgba(255,255,255,0.05);
color: var(--text-muted);
}
.crew-stops {
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 240px;
overflow-y: auto;
}
.stop-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
background: rgba(255,255,255,0.01);
border: 1px solid transparent;
}
.stop-item:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.08);
}
.stop-badge {
font-size: 10px;
font-weight: 800;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-dim);
color: var(--bg-dark);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
}
.stop-info {
flex: 1;
min-width: 0;
}
.stop-id {
font-family: monospace;
font-weight: 700;
color: var(--text-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stop-address {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
/* Global action buttons */
.action-panel {
padding: 16px 24px;
background: rgba(10, 17, 31, 0.4);
border-top: 1px solid var(--panel-border);
display: flex;
flex-direction: column;
gap: 8px;
}
.btn {
width: 100%;
padding: 11px 16px;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background: linear-gradient(135deg, #3B82F6 0%, #2563EB 100%);
color: #fff;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.3);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.45);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.05);
color: var(--text-main);
border: 1px solid var(--panel-border);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.09);
border-color: rgba(255, 255, 255, 0.15);
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
.dispatch-status-note {
font-size: 11px;
line-height: 1.5;
color: var(--text-muted);
padding: 10px 12px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
/* ── Right Panel (Interactive Leaflet Map) ── */
.map-container-outer {
position: relative;
width: 100%;
height: 100%;
}
#dispatch-map {
width: 100%;
height: 100%;
background: var(--bg-dark);
z-index: 1;
}
/* Map overlays and absolute badges */
.map-badge {
position: absolute;
top: 20px;
right: 20px;
z-index: 999;
background: rgba(13, 21, 37, 0.9);
backdrop-filter: blur(10px);
border: 1.5px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-md);
padding: 10px 14px;
pointer-events: none;
display: flex;
flex-direction: column;
gap: 4px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.map-badge-header {
font-size: 9px;
font-weight: 800;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.map-badge-val {
font-size: 13px;
font-weight: 700;
color: var(--text-main);
display: flex;
align-items: center;
gap: 6px;
}
.map-badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #10B981;
}
.map-hint-banner {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 999;
background: rgba(13, 21, 37, 0.95);
backdrop-filter: blur(8px);
border: 1.5px solid rgba(59, 130, 246, 0.25);
color: #93C5FD;
padding: 10px 20px;
border-radius: 30px;
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
pointer-events: none;
}
/* ════════════════════════════════════════════════════
4. DIALOG & MOCK MODALS (SETUP OVERLAY)
════════════════════════════════════════════════════ */
.setup-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(5, 10, 20, 0.85);
backdrop-filter: blur(16px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
transition: all 0.3s ease;
}
.setup-card {
background: #0E1628;
border: 1px solid rgba(255, 255, 255, 0.08);
width: 100%;
max-width: 580px;
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes zoomIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.setup-header {
padding: 28px 32px;
border-bottom: 1px solid var(--panel-border);
background: linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%);
}
.setup-title {
font-size: 20px;
font-weight: 800;
color: #fff;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.setup-desc {
font-size: 13px;
color: var(--text-muted);
line-height: 1.5;
}
.setup-body {
padding: 28px 32px;
display: flex;
flex-direction: column;
gap: 24px;
}
.setup-section-label {
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
margin-bottom: 10px;
}
/* Wards Checkbox Grid */
.wards-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
@media (max-width: 480px) {
.wards-grid {
grid-template-columns: 1fr;
}
}
.ward-checkbox-card {
background: rgba(255, 255, 255, 0.02);
border: 1.5px solid rgba(255, 255, 255, 0.05);
border-radius: var(--radius-md);
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
}
.ward-checkbox-card:hover {
background: rgba(255,255,255,0.05);
border-color: rgba(255,255,255,0.1);
}
.ward-checkbox-card.checked {
background: rgba(59, 130, 246, 0.08);
border-color: rgba(59, 130, 246, 0.4);
}
.ward-checkbox-card input {
display: none;
}
.ward-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.ward-label {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
}
.ward-checkbox-card.checked .ward-label {
color: var(--text-main);
}
/* Crew Group Slider */
.slider-wrap {
background: rgba(255,255,255,0.01);
border: 1px solid rgba(255,255,255,0.04);
border-radius: var(--radius-md);
padding: 16px 20px;
}
.slider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.slider-val-label {
font-size: 16px;
font-weight: 800;
color: var(--color-blue);
text-shadow: 0 0 10px var(--color-blue-glow);
}
.crew-slider {
width: 100%;
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 5px;
background: rgba(255, 255, 255, 0.1);
outline: none;
cursor: pointer;
}
.crew-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #3B82F6;
box-shadow: 0 0 10px rgba(59, 130, 246, 0.8);
transition: transform 0.1s ease;
}
.crew-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.setup-footer {
padding: 20px 32px 28px;
border-top: 1px solid var(--panel-border);
display: flex;
gap: 12px;
}
/* ── Popups and toasts ── */
.leaflet-popup-content-wrapper {
background: #0E1628 !important;
color: #fff !important;
border: 1.5px solid rgba(255,255,255,0.1) !important;
border-radius: 12px !important;
box-shadow: 0 8px 32px rgba(0,0,0,0.5) !important;
}
.leaflet-popup-tip {
background: #0E1628 !important;
border: 1px solid rgba(255,255,255,0.1) !important;
}
.popup-title {
font-size: 13px;
font-weight: 800;
color: var(--text-main);
margin-bottom: 4px;
font-family: monospace;
}
.popup-desc {
font-size: 11px;
color: var(--text-muted);
line-height: 1.4;
margin-bottom: 8px;
}
.popup-btn-danger {
padding: 6px 12px;
background: #EF4444;
color: #fff;
font-size: 11px;
font-weight: 700;
border-radius: 6px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.15s;
}
.popup-btn-danger:hover {
background: #DC2626;
}
.popup-add-form {
padding: 4px;
display: flex;
flex-direction: column;
gap: 8px;
}
.popup-add-title {
font-size: 12px;
font-weight: 700;
color: #fff;
}
.popup-select {
padding: 5px 8px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
color: #fff;
font-size: 11px;
outline: none;
}
.popup-btn-success {
padding: 6px 12px;
background: #10B981;
color: #fff;
font-size: 11px;
font-weight: 700;
border-radius: 6px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.popup-btn-success:hover {
background: #059669;
}
/* Print Dispatch routes sheet */
.print-overlay {
position: fixed;
inset: 0;
z-index: 99999;
background: #070D1A;
color: #fff;
padding: 40px;
overflow-y: auto;
display: none;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid var(--panel-border);
padding-bottom: 16px;
margin-bottom: 24px;
}
.print-logo {
font-size: 22px;
font-weight: 800;
color: #fff;
}
.print-meta {
font-size: 12px;
color: var(--text-muted);
}
.print-crew-section {
margin-bottom: 32px;
background: rgba(255,255,255,0.02);
border: 1px solid var(--panel-border);
border-radius: 12px;
padding: 20px;
}
.print-crew-title {
font-size: 16px;
font-weight: 800;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.print-stops-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.print-stops-table th, .print-stops-table td {
padding: 10px;
border-bottom: 1px solid var(--panel-border);
text-align: left;
}
.print-stops-table th {
font-weight: 700;
color: var(--text-muted);
}
.print-actions {
display: flex;
gap: 12px;
}
/* Notification Toasts */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 10000;
background: rgba(13, 21, 37, 0.95);
border: 1.5px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-md);
padding: 14px 20px;
color: #fff;
font-size: 13px;
font-weight: 600;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
transform: translateY(150%);
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
max-width: 380px;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
.toast.success { border-color: rgba(52, 211, 153, 0.4); background: rgba(6, 95, 70, 0.95); }
.toast.error { border-color: rgba(239, 68, 68, 0.4); background: rgba(155, 28, 28, 0.95); }
.toast.info { border-color: rgba(59, 130, 246, 0.4); background: rgba(30, 58, 138, 0.95); }
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════════════════
1. NAVIGATION BAR
═══════════════════════════════════════════════════════ -->
<nav class="console-nav">
<div class="console-nav__inner">
<div class="console-nav__brand">
<div class="console-nav__icon">
<svg width="18" height="18" viewBox="0 0 20 20" fill="none">
<path d="M3 14h14M3 10h14M5 6h10" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>
<circle cx="10" cy="16" r="1.5" fill="#fff"/>
</svg>
</div>
<div>
<div class="console-nav__title">Ops Control Room</div>
<div class="console-nav__sub" id="nav-city-label">City Road Operations</div>
</div>
</div>
<div class="console-nav__right">
<div class="nav-links">
<a href="index.html" class="nav-link">Submit Report</a>
<a href="track.html" class="nav-link">Track Case</a>
<a href="ops-review.html" class="nav-link">Ops Review</a>
<a href="deploy-crew.html" class="nav-link active">Deploy Crews</a>
<a href="crew-closeout.html" class="nav-link">Crew Closeout</a>
<a href="settings.html" class="nav-link">Settings</a>
</div>
<div class="console-conn-badge">
<span class="console-conn-dot"></span>
<span>Live Dispatch</span>
</div>
</div>
</div>
</nav>
<!-- ═══════════════════════════════════════════════════════
2. MAIN CONTROL PANEL LAYOUT
═══════════════════════════════════════════════════════ -->
<div class="dashboard-container">
<!-- SIDEBAR: COMMAND CENTER -->
<div class="sidebar">
<!-- Metrics overview -->
<div class="sidebar-section">
<div class="sidebar-title">Global Operations Metrics</div>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">Active Wards</div>
<div class="metric-val" id="metric-wards">0</div>
</div>
<div class="metric-card accent">
<div class="metric-label">Deployed Crews</div>
<div class="metric-val" id="metric-crews">0</div>
</div>
<div class="metric-card accent">
<div class="metric-label">Potholes in region</div>
<div class="metric-val" id="metric-potholes">0</div>
</div>
<div class="metric-card">
<div class="metric-label">Avg workload</div>
<div class="metric-val" id="metric-workload">0</div>
</div>
</div>
</div>
<!-- Deployed crew groups -->
<div class="sidebar-section" style="overflow-y: auto;">
<div class="sidebar-title" style="margin-bottom: 16px;">
<span>Active Crew Routes</span>
<span style="font-size: 10px; font-weight: 500; color: var(--text-dim);">Optimized TSP paths</span>
</div>
<div class="crew-list" id="crew-list-container">
<!-- Crew cards populated by JS -->
<div style="color: var(--text-dim); text-align: center; padding: 20px; font-size: 12px;">Crews not yet deployed.</div>
</div>
</div>
<!-- Actions -->
<div class="action-panel">
<button class="btn btn-primary" id="btn-send-to-crew">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 2L11 13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
Send Final Routes to Crews
</button>
<button class="btn btn-primary" id="btn-reconfigure">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
Modify Coverage
</button>
<button class="btn btn-secondary" id="btn-export-dispatch">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
Export Route Sheets
</button>
<a class="btn btn-secondary" href="crew-closeout.html" style="text-decoration:none;display:flex;align-items:center;justify-content:center;gap:8px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 1 1 3 3L7 19l-4 1 1-4Z"/></svg>
Open Crew Closeout
</a>
<div class="dispatch-status-note" id="dispatch-status-note">Finalize the crew groups, then send the route package to the field crews.</div>
</div>
</div>
<!-- MAP VIEWPORT -->
<div class="map-container-outer">
<div id="dispatch-map"></div>
<!-- On-map status badge -->
<div class="map-badge" id="map-status-overlay" style="display: none;">
<div class="map-badge-header">Active Deployment Scope</div>
<div class="map-badge-val">
<span class="map-badge-dot" id="map-status-dot"></span>
<span id="map-status-text">Philadelphia Control</span>
</div>
</div>
<!-- User assistance banner -->
<div class="map-hint-banner">
<span>Click anywhere on the map to <strong>Add a Pothole</strong>. Numbered markers show stop order for each crew route.</span>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════
3. SETUP CONFIGURATION OVERLAY MODAL
═══════════════════════════════════════════════════════ -->
<div class="setup-overlay" id="setup-modal">
<div class="setup-card">
<div class="setup-header">
<h2 class="setup-title">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#60A5FA" stroke-width="2.5"><polygon points="12 2 2 7 12 12 22 7 12 2/-"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
Crew Deployment Configuration
</h2>
<p class="setup-desc">Initialize maintenance zones, configure crew groupings, and auto-generate optimized route schedules to patch pothole incidents.</p>
</div>
<div class="setup-body">
<!-- Ward Checklist -->
<div>
<div class="setup-section-label">Select City Wards / Districts to Cover</div>
<div class="wards-grid" id="wards-checkbox-grid">
<!-- Dynamic ward selectors populated from gis.js PHILLY_TEN_REGIONS -->
</div>
<div style="margin-top: 10px; display: flex; gap: 8px;">
<button class="btn btn-secondary btn-sm" id="btn-select-all-wards" style="width: auto; padding: 4px 10px; font-size: 11px;">Select All</button>
<button class="btn btn-secondary btn-sm" id="btn-deselect-all-wards" style="width: auto; padding: 4px 10px; font-size: 11px;">Clear All</button>
</div>
</div>
<!-- Crew slider -->
<div>
<div class="slider-header">
<div class="setup-section-label" style="margin-bottom: 0;">Number of Crew Groups to Dispatch</div>
<div class="slider-val-label" id="slider-crew-count-val">3 Groups</div>
</div>
<div class="slider-wrap">
<input type="range" class="crew-slider" id="crew-count-slider" min="1" max="5" value="3" />
<div style="display: flex; justify-content: space-between; margin-top: 8px; font-size: 10px; color: var(--text-dim); font-weight: 700;">
<span>1 CREW</span>
<span>2</span>
<span>3</span>
<span>4</span>
<span>5 CREWS</span>
</div>
</div>
</div>
</div>
<div class="setup-footer">
<button class="btn btn-primary" id="btn-launch-deployment" style="flex: 1;">
πŸš€ Initialize Operations Command
</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════
4. EXPORT / DISPATCH SHEET MODAL
═══════════════════════════════════════════════════════ -->
<div class="print-overlay" id="print-sheet-modal">
<div class="print-header">
<div>
<h1 class="print-logo">City Crew Routing Dispatch Sheet</h1>
<div class="print-meta" id="print-meta-text">Generated on...</div>
</div>
<div class="print-actions">
<button class="btn btn-primary" style="width:auto;" onclick="window.print()">Print Sheet</button>
<button class="btn btn-secondary" style="width:auto;" id="btn-close-print">Close View</button>
</div>
</div>
<div id="print-routes-container">
<!-- Populate crew sheets dynamically -->
</div>
</div>
<!-- ═══════════════════════════════════════════════════════
5. NOTIFICATION TOAST
═══════════════════════════════════════════════════════ -->
<div class="toast" id="toast-notify">
<span id="toast-text">Dispatch system active</span>
</div>
<!-- ═══════════════════════════════════════════════════════
6. CORE JAVASCRIPT DISPATCH CONTROLLER
═══════════════════════════════════════════════════════ -->
<script src="backend-api.js?v=20260604b"></script>
<script>
'use strict';
// ── Preloaded Demo Potholes (from ops-review.html) ───────────────────────
const MOCK_CASES = [
{ caseId: 'PHL-20260520-DM001', location: { lat: 39.980, lng: -75.160 }, address: '1400 N Broad St, Philadelphia, PA 19121', ward: 'Lower North', aiResult: 'Large' },
{ caseId: 'PHL-20260521-DM002', location: { lat: 39.954, lng: -75.188 }, address: '3200 Chestnut St, Philadelphia, PA 19104', ward: 'West', aiResult: 'Medium' },
{ caseId: 'PHL-20260521-DM003', location: { lat: 39.942, lng: -75.150 }, address: '500 S 5th St, Philadelphia, PA 19147', ward: 'South', aiResult: 'Small' },
{ caseId: 'PHL-20260522-DM004', location: { lat: 39.954, lng: -75.175 }, address: '2100 Market St, Philadelphia, PA 19103', ward: 'Central', aiResult: 'Medium' },
{ caseId: 'PHL-20260522-DM005', location: { lat: 40.035, lng: -75.215 }, address: '700 Ridge Ave, Philadelphia, PA 19128', ward: 'Northwest', aiResult: 'Medium' },
// Added high density incidents to make routing feel extra awesome
{ caseId: 'PHL-ADD-001', location: { lat: 39.952, lng: -75.162 }, address: 'S Broad St & Market St', ward: 'Central', aiResult: 'Large' },
{ caseId: 'PHL-ADD-002', location: { lat: 39.961, lng: -75.171 }, address: 'Benjamin Franklin Pkwy', ward: 'Central', aiResult: 'Small' },
{ caseId: 'PHL-ADD-003', location: { lat: 39.938, lng: -75.181 }, address: 'Washington Ave & S 22nd St', ward: 'South', aiResult: 'Large' },
{ caseId: 'PHL-ADD-004', location: { lat: 39.931, lng: -75.155 }, address: 'Mifflin St & S 6th St', ward: 'South', aiResult: 'Medium' },
{ caseId: 'PHL-ADD-005', location: { lat: 39.972, lng: -75.195 }, address: 'Fairmount Park West', ward: 'West', aiResult: 'Medium' },
{ caseId: 'PHL-ADD-006', location: { lat: 39.965, lng: -75.215 }, address: 'Girard Ave & N 44th St', ward: 'West', aiResult: 'Large' },
{ caseId: 'PHL-ADD-007', location: { lat: 40.015, lng: -75.155 }, address: 'N Broad St & W Hunting Park Ave', ward: 'North', aiResult: 'Large' },
{ caseId: 'PHL-ADD-008', location: { lat: 40.032, lng: -75.148 }, address: 'N 5th St & W Olney Ave', ward: 'North', aiResult: 'Medium' },
{ caseId: 'PHL-ADD-009', location: { lat: 40.082, lng: -75.025 }, address: 'Roosevelt Blvd & Holme Ave', ward: 'Northeast', aiResult: 'Large' },
{ caseId: 'PHL-ADD-010', location: { lat: 40.061, lng: -75.058 }, address: 'Oxford Ave & Levick St', ward: 'Northeast', aiResult: 'Small' }
];
// Harmonious colors for crew routes
const CREW_STYLES = [
{ name: 'Crew Alpha', color: '#3B82F6', text: '#93C5FD', bg: 'rgba(59, 130, 246, 0.15)' },
{ name: 'Crew Bravo', color: '#10B981', text: '#A7F3D0', bg: 'rgba(16, 185, 129, 0.15)' },
{ name: 'Crew Charlie',color: '#8B5CF6', text: '#C4B5FD', bg: 'rgba(139, 92, 246, 0.15)' },
{ name: 'Crew Delta', color: '#F97316', text: '#FED7AA', bg: 'rgba(249, 115, 22, 0.15)' },
{ name: 'Crew Echo', color: '#EC4899', text: '#FBCFE8', bg: 'rgba(236, 72, 153, 0.15)' }
];
// Philadelphia Planning Regions bounds
const REGION_BOUNDS = {
'Northeast': [[40.04, -75.07], [40.14, -74.96]],
'Northwest': [[40.02, -75.28], [40.14, -75.17]],
'North': [[40.02, -75.17], [40.14, -75.07]],
'Lower North': [[39.97, -75.20], [40.02, -75.14]],
'West': [[39.94, -75.28], [40.04, -75.18]],
'River Wards': [[39.95, -75.09], [40.02, -74.96]],
'Central': [[39.94, -75.18], [40.02, -75.09]],
'South': [[39.90, -75.20], [39.95, -75.10]],
'Southwest': [[39.86, -75.28], [39.94, -75.15]],
'East': [[39.86, -75.10], [39.95, -74.96]]
};
// ── Global App State ─────────────────────────────────────────────────────
let activePotholes = []; // Master active pothole objects
let selectedWards = []; // Wards chosen by user
let numCrews = 3; // Deployed crew group count
let requestedFocusCaseId = '';
let map = null; // Leaflet map reference
let mapMarkers = []; // Active Leaflet marker objects
let mapOverlayWards = []; // Active Leaflet rectangle overlays
let mapPolylines = []; // Active Leaflet route polylines
let crewAssignments = []; // Array of assignments: { crewIdx, potholes: [], route: [] }
let shouldAutoLaunchFromRequest = false;
let googleMapsSdkPromise = null;
let routeRenderToken = 0;
const MAX_GOOGLE_ROUTE_STOPS = 25;
const DISPATCH_STORAGE = {
adHoc: 'dispatch_ad_hoc_potholes',
removed: 'dispatch_removed_case_ids',
wards: 'dispatch_selected_wards',
crews: 'dispatch_num_crews',
seed: 'ops_review_dispatch_seed_cases',
plan: 'dispatch_last_sent_plan',
};
// DOM selectors shortcut
const $ = id => document.getElementById(id);
function sanitizeDispatchUi() {
document.title = 'Crew Dispatch Command Center';
const hint = document.querySelector('.map-hint-banner span');
if (hint) {
hint.innerHTML = 'Click anywhere on the map to <strong>Add a Pothole</strong>. Numbered markers show each crew route order.';
}
const launchBtn = $('btn-launch-deployment');
if (launchBtn) {
launchBtn.textContent = 'Initialize Operations Command';
}
}
function sanitizeDispatchPopupUi() {
const popupButtons = Array.from(document.querySelectorAll('.leaflet-popup-content button'));
popupButtons.forEach((button) => {
if (/enable ward/i.test(button.textContent)) button.textContent = 'Enable Ward and Add';
if (/add to dispatch/i.test(button.textContent)) button.textContent = 'Add to Dispatch';
});
}
function getRouteAssignmentLookup(assignments) {
const lookup = new Map();
(Array.isArray(assignments) ? assignments : []).forEach((assign) => {
const style = CREW_STYLES[assign.crewIdx % CREW_STYLES.length];
assign.route.forEach((pothole, stopIndex) => {
lookup.set(pothole.caseId, {
crewIdx: assign.crewIdx,
crewName: style.name,
crewColor: style.color,
crewBg: style.bg,
stopIndex: stopIndex + 1,
totalStops: assign.route.length,
});
});
});
return lookup;
}
function refreshDispatchStatusNote() {
const note = $('dispatch-status-note');
const sendButton = $('btn-send-to-crew');
if (!note || !sendButton) return;
const activeRouteCount = crewAssignments.reduce((count, assign) => count + assign.route.length, 0);
sendButton.disabled = activeRouteCount === 0;
const plan = readJsonStorage(DISPATCH_STORAGE.plan, null);
if (plan && plan.sentAt) {
note.textContent = `Last sent ${new Date(plan.sentAt).toLocaleString()} for ${plan.crews.length} crew group${plan.crews.length === 1 ? '' : 's'} across ${plan.totalStops} pothole stop${plan.totalStops === 1 ? '' : 's'}.`;
} else if (activeRouteCount > 0) {
note.textContent = `Routes ready: ${crewAssignments.filter((assign) => assign.route.length).length} active crew group${crewAssignments.filter((assign) => assign.route.length).length === 1 ? '' : 's'} and ${activeRouteCount} stop${activeRouteCount === 1 ? '' : 's'}.`;
} else {
note.textContent = 'Finalize the crew groups, then send the route package to the field crews.';
}
}
// Toast helper
function showToast(msg, type = 'info') {
const t = $('toast-notify');
const txt = $('toast-text');
txt.textContent = msg;
t.className = `toast show ${type}`;
setTimeout(() => { t.classList.remove('show'); }, 3000);
}
function readJsonStorage(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : fallback;
} catch {
return fallback;
}
}
function writeJsonStorage(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
function getPersistedDispatchState() {
return {
adHocPotholes: readJsonStorage(DISPATCH_STORAGE.adHoc, []),
removedCaseIds: readJsonStorage(DISPATCH_STORAGE.removed, []),
selectedWards: readJsonStorage(DISPATCH_STORAGE.wards, null),
numCrews: parseInt(localStorage.getItem(DISPATCH_STORAGE.crews) || '3', 10),
};
}
function getRequestedDispatchState() {
const params = new URLSearchParams(window.location.search);
const validWardNames = new Set(window.PHILLY_TEN_REGIONS.map((region) => region.name));
const wards = (params.get('wards') || '')
.split(',')
.map((value) => value.trim())
.filter((value) => validWardNames.has(value));
const crews = parseInt(params.get('crews') || '', 10);
return {
wards: Array.from(new Set(wards)),
numCrews: Number.isFinite(crews) ? Math.min(5, Math.max(1, crews)) : null,
focusCaseId: (params.get('focus') || '').trim(),
};
}
function getGoogleMapsJsApiKey() {
return (localStorage.getItem('google_maps_js_api_key') || '').trim();
}
async function loadGoogleMapsSdk() {
if (window.google?.maps?.DirectionsService) {
return window.google.maps;
}
const apiKey = getGoogleMapsJsApiKey();
if (!apiKey) return null;
if (googleMapsSdkPromise) return googleMapsSdkPromise;
googleMapsSdkPromise = new Promise((resolve, reject) => {
const existingScript = document.getElementById('google-maps-sdk');
if (existingScript) {
existingScript.addEventListener('load', () => resolve(window.google?.maps || null), { once: true });
existingScript.addEventListener('error', () => reject(new Error('Google Maps script failed to load.')), { once: true });
return;
}
const script = document.createElement('script');
script.id = 'google-maps-sdk';
script.async = true;
script.defer = true;
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(apiKey)}&v=weekly`;
script.onload = () => resolve(window.google?.maps || null);
script.onerror = () => reject(new Error('Google Maps script failed to load.'));
document.head.appendChild(script);
}).catch((error) => {
console.warn('[Dispatch] could not load Google Maps SDK:', error.message);
googleMapsSdkPromise = null;
return null;
});
return googleMapsSdkPromise;
}
function getRouteLabelPoint(pathPoints) {
if (!Array.isArray(pathPoints) || !pathPoints.length) return null;
return pathPoints[Math.floor((pathPoints.length - 1) / 2)];
}
async function requestGoogleRoadRoute(routeStops, directionsService) {
if (!Array.isArray(routeStops) || routeStops.length < 2 || !directionsService || !window.google?.maps) {
return null;
}
if (routeStops.length > MAX_GOOGLE_ROUTE_STOPS) {
console.warn(`[Dispatch] skipping Google road route for ${routeStops.length} stops; limit is ${MAX_GOOGLE_ROUTE_STOPS}.`);
return null;
}
const mapsApi = window.google.maps;
const origin = routeStops[0].location;
const destination = routeStops[routeStops.length - 1].location;
const waypoints = routeStops.slice(1, -1).map((stop) => ({
location: new mapsApi.LatLng(stop.location.lat, stop.location.lng),
stopover: true,
}));
return new Promise((resolve, reject) => {
directionsService.route({
origin: new mapsApi.LatLng(origin.lat, origin.lng),
destination: new mapsApi.LatLng(destination.lat, destination.lng),
waypoints,
optimizeWaypoints: false,
provideRouteAlternatives: false,
travelMode: mapsApi.TravelMode.DRIVING,
}, (result, status) => {
if (status !== 'OK' || !result?.routes?.length) {
reject(new Error(`Directions status ${status || 'UNKNOWN'}`));
return;
}
const overviewPath = result.routes[0].overview_path || [];
const pathPoints = overviewPath.map((point) => [point.lat(), point.lng()]);
resolve(pathPoints.length ? pathPoints : null);
});
});
}
// Free road routing via the public OSRM demo server β€” no API key required.
async function requestOsrmRoadRoute(routeStops) {
if (!Array.isArray(routeStops) || routeStops.length < 2) return null;
if (routeStops.length > MAX_GOOGLE_ROUTE_STOPS) {
console.warn(`[Dispatch] skipping OSRM road route for ${routeStops.length} stops; limit is ${MAX_GOOGLE_ROUTE_STOPS}.`);
return null;
}
const coords = routeStops
.map((stop) => `${Number(stop.location.lng).toFixed(6)},${Number(stop.location.lat).toFixed(6)}`)
.join(';');
const url = `https://router.project-osrm.org/route/v1/driving/${coords}?overview=full&geometries=geojson&steps=false`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`OSRM HTTP ${response.status}`);
const payload = await response.json();
const geometry = payload?.routes?.[0]?.geometry?.coordinates;
if (!Array.isArray(geometry) || geometry.length < 2) return null;
return geometry.map(([lng, lat]) => [lat, lng]);
} finally {
clearTimeout(timer);
}
}
async function resolveAssignmentPath(assign, directionsService) {
const fallbackPath = assign.route.map((p) => [p.location.lat, p.location.lng]);
if (assign.route.length < 2) {
return { pathPoints: fallbackPath, source: 'single-stop' };
}
if (directionsService) {
try {
const googlePath = await requestGoogleRoadRoute(assign.route, directionsService);
if (googlePath?.length) {
return { pathPoints: googlePath, source: 'road-route' };
}
} catch (error) {
console.warn(`[Dispatch] could not load Google road route for ${assign.route[0]?.caseId || 'route'}:`, error.message);
}
}
try {
const osrmPath = await requestOsrmRoadRoute(assign.route);
if (osrmPath?.length) {
return { pathPoints: osrmPath, source: 'road-route' };
}
} catch (error) {
console.warn(`[Dispatch] could not load OSRM road route for ${assign.route[0]?.caseId || 'route'}:`, error.message);
}
return { pathPoints: fallbackPath, source: 'straight-line' };
}
function normalizeDispatchPothole(source) {
const report = source?.report || source || {};
const location = report.location || source?.location || null;
const lat = Number(location && location.lat);
const lng = Number(location && location.lng);
if (!source?.caseId || !Number.isFinite(lat) || !Number.isFinite(lng)) return null;
const fallbackArea = report.areaLabel || source?.areaLabel || '';
const rawMapping = report.gisMapping || {
ward: report.ward || source?.ward || '',
district: report.district || source?.district || '',
zone: report.maintenanceZone || source?.maintenanceZone || '',
area: fallbackArea,
matched: !!(report.gisMapping && report.gisMapping.matched),
configured: !!(report.gisMapping && report.gisMapping.configured),
};
const resolved = window.resolvePreferredGISMapping
? window.resolvePreferredGISMapping(lat, lng, rawMapping, fallbackArea)
: normalizeGISMapping(rawMapping, fallbackArea);
const ward = resolved.ward || resolved.area || 'Unclassified';
return {
caseId: source.caseId,
location: { lat, lng },
address: report.address || source?.address || `Incident ${source.caseId}`,
ward,
aiResult: report.aiResult || report.aiPrediction || source?.aiResult || 'Medium',
aiReviewStatus: report.aiReviewStatus || source?.aiReviewStatus || '',
duplicateStatus: report.duplicateStatus || source?.duplicateStatus || (report.enrichment?.duplicate?.isDuplicate ? 'duplicate' : 'new'),
isDuplicate: !!(report.isDuplicate || source?.isDuplicate || report.enrichment?.duplicate?.isDuplicate),
status: String(source?.status || report.workflowStatus || 'Submitted').trim() || 'Submitted',
supervisorApproved: report.supervisor?.isPothole === true,
sourceType: source?.sourceType || (source?.report ? 'submitted' : 'mock'),
gisMapping: resolved,
};
}
function isDispatchEligiblePothole(item) {
if (!item) return false;
const status = String(item.status || '').trim();
const reviewStatus = String(item.aiReviewStatus || '').toLowerCase();
const isRejected = reviewStatus === 'invalid_image'
|| reviewStatus === 'invalid_image_face'
|| reviewStatus === 'manual_rejected';
const isDuplicateChild = item.isDuplicate || String(item.duplicateStatus || '').toLowerCase() === 'duplicate';
if (isRejected || isDuplicateChild) return false;
if (item.sourceType === 'ops-review-demo') {
return status !== 'Resolved' && status !== 'Rejected';
}
if (item.sourceType === 'mock' || item.sourceType === 'ad-hoc') {
return true;
}
const isWorkflowClosed = status === 'Resolved' || status === 'Rejected';
const isApprovedOperationalCase = item.supervisorApproved || status === 'Assigned' || status === 'In Progress';
return !isWorkflowClosed && isApprovedOperationalCase;
}
// ── Initialization & Setup Modal ─────────────────────────────────────────
function initApp() {
// Set City Title
const cityName = localStorage.getItem('city_name') || 'Philadelphia';
$('nav-city-label').textContent = `${cityName} Road Operations`;
const persisted = getPersistedDispatchState();
const requested = getRequestedDispatchState();
const defaultWards = ['Central', 'West', 'South'];
const configuredWards = requested.wards.length
? requested.wards
: (Array.isArray(persisted.selectedWards) && persisted.selectedWards.length
? persisted.selectedWards
: defaultWards);
requestedFocusCaseId = requested.focusCaseId;
// Build Wards Checklist
const checkboxGrid = $('wards-checkbox-grid');
checkboxGrid.innerHTML = window.PHILLY_TEN_REGIONS.map((w, idx) => {
const tone = window.getGISBadgeTone({ matched: true });
const isPreselected = configuredWards.includes(w.name);
return `
<div class="ward-checkbox-card ${isPreselected ? 'checked' : ''}" data-ward="${w.name}" onclick="toggleWardCheckbox(this)">
<input type="checkbox" value="${w.name}" ${isPreselected ? 'checked' : ''} />
<span class="ward-dot" style="background:${tone.dot};"></span>
<span class="ward-label">${w.name} Region</span>
</div>`;
}).join('');
// Wire setup modal events
$('crew-count-slider').addEventListener('input', (e) => {
$('slider-crew-count-val').textContent = `${e.target.value} Group${e.target.value === '1' ? '' : 's'}`;
});
const initialCrewCount = requested.numCrews || persisted.numCrews || 3;
$('crew-count-slider').value = String(Math.min(5, Math.max(1, initialCrewCount)));
$('slider-crew-count-val').textContent = `${$('crew-count-slider').value} Group${$('crew-count-slider').value === '1' ? '' : 's'}`;
$('btn-select-all-wards').addEventListener('click', () => {
document.querySelectorAll('.ward-checkbox-card').forEach(card => {
card.classList.add('checked');
card.querySelector('input').checked = true;
});
});
$('btn-deselect-all-wards').addEventListener('click', () => {
document.querySelectorAll('.ward-checkbox-card').forEach(card => {
card.classList.remove('checked');
card.querySelector('input').checked = false;
});
});
$('btn-launch-deployment').addEventListener('click', launchCommandCenter);
$('btn-send-to-crew').addEventListener('click', sendRoutesToCrew);
$('btn-reconfigure').addEventListener('click', () => {
$('setup-modal').style.display = 'flex';
});
$('btn-export-dispatch').addEventListener('click', showPrintPreview);
$('btn-close-print').addEventListener('click', () => {
$('print-sheet-modal').style.display = 'none';
});
// Load Master Incident pool
loadMasterPotholes();
// Start Leaflet Map center on City Hall
initLeafletMap();
sanitizeDispatchUi();
refreshDispatchStatusNote();
shouldAutoLaunchFromRequest = requested.wards.length > 0 || !!requested.focusCaseId;
if (shouldAutoLaunchFromRequest) {
launchCommandCenter();
showToast('Loaded crew routes from Ops Review.', 'info');
}
}
function toggleWardCheckbox(element) {
const checkbox = element.querySelector('input');
checkbox.checked = !checkbox.checked;
element.classList.toggle('checked', checkbox.checked);
}
function loadMasterPotholes() {
const persisted = getPersistedDispatchState();
const removedIds = new Set(persisted.removedCaseIds || []);
const seededCases = readJsonStorage(DISPATCH_STORAGE.seed, []);
let localCases = [];
try {
localCases = JSON.parse(localStorage.getItem('submitted_cases') || '[]');
} catch (err) {
console.warn('Error reading custom submitted cases from localStorage:', err);
}
const normalizedLocal = localCases
.map(normalizeDispatchPothole)
.filter(Boolean);
const normalizedSeed = (Array.isArray(seededCases) ? seededCases : [])
.map((item) => normalizeDispatchPothole({ ...item, sourceType: item.sourceType || 'ops-review-seed' }))
.filter(Boolean);
const normalizedMock = MOCK_CASES
.map((item) => normalizeDispatchPothole({ ...item, sourceType: 'mock' }))
.filter(Boolean);
const normalizedAdHoc = (persisted.adHocPotholes || [])
.map((item) => normalizeDispatchPothole({ ...item, sourceType: 'ad-hoc' }))
.filter(Boolean);
const baseCases = normalizedSeed.length
? normalizedSeed
: (normalizedLocal.length ? normalizedLocal : normalizedMock);
const merged = [...baseCases, ...normalizedAdHoc];
const deduped = [];
const seen = new Set();
merged.forEach((item) => {
if (!item || seen.has(item.caseId) || removedIds.has(item.caseId)) return;
seen.add(item.caseId);
deduped.push(item);
});
activePotholes = deduped.filter((item) => isDispatchEligiblePothole(item));
}
function initLeafletMap() {
const PHILLY_CENTER = [39.9526, -75.1652];
map = L.map('dispatch-map', {
zoomControl: false,
attributionControl: false
}).setView(PHILLY_CENTER, 12);
// Deep dark sleek tile layer
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 20
}).addTo(map);
// Setup click-to-add event
map.on('click', handleMapClickToAdd);
}
// ── Setup Dialog Trigger: Launch Center ──────────────────────────────────
function launchCommandCenter() {
// Gather inputs
selectedWards = [];
document.querySelectorAll('.ward-checkbox-card input:checked').forEach(cb => {
selectedWards.push(cb.value);
});
if (!selectedWards.length) {
alert('Please select at least 1 ward region to cover.');
return;
}
numCrews = parseInt($('crew-count-slider').value, 10);
writeJsonStorage(DISPATCH_STORAGE.wards, selectedWards);
localStorage.setItem(DISPATCH_STORAGE.crews, String(numCrews));
// Hide modal
$('setup-modal').style.display = 'none';
// Update sidebar metrics
$('metric-wards').textContent = selectedWards.length;
$('metric-crews').textContent = numCrews;
// Refresh the live pothole pool before dispatching
loadMasterPotholes();
// Draw map layouts and run routing
recalculateDispatch();
refreshDispatchStatusNote();
showToast(`Command Center Activated: ${selectedWards.length} Wards covered by ${numCrews} Crews!`, 'success');
}
// ── CORE DISPATCH ROUTING PIPELINE (Clustering & TSP) ────────────────────
async function sendRoutesToCrew() {
const activeAssignments = crewAssignments.filter((assign) => assign.route.length);
if (!activeAssignments.length) {
showToast('No finalized routes are available to send yet.', 'error');
return;
}
const sendButton = $('btn-send-to-crew');
const originalSendButtonMarkup = sendButton ? sendButton.innerHTML : '';
if (sendButton) {
sendButton.disabled = true;
sendButton.textContent = 'Syncing Dispatch...';
}
const sentAt = new Date().toISOString();
const planId = `DSP-${sentAt.slice(0, 10).replace(/-/g, '')}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;
const assignmentLookup = getRouteAssignmentLookup(activeAssignments);
const planPayload = {
planId,
sentAt,
selectedWards: [...selectedWards],
numCrews,
totalStops: activeAssignments.reduce((count, assign) => count + assign.route.length, 0),
crews: activeAssignments.map((assign) => {
const style = CREW_STYLES[assign.crewIdx % CREW_STYLES.length];
return {
crewIdx: assign.crewIdx,
crewName: style.name,
crewColor: style.color,
totalStops: assign.route.length,
route: assign.route.map((pothole, stopIndex) => ({
caseId: pothole.caseId,
stopIndex: stopIndex + 1,
address: pothole.address,
ward: pothole.ward,
aiResult: pothole.aiResult,
location: pothole.location,
sourceType: pothole.sourceType || 'submitted',
})),
};
}),
};
const applyDispatchMetadata = (cases) => {
if (!Array.isArray(cases)) return [];
return cases.map((item) => {
const routeMeta = assignmentLookup.get(item?.caseId);
if (!routeMeta) return item;
const nextReport = { ...(item.report || {}) };
nextReport.dispatchPlan = {
planId,
sentAt,
crewIdx: routeMeta.crewIdx,
crewName: routeMeta.crewName,
stopIndex: routeMeta.stopIndex,
totalStops: routeMeta.totalStops,
selectedWards: [...selectedWards],
numCrews,
};
nextReport.assignedTo = routeMeta.crewName;
nextReport.crewName = routeMeta.crewName;
nextReport.workflowStage = 'crew_dispatch';
nextReport.workflowStatus = 'Assigned';
nextReport.workflowQueue = 'Field Operations';
nextReport.workflowNextAction = 'Crew should repair the pothole and submit closeout proof.';
nextReport.workflowLastUpdatedAt = sentAt;
return {
...item,
status: 'Assigned',
report: nextReport,
};
});
};
try {
const submittedCases = readJsonStorage('submitted_cases', []);
const updatedSubmittedCases = applyDispatchMetadata(submittedCases);
localStorage.setItem('submitted_cases', JSON.stringify(updatedSubmittedCases));
const seededCases = readJsonStorage(DISPATCH_STORAGE.seed, []);
writeJsonStorage(DISPATCH_STORAGE.seed, applyDispatchMetadata(seededCases));
writeJsonStorage(DISPATCH_STORAGE.plan, planPayload);
let syncedCases = [];
let skippedCaseIds = [];
if (window.PotholeBackend?.submitDispatchPlan) {
const response = await window.PotholeBackend.submitDispatchPlan(planPayload);
syncedCases = Array.isArray(response?.cases) ? response.cases : [];
skippedCaseIds = Array.isArray(response?.skippedCaseIds) ? response.skippedCaseIds : [];
}
if (syncedCases.length && window.PotholeBackend?.mergeCaseLists) {
const mergedSubmittedCases = window.PotholeBackend.mergeCaseLists(
readJsonStorage('submitted_cases', []),
syncedCases
);
localStorage.setItem('submitted_cases', JSON.stringify(mergedSubmittedCases));
writeJsonStorage(DISPATCH_STORAGE.seed, mergedSubmittedCases);
loadMasterPotholes();
recalculateDispatch();
}
refreshDispatchStatusNote();
const syncedCount = syncedCases.length;
const skippedCount = skippedCaseIds.length;
let summary = `Sent ${planPayload.totalStops} routed pothole stop${planPayload.totalStops === 1 ? '' : 's'} to ${activeAssignments.length} crew group${activeAssignments.length === 1 ? '' : 's'}.`;
if (syncedCount || skippedCount) {
summary += ` Database synced ${syncedCount} case${syncedCount === 1 ? '' : 's'}`;
if (skippedCount) {
summary += ` and skipped ${skippedCount} local-only stop${skippedCount === 1 ? '' : 's'}.`;
} else {
summary += '.';
}
}
showToast(summary, 'success');
} catch (error) {
console.error('[Dispatch] failed to send route plan:', error);
showToast(`Could not sync the final crew dispatch package: ${error.message}`, 'error');
} finally {
if (sendButton) {
sendButton.disabled = false;
sendButton.innerHTML = originalSendButtonMarkup;
}
}
}
function recalculateDispatch() {
// 1. Filter active incidents that fall within the covered Wards
const coveredIncidents = activePotholes.filter(p => selectedWards.includes(p.ward));
$('metric-potholes').textContent = coveredIncidents.length;
// Average workload calculation
const avgWorkload = coveredIncidents.length ? (coveredIncidents.length / numCrews).toFixed(1) : 0;
$('metric-workload').textContent = avgWorkload;
// 2. Perform Partitioning & Routing
if (coveredIncidents.length === 0) {
// Clear routes, redraw Wards, and exit
drawWardsOverlay();
plotPotholeMarkers(coveredIncidents, []);
clearRoutes();
renderCrewSidebar([]);
refreshDispatchStatusNote();
return;
}
// K-Means Clustering on Latitude/Longitude
const clusters = partitionPotholes(coveredIncidents, numCrews);
// TSP Routing for each cluster
crewAssignments = [];
clusters.forEach((potholesInCluster, crewIdx) => {
if (!potholesInCluster.length) {
crewAssignments.push({ crewIdx, potholes: [], route: [] });
return;
}
// Solve TSP with Nearest Neighbor
const tspRoute = solveTspNearestNeighbor(potholesInCluster);
crewAssignments.push({
crewIdx,
potholes: potholesInCluster,
route: tspRoute
});
});
// 3. Render map UI elements
drawWardsOverlay();
plotPotholeMarkers(coveredIncidents, crewAssignments);
drawRoadRouteLines(crewAssignments).catch((error) => {
console.warn('[Dispatch] route rendering failed:', error);
});
// 4. Update sidebar list stops
renderCrewSidebar(crewAssignments);
refreshDispatchStatusNote();
// Zoom map to fit all active markers or active region bounds
fitMapToActiveCoverage(coveredIncidents);
focusRequestedDispatchCase();
// Update map badge
$('map-status-overlay').style.display = 'flex';
$('map-status-text').textContent = selectedWards.length === 10 ? 'All Wards Covered' : `${selectedWards.length} Zones Monitored`;
}
// K-Means implementation
function partitionPotholes(potholes, k) {
// If we have fewer potholes than crews, assign 1 pothole per cluster
if (potholes.length <= k) {
const clusters = Array.from({ length: k }, () => []);
potholes.forEach((p, idx) => {
clusters[idx].push(p);
});
return clusters;
}
// Initialize centroids at random pothole coords
let centroids = [];
const shuffled = [...potholes].sort(() => 0.5 - Math.random());
for (let i = 0; i < k; i++) {
centroids.push({ lat: shuffled[i].location.lat, lng: shuffled[i].location.lng });
}
let clusters = Array.from({ length: k }, () => []);
let converged = false;
let iterations = 0;
while (!converged && iterations < 15) {
iterations++;
// Clear clusters
clusters = Array.from({ length: k }, () => []);
// Assign potholes to closest centroid
potholes.forEach(p => {
let minDist = Infinity;
let bestCentroid = 0;
centroids.forEach((c, idx) => {
const dist = getLatLlngDistance(p.location.lat, p.location.lng, c.lat, c.lng);
if (dist < minDist) {
minDist = dist;
bestCentroid = idx;
}
});
clusters[bestCentroid].push(p);
});
// Recompute centroids
let newCentroids = [];
let shifted = false;
clusters.forEach((cList, idx) => {
if (cList.length === 0) {
// Keep previous centroid if no items assigned
newCentroids.push(centroids[idx]);
return;
}
let sumLat = 0, sumLng = 0;
cList.forEach(p => {
sumLat += p.location.lat;
sumLng += p.location.lng;
});
const meanLat = sumLat / cList.length;
const meanLng = sumLng / cList.length;
// Check shift
if (Math.abs(meanLat - centroids[idx].lat) > 0.0001 || Math.abs(meanLng - centroids[idx].lng) > 0.0001) {
shifted = true;
}
newCentroids.push({ lat: meanLat, lng: meanLng });
});
centroids = newCentroids;
if (!shifted) converged = true;
}
return clusters;
}
// TSP Nearest Neighbor Implementation
function solveTspNearestNeighbor(potholes) {
if (potholes.length <= 1) return [...potholes];
const unvisited = [...potholes];
const route = [];
// Determine starting point - closest to centroid or simply the first element
let current = unvisited.splice(0, 1)[0];
route.push(current);
while (unvisited.length > 0) {
let nearestIdx = 0;
let minDist = Infinity;
unvisited.forEach((p, idx) => {
const dist = getLatLlngDistance(current.location.lat, current.location.lng, p.location.lat, p.location.lng);
if (dist < minDist) {
minDist = dist;
nearestIdx = idx;
}
});
current = unvisited.splice(nearestIdx, 1)[0];
route.push(current);
}
return route;
}
// Geographic Euclidean Distance approximation (sufficient for K-Means and local TSP)
function getLatLlngDistance(lat1, lng1, lat2, lng2) {
const dLat = lat1 - lat2;
const dLng = lng1 - lng2;
return Math.sqrt(dLat * dLat + dLng * dLng);
}
// ── DRAW GIS OVERLAYS & MAP MARKERS ──────────────────────────────────────
function drawWardsOverlay() {
// Clear old overlays
mapOverlayWards.forEach(layer => map.removeLayer(layer));
mapOverlayWards = [];
selectedWards.forEach(wName => {
const bounds = REGION_BOUNDS[wName];
if (!bounds) return;
const regionData = window.PHILLY_TEN_REGIONS.find(r => r.name === wName);
const tone = window.getGISBadgeTone({ matched: true });
const layer = L.rectangle(bounds, {
color: tone.dot,
weight: 2,
dashArray: '4, 8',
fillOpacity: 0.04,
fillColor: tone.dot,
interactive: false
}).addTo(map);
mapOverlayWards.push(layer);
});
}
function plotPotholeMarkers(potholes, assignments = crewAssignments) {
// Clear old markers
mapMarkers.forEach(m => map.removeLayer(m));
mapMarkers = [];
const routeLookup = getRouteAssignmentLookup(assignments);
potholes.forEach(p => {
const sizeColors = { Large: '#EF4444', Medium: '#F59E0B', Small: '#10B981' };
const severityColor = sizeColors[p.aiResult] || '#3B82F6';
const routeMeta = routeLookup.get(p.caseId);
const isFocused = p.caseId === requestedFocusCaseId;
const markerSize = routeMeta ? (isFocused ? 30 : 24) : (isFocused ? 20 : 14);
const routeColor = routeMeta ? routeMeta.crewColor : severityColor;
const markerGlow = isFocused
? `0 0 0 4px rgba(255,255,255,0.95), 0 0 0 9px ${routeColor}55, 0 0 18px ${routeColor}`
: `0 0 10px ${routeColor}`;
const focusCallout = isFocused
? `<div style="margin-top:8px;padding:6px 8px;border-radius:8px;background:rgba(59,130,246,0.12);border:1px solid rgba(59,130,246,0.28);color:#60A5FA;font-size:11px;font-weight:700;">Selected from Ops Review</div>`
: '';
const routeSummary = routeMeta
? `<b>Crew Route:</b> ${routeMeta.crewName} Β· Stop ${routeMeta.stopIndex} of ${routeMeta.totalStops}<br>`
: '';
const routeBadge = routeMeta
? `<div style="margin-top:8px;padding:6px 8px;border-radius:8px;background:${routeMeta.crewBg};border:1px solid ${routeMeta.crewColor}40;color:${routeMeta.crewColor};font-size:11px;font-weight:700;">${routeMeta.crewName} Β· Stop ${routeMeta.stopIndex}</div>`
: '';
const markerHtml = routeMeta
? `
<div style="position:relative;width:${markerSize}px;height:${markerSize}px;">
<div style="width:${markerSize}px;height:${markerSize}px;background:${routeMeta.crewColor};border:2px solid #fff;border-radius:50%;box-shadow:${markerGlow};display:flex;align-items:center;justify-content:center;color:#07111F;font-size:${isFocused ? 12 : 10}px;font-weight:900;">
${routeMeta.stopIndex}
</div>
<span style="position:absolute;right:-2px;bottom:-2px;width:10px;height:10px;border-radius:50%;background:${severityColor};border:1.5px solid #fff;"></span>
</div>
`
: `<div style="width:${markerSize}px;height:${markerSize}px;background:${severityColor};border:2px solid #fff;border-radius:50%;box-shadow:${markerGlow}"></div>`;
const pinIcon = L.divIcon({
className: '',
html: markerHtml,
iconSize: [markerSize, markerSize],
iconAnchor: [markerSize / 2, markerSize / 2]
});
const popupContent = `
<div class="popup-title">${p.caseId}</div>
<div class="popup-desc">
<b>Region:</b> ${p.ward} Region<br>
<b>Address:</b> ${p.address}<br>
${routeSummary}<b>AI Severity:</b> <span style="color:${severityColor};font-weight:800;">${p.aiResult}</span>
</div>
${routeBadge}
${focusCallout}
<button class="popup-btn-danger" onclick="removePothole('${p.caseId}')">
Remove from Route
</button>
`;
const marker = L.marker([p.location.lat, p.location.lng], { icon: pinIcon })
.addTo(map)
.bindPopup(popupContent);
// Reference caseId inside marker to allow highlight search
marker.caseId = p.caseId;
marker.routeMeta = routeMeta;
mapMarkers.push(marker);
});
}
function focusRequestedDispatchCase() {
if (!requestedFocusCaseId) return;
const marker = mapMarkers.find((item) => item.caseId === requestedFocusCaseId);
if (!marker) return;
marker.openPopup();
map.setView(marker.getLatLng(), Math.max(map.getZoom(), 15), { animate: true, duration: 0.6 });
requestedFocusCaseId = '';
}
async function drawRoadRouteLines(assignments) {
const renderToken = ++routeRenderToken;
clearRoutes();
const mapsApi = await loadGoogleMapsSdk();
const directionsService = mapsApi?.DirectionsService ? new mapsApi.DirectionsService() : null;
for (const assign of assignments) {
if (assign.route.length < 2) continue;
const { pathPoints, source } = await resolveAssignmentPath(assign, directionsService);
if (renderToken !== routeRenderToken) return;
if (!Array.isArray(pathPoints) || pathPoints.length < 2) continue;
const style = CREW_STYLES[assign.crewIdx % CREW_STYLES.length];
const glowLine = L.polyline(pathPoints, {
color: style.color,
weight: 10,
opacity: 0.18,
lineCap: 'round',
lineJoin: 'round'
}).addTo(map);
const polyline = L.polyline(pathPoints, {
color: style.color,
weight: 5,
opacity: 0.95,
lineCap: 'round',
lineJoin: 'round'
}).addTo(map);
const pathElement = polyline.getElement();
if (pathElement) {
pathElement.style.filter = `drop-shadow(0px 0px 4px ${style.color})`;
pathElement.animate(
[
{ strokeDashoffset: '0' },
{ strokeDashoffset: '-20' }
],
{
duration: 1000,
iterations: Infinity,
easing: 'linear'
}
);
}
const midPoint = getRouteLabelPoint(pathPoints);
if (!midPoint) continue;
const routeLabel = L.marker(midPoint, {
interactive: false,
icon: L.divIcon({
className: '',
html: `<div style="padding:4px 10px;border-radius:999px;background:rgba(7,13,26,0.9);border:1px solid ${style.color}55;color:${style.color};font-size:10px;font-weight:800;box-shadow:0 8px 18px rgba(0,0,0,0.25);white-space:nowrap;">${style.name} &middot; ${assign.route.length} stop${assign.route.length === 1 ? '' : 's'}${source === 'road-route' ? ' &middot; Road Route' : ''}</div>`,
iconSize: [180, 24],
iconAnchor: [90, 12]
})
}).addTo(map);
mapPolylines.push(glowLine, polyline, routeLabel);
}
}
function clearRoutes() {
mapPolylines.forEach(line => map.removeLayer(line));
mapPolylines = [];
}
function fitMapToActiveCoverage(potholes) {
if (potholes.length > 1) {
const points = potholes.map(p => [p.location.lat, p.location.lng]);
map.fitBounds(L.latLngBounds(points), { padding: [40, 40] });
} else if (selectedWards.length === 1 && REGION_BOUNDS[selectedWards[0]]) {
map.fitBounds(REGION_BOUNDS[selectedWards[0]], { padding: [20, 20] });
} else {
// Philly center zoom
map.setView([39.9526, -75.1652], 12);
}
}
// ── SIDEBAR RENDERER stops ───────────────────────────────────────────────
function renderCrewSidebar(assignments) {
const container = $('crew-list-container');
if (!assignments.length) {
container.innerHTML = '<div style="color: var(--text-dim); text-align: center; padding: 20px; font-size: 12px;">No active routes. Select wards to cover.</div>';
return;
}
container.innerHTML = assignments.map(assign => {
const style = CREW_STYLES[assign.crewIdx % CREW_STYLES.length];
const numStops = assign.route.length;
const totalWork = numStops ? `${numStops} Stop${numStops === 1 ? '' : 's'}` : 'Idle';
return `
<div class="crew-card">
<div class="crew-header" onclick="toggleCrewCardCollapse(this)">
<div class="crew-info">
<span class="crew-color-indicator" style="background:${style.color};color:${style.color};"></span>
<span class="crew-name">${style.name}</span>
</div>
<span class="crew-meta" style="color:${style.color};background:${style.bg};">${totalWork}</span>
</div>
<div class="crew-stops">
${numStops === 0 ? `
<div style="font-size:11px;color:var(--text-dim);padding:8px 10px;">Idle β€” no potholes assigned in this zone.</div>
` : assign.route.map((p, idx) => `
<div class="stop-item" onmouseenter="highlightMarker('${p.caseId}', true)" onmouseleave="highlightMarker('${p.caseId}', false)" onclick="zoomToStop([${p.location.lat}, ${p.location.lng}])">
<span class="stop-badge" style="background:${style.color};">${idx + 1}</span>
<div class="stop-info">
<div class="stop-id">${p.caseId}</div>
<div class="stop-address">${p.address}</div>
</div>
</div>
`).join('')}
</div>
</div>
`;
}).join('');
}
function toggleCrewCardCollapse(headerElement) {
const card = headerElement.parentElement;
const stops = card.querySelector('.crew-stops');
if (stops.style.display === 'none') {
stops.style.display = 'flex';
} else {
stops.style.display = 'none';
}
}
// ── INTERACTIVE ROUTE ACTIONS (Highlight/Zoom) ───────────────────────────
function highlightMarker(caseId, isHighlight) {
const marker = mapMarkers.find(m => m.caseId === caseId);
if (!marker) return;
const element = marker.getElement();
if (!element) return;
const sizeColors = { Large: '#EF4444', Medium: '#F59E0B', Small: '#10B981' };
const pothole = activePotholes.find(p => p.caseId === caseId);
const color = sizeColors[pothole?.aiResult] || '#3B82F6';
if (isHighlight) {
element.style.transform += ' scale(1.4)';
element.style.filter = `drop-shadow(0px 0px 8px ${color})`;
element.style.zIndex = '999999';
} else {
element.style.transform = element.style.transform.replace(' scale(1.4)', '');
element.style.filter = `drop-shadow(0px 0px 4px ${color})`;
element.style.zIndex = '1000';
}
}
function zoomToStop(coords) {
map.setView(coords, 16, { animate: true, duration: 0.6 });
}
// ── MAP CLICK INTERACTIVITY: ADD POTHOLE ──────────────────────────────────
function handleMapClickToAdd(e) {
const { lat, lng } = e.latlng;
// 1. Resolve coordinate planning ward
const resolvedGIS = window.resolvePreferredGISMapping
? window.resolvePreferredGISMapping(lat, lng, {}, '')
: window.resolvePhiladelphiaTenRegion(lat, lng);
if (!resolvedGIS || !resolvedGIS.ward) {
showToast('Click point is outside Philadelphia municipal boundaries!', 'error');
return;
}
const clickedWard = resolvedGIS.ward;
// 2. Check if the clicked ward is currently covered/selected
if (!selectedWards.includes(clickedWard)) {
// Beautiful on-map popover telling them they must enable this ward first
const popupContent = `
<div class="popup-add-form" style="max-width:210px;">
<div class="popup-add-title" style="color:#EF4444;">Ward Region Not Covered</div>
<p style="font-size:11px;color:#94A3B8;line-height:1.4;margin-bottom:6px;">
This location is inside the <b>${clickedWard} District</b>, which is currently unassigned.
</p>
<button class="popup-btn-success" style="background:#3B82F6;" onclick="activateAndAddPothole('${clickedWard}', ${lat}, ${lng})">
βœ“ Enable Ward &amp; Add
</button>
</div>
`;
L.popup()
.setLatLng([lat, lng])
.setContent(popupContent)
.openOn(map);
setTimeout(sanitizeDispatchPopupUi, 0);
return;
}
// 3. Inside covered ward: offer standard add form popup
const tempId = `PHL-M-${Math.random().toString(36).substring(2, 6).toUpperCase()}`;
const formContent = `
<div class="popup-add-form" id="map-add-pothole-form">
<div class="popup-add-title">Add Incident to Route</div>
<p style="font-size:10px;color:#94A3B8;margin-bottom:4px;"><b>Region:</b> ${clickedWard}</p>
<div style="display:flex;flex-direction:column;gap:4px;">
<label style="font-size:9px;font-weight:700;color:#64748B;">Pothole Severity</label>
<select class="popup-select" id="add-pothole-severity">
<option value="Medium">Medium β€” Moderate</option>
<option value="Large">Large β€” Major Hazard</option>
<option value="Small">Small β€” Minor</option>
</select>
</div>
<button class="popup-btn-success" style="margin-top:6px;" onclick="confirmAddPothole('${tempId}', '${clickedWard}', ${lat}, ${lng})">
πŸš€ Add to Dispatch
</button>
</div>
`;
L.popup()
.setLatLng([lat, lng])
.setContent(formContent)
.openOn(map);
setTimeout(sanitizeDispatchPopupUi, 0);
}
// Enable an inactive ward and automatically launch standard add pothole popup
window.activateAndAddPothole = function(wardName, lat, lng) {
// Toggle ward checkbox checked
const card = document.querySelector(`.ward-checkbox-card[data-ward="${wardName}"]`);
if (card) {
card.classList.add('checked');
card.querySelector('input').checked = true;
}
if (!selectedWards.includes(wardName)) {
selectedWards.push(wardName);
}
writeJsonStorage(DISPATCH_STORAGE.wards, selectedWards);
$('metric-wards').textContent = selectedWards.length;
// Close map popup, wait 150ms, open the add popup
map.closePopup();
setTimeout(() => {
handleMapClickToAdd({ latlng: { lat, lng } });
}, 150);
};
window.confirmAddPothole = function(caseId, wardName, lat, lng) {
const severity = $('add-pothole-severity').value;
// Attempt reverse address lookup simulated or hardcoded
const address = `Incident at ${lat.toFixed(4)}Β°N, ${lng.toFixed(4)}Β°W (${wardName})`;
const newPothole = {
caseId,
location: { lat, lng },
address,
ward: wardName,
aiResult: severity,
sourceType: 'ad-hoc',
gisMapping: normalizeGISMapping({ ward: wardName, area: wardName, matched: true, configured: true }, wardName),
};
// Add to master
activePotholes.push(newPothole);
const persisted = getPersistedDispatchState();
persisted.adHocPotholes.push(newPothole);
writeJsonStorage(DISPATCH_STORAGE.adHoc, persisted.adHocPotholes);
writeJsonStorage(DISPATCH_STORAGE.removed, persisted.removedCaseIds.filter(id => id !== caseId));
// Close popup
map.closePopup();
// Recalculate everything
recalculateDispatch();
showToast(`Added pothole ${caseId} to routes in ${wardName} Region!`, 'success');
};
// ── REMOVE POTHOLE ACTION ────────────────────────────────────────────────
window.removePothole = function(caseId) {
activePotholes = activePotholes.filter(p => p.caseId !== caseId);
const persisted = getPersistedDispatchState();
const keptAdHoc = (persisted.adHocPotholes || []).filter(p => p.caseId !== caseId);
const removedIds = new Set(persisted.removedCaseIds || []);
removedIds.add(caseId);
writeJsonStorage(DISPATCH_STORAGE.adHoc, keptAdHoc);
writeJsonStorage(DISPATCH_STORAGE.removed, Array.from(removedIds));
// Close map popup
map.closePopup();
// Recalculate
recalculateDispatch();
showToast(`Cleared pothole ${caseId} from routing queue!`, 'info');
};
// ── PRINT ROUTE DIRECTIVES OVERLAY SHEET ──────────────────────────────────
function showPrintPreview() {
const overlay = $('print-sheet-modal');
const container = $('print-routes-container');
const meta = $('print-meta-text');
meta.textContent = `Generated on ${new Date().toLocaleString()} Β· Active Wards: ${selectedWards.join(', ')} Β· Teams Deployed: ${numCrews}`;
let html = '';
crewAssignments.forEach(assign => {
const style = CREW_STYLES[assign.crewIdx % CREW_STYLES.length];
const numStops = assign.route.length;
html += `
<div class="print-crew-section">
<h2 class="print-crew-title" style="color:${style.color};">
<span style="display:inline-block;width:12px;height:12px;background:${style.color};border-radius:50%;"></span>
${style.name} Dispatch Routing (${numStops} Stop${numStops === 1 ? '' : 's'})
</h2>
${numStops === 0 ? `
<p style="font-size:13px;color:var(--text-muted);">No incident stops assigned to this crew group. Status: STANDBY.</p>
` : `
<table class="print-stops-table">
<thead>
<tr>
<th style="width: 80px;">Seq Stop</th>
<th style="width: 160px;">Case Incident ID</th>
<th>Staging Address Location</th>
<th style="width: 120px;">AI Severity</th>
<th style="width: 120px;">Verification</th>
</tr>
</thead>
<tbody>
${assign.route.map((p, idx) => `
<tr>
<td style="font-weight:800;">Stop #${idx + 1}</td>
<td style="font-family:monospace;font-weight:700;">${p.caseId}</td>
<td>${p.address}</td>
<td style="font-weight:700;color:${p.aiResult==='Large'?'#EF4444':(p.aiResult==='Medium'?'#F59E0B':'#10B981')}">${p.aiResult} Severity</td>
<td style="color:#64748B;">[ ] Patched</td>
</tr>
`).join('')}
</tbody>
</table>
`}
</div>
`;
});
container.innerHTML = html;
overlay.style.display = 'block';
}
// Boot Command Center
document.addEventListener('DOMContentLoaded', async () => {
try {
await window.PotholeBackend?.hydrateCasesIntoLocalStorage?.('submitted_cases');
} catch (err) {
console.warn('[Dispatch] backend hydration failed:', err);
}
initApp();
if (!shouldAutoLaunchFromRequest) {
$('setup-modal').style.display = 'flex';
}
});
</script>
</body>
</html>