From a636e23568d13336e5b87fcec01038eb5989132a Mon Sep 17 00:00:00 2001 From: Satyam <95536056+Satyam-Rastogi@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:13:51 +0530 Subject: [PATCH] =?UTF-8?q?feat(dispatch):=20Phase=204=20=E2=80=94=20Leafl?= =?UTF-8?q?et=20map=20panel=20with=20rep=20markers,=20lead=20pins,=20route?= =?UTF-8?q?=20polylines,=20and=20storm=20polygon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Real Plano TX map using Leaflet with dark/light tile layer filter - Custom DivIcon rep markers (initials circles, pulsing ring on top AI pick) - Urgency-colored lead pins with radar rings on selected lead - Route polyline with stroke-dashoffset draw animation keyed to selected lead - Travel time chip at route midpoint — solid color background, white text, triple ring shadow - Storm polygon overlay (NE Plano) with Storm Zone Active badge - Route Active chip top-right, legend overlay bottom-left (rep status + lead urgency counts) - Hover tooltips on rep markers (name, status, slots, rating/close%) and lead pins (name, urgency, address, notes snippet) - invalidateSize() with 50ms defer + window resize listener to fix blank tile area after flex layout settles --- src/components/dispatch/DispatchMapPanel.jsx | 427 +++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 src/components/dispatch/DispatchMapPanel.jsx 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: ` +
+ ${radar} +
+
`, + 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;