Files
LynkedUpPro_CRM/src/components/dispatch/DispatchMapPanel.jsx
T
Satyam 9be8ec911f fix(dispatch): fix Leaflet map blank/partial render on mobile and tablet
- DispatchMapPanel: replace h-full/height:100% with explicit inline px height
  on both wrapper div and MapContainer — CollapsePanel uses height:auto which
  makes percentage heights resolve to 0 giving Leaflet a zero-size canvas
- DispatchMapPanel: accept mapHeight prop from parent for responsive sizing
- LynkDispatchPage: compute responsive mapHeight state (300px mobile →
  380px sm → 460px md → 520px lg tablet) updated on window resize; passed
  as prop to DispatchMapPanel on all non-desktop renders
- LynkDispatchPage: add missing reps={effectiveReps} prop on mobile map render
  so live rep status cycling is reflected on all breakpoints
- LynkDispatchPage: add missing onQuickAssign={handleAssign} on desktop lead
  queue render so Quick Assign button works on desktop
- LynkDispatchPage: add whitespace-nowrap to efficiency badge to prevent
  '89% AI-Dispatched' wrapping to two lines in header
- LynkDispatchPage: dispatch synthetic resize event 260ms after map panel
  expands on mobile/tablet so Leaflet invalidateSize fires post-animation
- DispatchMapPanel: add second invalidateSize call at 320ms (belt-and-suspenders
  alongside 50ms) to cover 220ms CollapsePanel animation on slower devices
- DispatchLeadQueue: enrich DROP_POOL leads with phone, email, source
  sub-details and notes so Lead Quick View shows complete data on drop-in leads
2026-03-26 14:31:36 +05:30

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';
// ---------------------------------------------------------------------------
// 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
? `<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 — bottom-left ── */}
<div className="absolute bottom-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;