diff --git a/src/components/dispatch/DispatchMapPanel.jsx b/src/components/dispatch/DispatchMapPanel.jsx
new file mode 100644
index 0000000..216d28b
--- /dev/null
+++ b/src/components/dispatch/DispatchMapPanel.jsx
@@ -0,0 +1,427 @@
+import React, { useEffect, useRef } from 'react';
+import { MapContainer, TileLayer, Marker, Polyline, Polygon, Tooltip, useMap } from 'react-leaflet';
+import L from 'leaflet';
+import 'leaflet/dist/leaflet.css';
+import { useTheme } from '../../context/ThemeContext';
+import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+const PLANO_CENTER = [33.055, -96.752];
+const DEFAULT_ZOOM = 12;
+
+const REP_STATUS_COLORS = { available: '#10B981', en_route: '#3B82F6', busy: '#F59E0B' };
+const REP_STATUS_LABELS = { available: 'Available', en_route: 'En Route', busy: 'Busy' };
+const URGENCY_COLORS = { emergency: '#EF4444', high: '#F59E0B', standard: '#6B7280' };
+
+// Storm polygon — northeast Plano, sweeping toward central
+const STORM_POLYGON = [
+ [33.105, -96.745], [33.100, -96.695], [33.078, -96.682],
+ [33.051, -96.698], [33.042, -96.725], [33.058, -96.750],
+ [33.080, -96.758],
+];
+
+// ---------------------------------------------------------------------------
+// Custom marker factories
+// ---------------------------------------------------------------------------
+const createRepIcon = (rep, isTopPick) => {
+ const color = REP_STATUS_COLORS[rep.status] || '#6B7280';
+ const size = isTopPick ? 42 : 36;
+ const ring = isTopPick
+ ? `
`
+ : '';
+ return L.divIcon({
+ html: `
+
+ ${ring}
+
${rep.initials}
+
+
`,
+ className: '',
+ iconSize: [size, size],
+ iconAnchor: [size / 2, size / 2],
+ });
+};
+
+const createLeadIcon = (lead, isSelected) => {
+ const color = URGENCY_COLORS[lead.urgency] || '#6B7280';
+ const size = isSelected ? 22 : 14;
+ const pulse = lead.urgency !== 'standard';
+ const radar = isSelected
+ ? `
+ `
+ : '';
+
+ return L.divIcon({
+ html: `
+ `,
+ className: '',
+ iconSize: [size, size],
+ iconAnchor: [size / 2, size / 2],
+ });
+};
+
+const createTravelChipIcon = (text, color) => {
+ const label = `⏱ ${text}`;
+ return L.divIcon({
+ html: `
+ ${label}
`,
+ className: '',
+ iconAnchor: [56, 20],
+ });
+};
+
+// ---------------------------------------------------------------------------
+// MapController — fits bounds when lead selection changes + fixes tile gaps
+// ---------------------------------------------------------------------------
+const MapController = ({ selectedLeadId, leadPos, repPos, routePath }) => {
+ const map = useMap();
+ const prevId = useRef(null);
+
+ // Invalidate size on mount and on every window resize so Leaflet fills
+ // the full flex container — prevents the blank tile area on the right.
+ useEffect(() => {
+ const invalidate = () => map.invalidateSize({ animate: false });
+ // Defer one frame so the flex layout has fully settled
+ const t = setTimeout(invalidate, 50);
+ window.addEventListener('resize', invalidate);
+ return () => {
+ clearTimeout(t);
+ window.removeEventListener('resize', invalidate);
+ };
+ }, [map]);
+
+ useEffect(() => {
+ if (selectedLeadId === prevId.current) return;
+ prevId.current = selectedLeadId;
+
+ if (!leadPos || !repPos) return;
+
+ const positions = [leadPos, repPos, ...(routePath || [])].filter(Boolean);
+ if (positions.length < 2) return;
+
+ map.fitBounds(L.latLngBounds(positions), {
+ padding: [72, 72],
+ maxZoom: 14,
+ animate: true,
+ duration: 0.7,
+ });
+ }, [selectedLeadId]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return null;
+};
+
+// ---------------------------------------------------------------------------
+// CSS animations — injected as a style block
+// ---------------------------------------------------------------------------
+const MAP_STYLES = `
+ .dispatch-route {
+ stroke-dasharray: 5000;
+ stroke-dashoffset: 5000;
+ animation: drawRoute 1.1s cubic-bezier(0.4,0,0.2,1) forwards;
+ }
+ @keyframes drawRoute {
+ to { stroke-dashoffset: 0; }
+ }
+ @keyframes radarPing {
+ 0% { transform: scale(1); opacity: 0.75; }
+ 100% { transform: scale(1); opacity: 0; }
+ 0% { transform: scale(1); }
+ 100% { transform: scale(2.4); opacity: 0; }
+ }
+ @keyframes dotPulse {
+ 0%, 100% { transform: scale(1); opacity: 1; }
+ 50% { transform: scale(1.3); opacity: 0.7; }
+ }
+ @keyframes repRing {
+ 0% { transform: scale(1); opacity: 0.6; }
+ 100% { transform: scale(1.8); opacity: 0; }
+ }
+ .leaflet-container { font-family: system-ui, sans-serif; }
+ .dispatch-tooltip {
+ background: white;
+ border: 1px solid rgba(0,0,0,0.08);
+ border-radius: 10px;
+ box-shadow: 0 4px 16px rgba(0,0,0,0.12);
+ padding: 8px 10px;
+ pointer-events: none;
+ }
+ .dispatch-tooltip::before { display: none; }
+ .leaflet-tooltip.dispatch-tooltip { white-space: nowrap; }
+`;
+
+// ---------------------------------------------------------------------------
+// Main Component
+// ---------------------------------------------------------------------------
+const DispatchMapPanel = ({ selectedLead, dispatchLeads, reps = DISPATCH_REPS, stormMode, accent, isDesktop }) => {
+ const { theme } = useTheme();
+
+ // Top recommendation for the selected lead
+ const topRec = selectedLead ? (DISPATCH_RECOMMENDATIONS[selectedLead.id]?.[0] ?? null) : null;
+ const topRep = topRec ? reps.find(r => r.id === topRec.repId) : null;
+
+ // Positions for bounds fitting
+ const leadPos = selectedLead
+ ? [selectedLead.property.lat, selectedLead.property.lng]
+ : null;
+ const repPos = topRep
+ ? [topRep.currentLat, topRep.currentLng]
+ : null;
+
+ // Travel time chip — midpoint of route
+ const routeMid = topRec?.routePath?.length
+ ? topRec.routePath[Math.floor(topRec.routePath.length / 2)]
+ : null;
+
+ // Derive estimated drive minutes from top rec factors (driveTime score → minutes)
+ const driveMinutes = topRec
+ ? Math.round(8 + ((100 - topRec.factors.driveTime) / 100) * 22)
+ : null;
+
+ const routeColor = stormMode ? '#F59E0B' : accent;
+
+ const containerStyle = isDesktop ? { height: 'calc(72rem + 2.375rem)' } : undefined;
+ const containerClass = !isDesktop
+ ? 'min-h-[280px] sm:min-h-[360px] md:min-h-[420px]'
+ : '';
+
+ return (
+
+ {/* Inject map animations */}
+
+
+
+ {/* Tile layer — dark/light aware */}
+
+
+ {/* Fit bounds on lead selection */}
+
+
+ {/* Storm polygon */}
+ {stormMode && (
+
+ )}
+
+ {/* Route polyline — animates in with stroke-dashoffset draw */}
+ {topRec && (
+
+ )}
+
+ {/* Lead pins */}
+ {dispatchLeads.map(lead => {
+ const urgColor = URGENCY_COLORS[lead.urgency] || '#6B7280';
+ const notesSnip = lead.notes
+ ? (lead.notes.length > 55 ? lead.notes.slice(0, 55) + '…' : lead.notes)
+ : null;
+ return (
+
+
+
+
+ {lead.customer.name}
+
+
+ {lead.urgency.toUpperCase()}
+
+
+ {lead.property.address}
+
+ {notesSnip && (
+
+ {notesSnip}
+
+ )}
+
+
+
+ );
+ })}
+
+ {/* Rep markers */}
+ {reps.map(rep => {
+ const repColor = REP_STATUS_COLORS[rep.status] || '#6B7280';
+ return (
+
+
+
+
+ {rep.name}
+
+
+ {REP_STATUS_LABELS[rep.status]} · {rep.todayAppointments}/{rep.maxDaily} slots
+
+
+ ★ {rep.rating} · {rep.closeRate}% close
+
+
+
+
+ );
+ })}
+
+ {/* Travel time chip at route midpoint */}
+ {topRec && routeMid && driveMinutes && (
+
+ )}
+
+
+ {/* ── Legend overlay — bottom-left ── */}
+
+ {/* Rep status legend */}
+
+
Reps
+
+ {Object.entries(REP_STATUS_COLORS).map(([status, color]) => (
+
+
+
+ {REP_STATUS_LABELS[status]}
+
+
+ {DISPATCH_REPS.filter(r => r.status === status).length}
+
+
+ ))}
+
+
+
+ {/* Lead urgency legend */}
+
+
Leads
+
+ {Object.entries(URGENCY_COLORS).map(([urgency, color]) => (
+
+
+
+ {urgency}
+
+
+ {dispatchLeads.filter(l => l.urgency === urgency).length}
+
+
+ ))}
+
+
+
+
+ {/* Storm mode overlay label */}
+ {stormMode && (
+
+
+
+
+ Storm Zone Active
+
+
+
+ )}
+
+ {/* Selected lead info chip — top right */}
+ {selectedLead && topRep && (
+
+
+
+ Route Active
+
+
{topRep.name}
+
+ → {selectedLead.property.address}
+
+
+
+ )}
+
+ );
+};
+
+export default DispatchMapPanel;