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
This commit is contained in:
@@ -56,23 +56,30 @@ const getAgingState = (arrivedMinsAgo, status) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Fake leads that "drop in" every 20s during demo — cycled through pool
|
// Fake leads that "drop in" every 20s during demo — cycled through pool
|
||||||
|
// Keep these fully enriched so Lead Quick View shows complete detail
|
||||||
const DROP_POOL = [
|
const DROP_POOL = [
|
||||||
{
|
{
|
||||||
source: 'call_center', leadType: 'roof_inspection', urgency: 'high',
|
source: 'call_center', leadType: 'roof_inspection', urgency: 'high',
|
||||||
customer: { name: 'Marcus Webb' },
|
customer: { name: 'Marcus Webb', phone: '(972) 555-2201', email: 'marcus.webb@gmail.com' },
|
||||||
property: { address: '1620 Mapleshade Ln', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0545, lng: -96.7634 },
|
property: { address: '1620 Mapleshade Ln', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0545, lng: -96.7634 },
|
||||||
|
callCenter: { id: 'CC-02', name: 'DFW Ops Hub' },
|
||||||
|
notes: 'Homeowner noticed missing shingles after last week\'s storm. No active leak reported but wants inspection before weekend rain.',
|
||||||
estimatedDuration: 45, status: 'unassigned', arrivedMinsAgo: 0, assignedRepId: null,
|
estimatedDuration: 45, status: 'unassigned', arrivedMinsAgo: 0, assignedRepId: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: 'website_form', leadType: 'emergency_tarp', urgency: 'emergency',
|
source: 'website_form', leadType: 'emergency_tarp', urgency: 'emergency',
|
||||||
customer: { name: 'Sandra Kim' },
|
customer: { name: 'Sandra Kim', phone: '(972) 555-2202', email: 'sandra.kim@icloud.com' },
|
||||||
property: { address: '3488 Midway Rd', city: 'Plano', state: 'TX', zip: '75093', lat: 33.0712, lng: -96.8090 },
|
property: { address: '3488 Midway Rd', city: 'Plano', state: 'TX', zip: '75093', lat: 33.0712, lng: -96.8090 },
|
||||||
|
webSource: { campaign: 'Google Ads — Emergency Storm Response' },
|
||||||
|
notes: 'Tree limb through garage roof — active water ingress reported. Customer is extremely stressed. Dispatch immediately.',
|
||||||
estimatedDuration: 90, status: 'unassigned', arrivedMinsAgo: 0, assignedRepId: null,
|
estimatedDuration: 90, status: 'unassigned', arrivedMinsAgo: 0, assignedRepId: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: 'canvassing', leadType: 'insurance_claim', urgency: 'high',
|
source: 'canvassing', leadType: 'insurance_claim', urgency: 'high',
|
||||||
customer: { name: 'David Okonkwo' },
|
customer: { name: 'David Okonkwo', phone: '(972) 555-2203', email: 'd.okonkwo@outlook.com' },
|
||||||
property: { address: '892 McDermott Dr', city: 'Plano', state: 'TX', zip: '75024', lat: 33.0921, lng: -96.7812 },
|
property: { address: '892 McDermott Dr', city: 'Plano', state: 'TX', zip: '75024', lat: 33.0921, lng: -96.7812 },
|
||||||
|
canvassedBy: { agentId: 'U-09', name: 'Kenji Flores' },
|
||||||
|
notes: null,
|
||||||
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 0, assignedRepId: null,
|
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 0, assignedRepId: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -113,13 +113,19 @@ const MapController = ({ selectedLeadId, leadPos, repPos, routePath }) => {
|
|||||||
|
|
||||||
// Invalidate size on mount and on every window resize so Leaflet fills
|
// Invalidate size on mount and on every window resize so Leaflet fills
|
||||||
// the full flex container — prevents the blank tile area on the right.
|
// 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(() => {
|
useEffect(() => {
|
||||||
const invalidate = () => map.invalidateSize({ animate: false });
|
const invalidate = () => map.invalidateSize({ animate: false });
|
||||||
// Defer one frame so the flex layout has fully settled
|
// 50ms covers desktop (no animation). 320ms covers the 220ms collapse
|
||||||
const t = setTimeout(invalidate, 50);
|
// animation with margin so tiles fill correctly on mobile/tablet.
|
||||||
|
const t1 = setTimeout(invalidate, 50);
|
||||||
|
const t2 = setTimeout(invalidate, 320);
|
||||||
window.addEventListener('resize', invalidate);
|
window.addEventListener('resize', invalidate);
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(t);
|
clearTimeout(t1);
|
||||||
|
clearTimeout(t2);
|
||||||
window.removeEventListener('resize', invalidate);
|
window.removeEventListener('resize', invalidate);
|
||||||
};
|
};
|
||||||
}, [map]);
|
}, [map]);
|
||||||
@@ -186,7 +192,7 @@ const MAP_STYLES = `
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main Component
|
// Main Component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const DispatchMapPanel = ({ selectedLead, dispatchLeads, reps = DISPATCH_REPS, stormMode, accent, isDesktop }) => {
|
const DispatchMapPanel = ({ selectedLead, dispatchLeads, reps = DISPATCH_REPS, stormMode, accent, isDesktop, mapHeight }) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
// Top recommendation for the selected lead
|
// Top recommendation for the selected lead
|
||||||
@@ -213,13 +219,15 @@ const DispatchMapPanel = ({ selectedLead, dispatchLeads, reps = DISPATCH_REPS, s
|
|||||||
|
|
||||||
const routeColor = stormMode ? '#F59E0B' : accent;
|
const routeColor = stormMode ? '#F59E0B' : accent;
|
||||||
|
|
||||||
const containerStyle = isDesktop ? { height: 'calc(72rem + 2.375rem)' } : undefined;
|
// Explicit pixel height on both the wrapper div and MapContainer.
|
||||||
const containerClass = !isDesktop
|
// Do NOT use h-full / height:100% — the CollapsePanel ancestor has
|
||||||
? 'min-h-[280px] sm:min-h-[360px] md:min-h-[420px]'
|
// 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 (
|
return (
|
||||||
<div className={`relative overflow-hidden ${containerClass}`} style={containerStyle}>
|
<div className="relative overflow-hidden" style={{ height: mapH }}>
|
||||||
{/* Inject map animations */}
|
{/* Inject map animations */}
|
||||||
<style>{MAP_STYLES}</style>
|
<style>{MAP_STYLES}</style>
|
||||||
|
|
||||||
@@ -228,8 +236,8 @@ const DispatchMapPanel = ({ selectedLead, dispatchLeads, reps = DISPATCH_REPS, s
|
|||||||
zoom={DEFAULT_ZOOM}
|
zoom={DEFAULT_ZOOM}
|
||||||
scrollWheelZoom={true}
|
scrollWheelZoom={true}
|
||||||
zoomControl={false}
|
zoomControl={false}
|
||||||
className="w-full h-full"
|
className="w-full"
|
||||||
style={{ height: '100%', width: '100%' }}
|
style={{ height: mapH, width: '100%' }}
|
||||||
>
|
>
|
||||||
{/* Tile layer — dark/light aware */}
|
{/* Tile layer — dark/light aware */}
|
||||||
<TileLayer
|
<TileLayer
|
||||||
|
|||||||
@@ -162,8 +162,23 @@ const LynkDispatchPage = () => {
|
|||||||
const [isDesktop, setIsDesktop] = useState(() => window.innerWidth >= 1280);
|
const [isDesktop, setIsDesktop] = useState(() => window.innerWidth >= 1280);
|
||||||
const aiPanelRef = useRef(null);
|
const aiPanelRef = useRef(null);
|
||||||
|
|
||||||
|
// Responsive map height for non-desktop — explicit px needed so Leaflet
|
||||||
|
// gets a reliable offsetHeight regardless of CollapsePanel's height:auto chain
|
||||||
|
const calcMapHeight = () => {
|
||||||
|
const w = window.innerWidth;
|
||||||
|
if (w >= 1280) return null; // desktop: DispatchMapPanel handles its own height
|
||||||
|
if (w >= 1024) return '520px'; // iPad Pro 11" landscape / large tablet
|
||||||
|
if (w >= 768) return '460px'; // iPad Air / md tablet
|
||||||
|
if (w >= 640) return '380px'; // small tablet / large phone landscape
|
||||||
|
return '300px'; // mobile portrait
|
||||||
|
};
|
||||||
|
const [mapHeight, setMapHeight] = useState(calcMapHeight);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => setIsDesktop(window.innerWidth >= 1280);
|
const handler = () => {
|
||||||
|
setIsDesktop(window.innerWidth >= 1280);
|
||||||
|
setMapHeight(calcMapHeight());
|
||||||
|
};
|
||||||
window.addEventListener('resize', handler);
|
window.addEventListener('resize', handler);
|
||||||
return () => window.removeEventListener('resize', handler);
|
return () => window.removeEventListener('resize', handler);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -179,6 +194,16 @@ const LynkDispatchPage = () => {
|
|||||||
const dragLeft = useCallback((delta) => setLeftWidth(w => Math.min(440, Math.max(220, w + delta))), []);
|
const dragLeft = useCallback((delta) => setLeftWidth(w => Math.min(440, Math.max(220, w + delta))), []);
|
||||||
const dragRight = useCallback((delta) => setRightWidth(w => Math.min(440, Math.max(260, w - delta))), []);
|
const dragRight = useCallback((delta) => setRightWidth(w => Math.min(440, Math.max(260, w - delta))), []);
|
||||||
|
|
||||||
|
// When the map panel expands on mobile/tablet, fire a synthetic resize
|
||||||
|
// after the CollapsePanel animation (220ms) completes so Leaflet
|
||||||
|
// invalidateSize() in MapController gets a correct container height.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!collapsed.map && !isDesktop) {
|
||||||
|
const t = setTimeout(() => window.dispatchEvent(new Event('resize')), 260);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [collapsed.map, isDesktop]);
|
||||||
|
|
||||||
const handleSelectLead = (lead) => {
|
const handleSelectLead = (lead) => {
|
||||||
if (selectedLead?.id === lead.id) return;
|
if (selectedLead?.id === lead.id) return;
|
||||||
setSelectedLead(lead);
|
setSelectedLead(lead);
|
||||||
@@ -328,7 +353,7 @@ const LynkDispatchPage = () => {
|
|||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
title={`${efficiencyPct}% of dispatches used AI recommendation — ${100 - efficiencyPct}% were manual overrides`}
|
title={`${efficiencyPct}% of dispatches used AI recommendation — ${100 - efficiencyPct}% were manual overrides`}
|
||||||
className="flex items-center gap-1 text-[10px] font-black cursor-default"
|
className="flex items-center gap-1 text-[10px] font-black cursor-default whitespace-nowrap"
|
||||||
style={{ color: stormMode ? '#F59E0B' : '#10B981' }}
|
style={{ color: stormMode ? '#F59E0B' : '#10B981' }}
|
||||||
>
|
>
|
||||||
<span className="w-1 h-1 rounded-full" style={{ backgroundColor: stormMode ? '#F59E0B' : '#10B981' }} />
|
<span className="w-1 h-1 rounded-full" style={{ backgroundColor: stormMode ? '#F59E0B' : '#10B981' }} />
|
||||||
@@ -465,6 +490,7 @@ const LynkDispatchPage = () => {
|
|||||||
selectedLead={selectedLead}
|
selectedLead={selectedLead}
|
||||||
onSelectLead={handleSelectLead}
|
onSelectLead={handleSelectLead}
|
||||||
onViewDetails={setViewDetailLead}
|
onViewDetails={setViewDetailLead}
|
||||||
|
onQuickAssign={handleAssign}
|
||||||
stormMode={stormMode}
|
stormMode={stormMode}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
/>
|
/>
|
||||||
@@ -513,15 +539,18 @@ const LynkDispatchPage = () => {
|
|||||||
stormMode={stormMode}
|
stormMode={stormMode}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
isDesktop={isDesktop}
|
isDesktop={isDesktop}
|
||||||
|
mapHeight={null}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CollapsePanel open={!collapsed.map}>
|
<CollapsePanel open={!collapsed.map}>
|
||||||
<DispatchMapPanel
|
<DispatchMapPanel
|
||||||
selectedLead={selectedLead}
|
selectedLead={selectedLead}
|
||||||
dispatchLeads={dispatchLeads}
|
dispatchLeads={dispatchLeads}
|
||||||
|
reps={effectiveReps}
|
||||||
stormMode={stormMode}
|
stormMode={stormMode}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
isDesktop={isDesktop}
|
isDesktop={isDesktop}
|
||||||
|
mapHeight={mapHeight}
|
||||||
/>
|
/>
|
||||||
</CollapsePanel>
|
</CollapsePanel>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user