Files
LynkedUpPro_CRM/src/components/dispatch/DispatchMapPanel.jsx
T

436 lines
20 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `&#x23F1;&nbsp;${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 (300520px).
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='&copy; <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;