436 lines
20 KiB
React
436 lines
20 KiB
React
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
|
||
? `<div style="position:absolute;inset:-5px;border-radius:50%;border:2px solid ${color};opacity:0.5;animation:repRing 1.6s ease-out infinite;"></div>`
|
||
: '';
|
||
return L.divIcon({
|
||
html: `
|
||
<div style="position:relative;width:${size}px;height:${size}px;">
|
||
${ring}
|
||
<div style="
|
||
width:${size}px;height:${size}px;border-radius:50%;
|
||
background:${color}DD;border:2.5px solid white;
|
||
display:flex;align-items:center;justify-content:center;
|
||
font-size:${isTopPick ? 12 : 11}px;font-weight:900;color:white;
|
||
font-family:system-ui,sans-serif;
|
||
box-shadow:0 3px 10px ${color}60,0 1px 3px rgba(0,0,0,0.25);
|
||
transition:transform 0.2s;
|
||
">${rep.initials}</div>
|
||
<div style="
|
||
position:absolute;bottom:-3px;right:-3px;
|
||
width:10px;height:10px;border-radius:50%;
|
||
background:${color};border:2px solid white;
|
||
"></div>
|
||
</div>`,
|
||
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
|
||
? `<div style="position:absolute;inset:-7px;border-radius:50%;border:2px solid ${color};animation:radarPing 1.4s ease-out infinite;"></div>
|
||
<div style="position:absolute;inset:-14px;border-radius:50%;border:1.5px solid ${color};animation:radarPing 1.4s ease-out 0.4s infinite;"></div>`
|
||
: '';
|
||
|
||
return L.divIcon({
|
||
html: `
|
||
<div style="position:relative;width:${size}px;height:${size}px;${pulse && !isSelected ? 'animation:dotPulse 1.6s ease-in-out infinite;' : ''}">
|
||
${radar}
|
||
<div style="
|
||
width:${size}px;height:${size}px;border-radius:50%;
|
||
background:${color};border:2px solid white;
|
||
box-shadow:0 2px 6px ${color}80;
|
||
"></div>
|
||
</div>`,
|
||
className: '',
|
||
iconSize: [size, size],
|
||
iconAnchor: [size / 2, size / 2],
|
||
});
|
||
};
|
||
|
||
const createTravelChipIcon = (text, color) => {
|
||
const label = `⏱ ${text}`;
|
||
return L.divIcon({
|
||
html: `
|
||
<div style="
|
||
background:${color};
|
||
border-radius:999px;
|
||
padding:7px 16px;
|
||
font-size:15px;
|
||
font-weight:900;
|
||
color:#ffffff;
|
||
box-shadow:0 4px 16px rgba(0,0,0,0.45), 0 0 0 3px white, 0 0 0 5px ${color};
|
||
white-space:nowrap;
|
||
font-family:system-ui,sans-serif;
|
||
letter-spacing:0.2px;
|
||
text-shadow:0 1px 3px rgba(0,0,0,0.35);
|
||
pointer-events:none;
|
||
">${label}</div>`,
|
||
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 (
|
||
<div className="relative overflow-hidden" style={{ height: mapH }}>
|
||
{/* Inject map animations */}
|
||
<style>{MAP_STYLES}</style>
|
||
|
||
<MapContainer
|
||
center={PLANO_CENTER}
|
||
zoom={DEFAULT_ZOOM}
|
||
scrollWheelZoom={true}
|
||
zoomControl={false}
|
||
className="w-full"
|
||
style={{ height: mapH, width: '100%' }}
|
||
>
|
||
{/* Tile layer — dark/light aware */}
|
||
<TileLayer
|
||
key={theme}
|
||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||
className={
|
||
theme === 'dark'
|
||
? 'map-tiles filter invert grayscale contrast-100'
|
||
: 'map-tiles'
|
||
}
|
||
/>
|
||
|
||
{/* Fit bounds on lead selection */}
|
||
<MapController
|
||
selectedLeadId={selectedLead?.id}
|
||
leadPos={leadPos}
|
||
repPos={repPos}
|
||
routePath={topRec?.routePath}
|
||
/>
|
||
|
||
{/* Storm polygon */}
|
||
{stormMode && (
|
||
<Polygon
|
||
positions={STORM_POLYGON}
|
||
pathOptions={{
|
||
color: '#F59E0B',
|
||
fillColor: '#F59E0B',
|
||
fillOpacity: 0.13,
|
||
weight: 2,
|
||
dashArray: '6 5',
|
||
opacity: 0.7,
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Route polyline — animates in with stroke-dashoffset draw */}
|
||
{topRec && (
|
||
<Polyline
|
||
key={selectedLead.id}
|
||
positions={topRec.routePath}
|
||
pathOptions={{
|
||
color: routeColor,
|
||
weight: 3.5,
|
||
opacity: 0.85,
|
||
className: 'dispatch-route',
|
||
lineCap: 'round',
|
||
lineJoin: 'round',
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 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 (
|
||
<Marker
|
||
key={lead.id}
|
||
position={[lead.property.lat, lead.property.lng]}
|
||
icon={createLeadIcon(lead, selectedLead?.id === lead.id)}
|
||
>
|
||
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="dispatch-tooltip">
|
||
<div style={{ fontFamily: 'system-ui,sans-serif', minWidth: 150, maxWidth: 220 }}>
|
||
<p style={{ fontSize: 11, fontWeight: 700, color: '#18181b', marginBottom: 2 }}>
|
||
{lead.customer.name}
|
||
</p>
|
||
<p style={{ fontSize: 10, fontWeight: 700, color: urgColor, marginBottom: 2 }}>
|
||
{lead.urgency.toUpperCase()}
|
||
</p>
|
||
<p style={{ fontSize: 10, color: '#71717a', marginBottom: notesSnip ? 4 : 0 }}>
|
||
{lead.property.address}
|
||
</p>
|
||
{notesSnip && (
|
||
<p style={{ fontSize: 9, color: '#a1a1aa', fontStyle: 'italic', lineHeight: 1.4, borderTop: '1px solid #f0f0f0', paddingTop: 4 }}>
|
||
{notesSnip}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</Tooltip>
|
||
</Marker>
|
||
);
|
||
})}
|
||
|
||
{/* Rep markers */}
|
||
{reps.map(rep => {
|
||
const repColor = REP_STATUS_COLORS[rep.status] || '#6B7280';
|
||
return (
|
||
<Marker
|
||
key={rep.id}
|
||
position={[rep.currentLat, rep.currentLng]}
|
||
icon={createRepIcon(rep, topRep?.id === rep.id)}
|
||
>
|
||
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="dispatch-tooltip">
|
||
<div style={{ fontFamily: 'system-ui,sans-serif', minWidth: 140 }}>
|
||
<p style={{ fontSize: 11, fontWeight: 800, color: repColor, marginBottom: 3 }}>
|
||
{rep.name}
|
||
</p>
|
||
<p style={{ fontSize: 10, color: '#71717a', marginBottom: 1 }}>
|
||
{REP_STATUS_LABELS[rep.status]} · {rep.todayAppointments}/{rep.maxDaily} slots
|
||
</p>
|
||
<p style={{ fontSize: 10, color: '#71717a' }}>
|
||
★ {rep.rating} · {rep.closeRate}% close
|
||
</p>
|
||
</div>
|
||
</Tooltip>
|
||
</Marker>
|
||
);
|
||
})}
|
||
|
||
{/* Travel time chip at route midpoint */}
|
||
{topRec && routeMid && driveMinutes && (
|
||
<Marker
|
||
key={`chip-${selectedLead.id}`}
|
||
position={routeMid}
|
||
icon={createTravelChipIcon(`~${driveMinutes} min`, routeColor)}
|
||
/>
|
||
)}
|
||
</MapContainer>
|
||
|
||
{/* ── Legend overlay — top-left (kept in view; the desktop map is
|
||
~1190px tall, so a bottom-anchored legend sat far below the fold) ── */}
|
||
<div className="absolute top-3 left-3 z-[1000] flex flex-col gap-1.5 pointer-events-none">
|
||
{/* Rep status legend */}
|
||
<div className="rounded-xl border bg-white/90 dark:bg-zinc-900/85 backdrop-blur-md border-zinc-200/80 dark:border-white/[0.08] px-2.5 py-2 shadow-lg">
|
||
<p className="text-[9px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500 mb-1.5">Reps</p>
|
||
<div className="space-y-1">
|
||
{Object.entries(REP_STATUS_COLORS).map(([status, color]) => (
|
||
<div key={status} className="flex items-center gap-1.5">
|
||
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||
<span className="text-[10px] font-medium text-zinc-600 dark:text-zinc-300 capitalize">
|
||
{REP_STATUS_LABELS[status]}
|
||
</span>
|
||
<span className="text-[10px] text-zinc-400 ml-1">
|
||
{DISPATCH_REPS.filter(r => r.status === status).length}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Lead urgency legend */}
|
||
<div className="rounded-xl border bg-white/90 dark:bg-zinc-900/85 backdrop-blur-md border-zinc-200/80 dark:border-white/[0.08] px-2.5 py-2 shadow-lg">
|
||
<p className="text-[9px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500 mb-1.5">Leads</p>
|
||
<div className="space-y-1">
|
||
{Object.entries(URGENCY_COLORS).map(([urgency, color]) => (
|
||
<div key={urgency} className="flex items-center gap-1.5">
|
||
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||
<span className="text-[10px] font-medium text-zinc-600 dark:text-zinc-300 capitalize">
|
||
{urgency}
|
||
</span>
|
||
<span className="text-[10px] text-zinc-400 ml-1">
|
||
{dispatchLeads.filter(l => l.urgency === urgency).length}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Storm mode overlay label */}
|
||
{stormMode && (
|
||
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[1000] pointer-events-none">
|
||
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/90 backdrop-blur-sm shadow-lg">
|
||
<span className="w-1.5 h-1.5 rounded-full bg-white animate-pulse" />
|
||
<span className="text-[10px] font-black uppercase tracking-widest text-white">
|
||
Storm Zone Active
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Selected lead info chip — top right */}
|
||
{selectedLead && topRep && (
|
||
<div className="absolute top-3 right-3 z-[1000] pointer-events-none">
|
||
<div
|
||
className="rounded-xl border px-3 py-2 bg-white/92 dark:bg-zinc-900/85 backdrop-blur-md shadow-lg border-zinc-200/80 dark:border-white/[0.08]"
|
||
style={{ borderLeftWidth: 3, borderLeftColor: routeColor }}
|
||
>
|
||
<p className="text-[9px] font-black uppercase tracking-widest mb-0.5" style={{ color: routeColor }}>
|
||
Route Active
|
||
</p>
|
||
<p className="text-[11px] font-bold text-zinc-900 dark:text-white">{topRep.name}</p>
|
||
<p className="text-[10px] text-zinc-400 mt-0.5 truncate max-w-[140px]">
|
||
→ {selectedLead.property.address}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default DispatchMapPanel;
|