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'; // Shared color taxonomy — same source of truth the Territory Map reads from, // so pin colors stay consistent across the app. import { REP_STATUS_COLORS, REP_STATUS_LABELS, URGENCY_COLORS } from '../../data/statusTaxonomy'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const PLANO_CENTER = [33.055, -96.752]; const DEFAULT_ZOOM = 12; // 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. // On mobile the map panel is inside a CollapsePanel that animates // height 0→auto over 220ms, so we need to wait past that before calling // invalidateSize, otherwise Leaflet initialises at near-zero height. useEffect(() => { const invalidate = () => map.invalidateSize({ animate: false }); // 50ms covers desktop (no animation). 320ms covers the 220ms collapse // animation with margin so tiles fill correctly on mobile/tablet. const t1 = setTimeout(invalidate, 50); const t2 = setTimeout(invalidate, 320); window.addEventListener('resize', invalidate); return () => { clearTimeout(t1); clearTimeout(t2); 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, mapHeight }) => { 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; // Explicit pixel height on both the wrapper div and MapContainer. // Do NOT use h-full / height:100% — the CollapsePanel ancestor has // height:auto so the percentage chain resolves to 0. Inline px values // bypass the chain entirely and give Leaflet a reliable offsetHeight. // mapHeight prop is computed responsively in LynkDispatchPage (300–520px). const mapH = isDesktop ? 'calc(72rem + 2.375rem)' : (mapHeight || '300px'); 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 — top-left (kept in view; the desktop map is ~1190px tall, so a bottom-anchored legend sat far below the fold) ── */}
{/* 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;