9be8ec911f
- 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
695 lines
33 KiB
React
695 lines
33 KiB
React
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { Zap, CloudLightning, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown, History, BarChart2 } from 'lucide-react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { useMockStore, DISPATCH_REPS } from '../data/mockStore';
|
|
import DispatchLeadQueue from '../components/dispatch/DispatchLeadQueue';
|
|
import DispatchRecommendationDrawer from '../components/dispatch/DispatchRecommendationDrawer';
|
|
import DispatchMapPanel from '../components/dispatch/DispatchMapPanel';
|
|
import LeadQuickViewModal from '../components/dispatch/LeadQuickViewModal';
|
|
import DispatchLogDrawer from '../components/dispatch/DispatchLogDrawer';
|
|
import DispatchChartModal from '../components/dispatch/DispatchChartModal';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Panel — themed container
|
|
// ---------------------------------------------------------------------------
|
|
const Panel = ({ children, className = '', style, stormMode }) => (
|
|
<div
|
|
className={`flex flex-col rounded-2xl border overflow-hidden bg-white shadow-sm dark:bg-zinc-900/60 transition-colors duration-500 ${
|
|
stormMode
|
|
? 'border-amber-200 dark:border-amber-500/20'
|
|
: 'border-zinc-200 dark:border-white/[0.06]'
|
|
} ${className}`}
|
|
style={style}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
// PanelHeader — supports optional collapse toggle on mobile/tablet
|
|
const PanelHeader = ({ title, subtitle, accent, right, collapsible, isCollapsed, onToggle }) => (
|
|
<div
|
|
className={`shrink-0 flex items-center justify-between px-4 py-2 border-b border-zinc-100 dark:border-white/[0.06] transition-colors duration-150 ${collapsible ? 'cursor-pointer select-none active:bg-zinc-50 dark:active:bg-white/5' : ''}`}
|
|
style={{ borderTopColor: accent, borderTopWidth: 2 }}
|
|
onClick={collapsible ? onToggle : undefined}
|
|
>
|
|
<div className="min-w-0">
|
|
<h2 className="text-sm font-bold uppercase tracking-widest text-zinc-800 dark:text-white">{title}</h2>
|
|
{subtitle && <p className="text-[11px] text-zinc-400 mt-0.5">{subtitle}</p>}
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{right && (
|
|
<div onClick={e => collapsible && e.stopPropagation()}>
|
|
{right}
|
|
</div>
|
|
)}
|
|
{collapsible && (
|
|
<ChevronDown
|
|
size={16}
|
|
className={`text-zinc-400 transition-transform duration-200 ${isCollapsed ? '-rotate-90' : ''}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// KPI Card
|
|
// ---------------------------------------------------------------------------
|
|
const KpiCard = ({ label, value, icon: Icon, accent, stormMode, pulse }) => (
|
|
<div className={`flex items-center gap-2.5 rounded-xl px-3 py-2 border transition-all duration-500
|
|
${stormMode && pulse
|
|
? 'bg-amber-50 border-amber-300 dark:bg-amber-500/10 dark:border-amber-500/40 shadow-amber-200/50 dark:shadow-amber-500/10 shadow-md'
|
|
: 'bg-white border-zinc-200 shadow-sm dark:bg-zinc-900/60 dark:border-white/[0.06]'
|
|
}`}
|
|
>
|
|
<div
|
|
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
|
|
style={{ backgroundColor: `${accent}18`, color: accent }}
|
|
>
|
|
<Icon size={16} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-zinc-400 truncate">{label}</p>
|
|
<p className="text-lg font-black font-mono leading-tight text-zinc-900 dark:text-white">{value}</p>
|
|
</div>
|
|
{stormMode && pulse && (
|
|
<span className="ml-auto w-2 h-2 rounded-full bg-amber-500 animate-pulse shrink-0" />
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ResizableHandle — desktop only (xl+)
|
|
// ---------------------------------------------------------------------------
|
|
const ResizableHandle = ({ onDrag }) => {
|
|
const dragging = useRef(false);
|
|
const lastX = useRef(0);
|
|
|
|
const onMouseDown = useCallback((e) => {
|
|
dragging.current = true;
|
|
lastX.current = e.clientX;
|
|
e.preventDefault();
|
|
|
|
const onMove = (ev) => {
|
|
if (!dragging.current) return;
|
|
const delta = ev.clientX - lastX.current;
|
|
lastX.current = ev.clientX;
|
|
onDrag(delta);
|
|
};
|
|
const onUp = () => {
|
|
dragging.current = false;
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
};
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
}, [onDrag]);
|
|
|
|
return (
|
|
<div
|
|
onMouseDown={onMouseDown}
|
|
className="hidden xl:flex shrink-0 w-1.5 cursor-col-resize items-center justify-center group self-stretch"
|
|
>
|
|
<div className="w-0.5 h-8 rounded-full bg-zinc-200 dark:bg-white/10 group-hover:bg-blue-400 dark:group-hover:bg-blue-500/60 transition-colors duration-150" />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Animated collapse wrapper
|
|
const CollapsePanel = ({ open, children }) => (
|
|
<AnimatePresence initial={false}>
|
|
{open && (
|
|
<motion.div
|
|
key="content"
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.22, ease: 'easeInOut' }}
|
|
style={{ overflow: 'hidden' }}
|
|
>
|
|
{children}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main Page
|
|
// ---------------------------------------------------------------------------
|
|
const LynkDispatchPage = () => {
|
|
const { dispatchLeads, assignDispatchLead } = useMockStore();
|
|
const [viewDetailLead, setViewDetailLead] = useState(null);
|
|
const [dispatchLog, setDispatchLog] = useState([]);
|
|
|
|
const [selectedLead, setSelectedLead] = useState(null);
|
|
const [stormMode, setStormMode] = useState(false);
|
|
const [logDrawerOpen, setLogDrawerOpen] = useState(false);
|
|
const [chartModalOpen, setChartModalOpen] = useState(false);
|
|
|
|
// Rep status live cycling — overrides static DISPATCH_REPS.status
|
|
const [repStatuses, setRepStatuses] = useState(
|
|
() => Object.fromEntries(DISPATCH_REPS.map(r => [r.id, r.status]))
|
|
);
|
|
|
|
// Dispatch efficiency tracking — seeded with today's prior activity
|
|
const [aiCount, setAiCount] = useState(8);
|
|
const [totalCount, setTotalCount] = useState(9);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [leftWidth, setLeftWidth] = useState(300);
|
|
const [rightWidth, setRightWidth] = useState(320);
|
|
|
|
// xl = 1280px — treats all iPads (inc. Air 820/1180 & Pro 11" 834/1194) as tablet
|
|
const [isDesktop, setIsDesktop] = useState(() => window.innerWidth >= 1280);
|
|
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(() => {
|
|
const handler = () => {
|
|
setIsDesktop(window.innerWidth >= 1280);
|
|
setMapHeight(calcMapHeight());
|
|
};
|
|
window.addEventListener('resize', handler);
|
|
return () => window.removeEventListener('resize', handler);
|
|
}, []);
|
|
|
|
// Collapse state — map & AI default-collapsed on small phones only
|
|
const [collapsed, setCollapsed] = useState(() => ({
|
|
queue: false,
|
|
map: window.innerWidth < 640,
|
|
ai: window.innerWidth < 640,
|
|
}));
|
|
const toggle = (key) => setCollapsed(p => ({ ...p, [key]: !p[key] }));
|
|
|
|
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))), []);
|
|
|
|
// 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) => {
|
|
if (selectedLead?.id === lead.id) return;
|
|
setSelectedLead(lead);
|
|
setIsProcessing(true);
|
|
setTimeout(() => setIsProcessing(false), 1200);
|
|
|
|
// Mobile/tablet: expand AI panel and scroll to it
|
|
if (!isDesktop) {
|
|
setCollapsed(p => ({ ...p, ai: false }));
|
|
setTimeout(() => {
|
|
aiPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}, 120);
|
|
}
|
|
};
|
|
|
|
const handleAssign = useCallback((leadId, repId, isManual = false) => {
|
|
assignDispatchLead(leadId, repId);
|
|
|
|
// Cycle rep status: en_route for 60s, then back to available
|
|
setRepStatuses(prev => ({ ...prev, [repId]: 'en_route' }));
|
|
setTimeout(() => {
|
|
setRepStatuses(prev => ({ ...prev, [repId]: 'available' }));
|
|
}, 60000);
|
|
|
|
// Track AI vs manual efficiency
|
|
setTotalCount(n => n + 1);
|
|
if (!isManual) setAiCount(n => n + 1);
|
|
|
|
const lead = dispatchLeads.find(l => l.id === leadId);
|
|
const rep = DISPATCH_REPS.find(r => r.id === repId);
|
|
if (lead && rep) {
|
|
setDispatchLog(prev => [{
|
|
id: Date.now(),
|
|
repFirstName: rep.name.split(' ')[0],
|
|
repInitials: rep.initials,
|
|
repStatus: 'en_route',
|
|
customerName: lead.customer.name,
|
|
address: `${lead.property.address}, ${lead.property.city}`,
|
|
leadType: lead.leadType,
|
|
urgency: lead.urgency,
|
|
timestamp: Date.now(),
|
|
}, ...prev].slice(0, 20));
|
|
}
|
|
}, [assignDispatchLead, dispatchLeads]);
|
|
|
|
const handleDismissAfterAssign = useCallback(() => {
|
|
setSelectedLead(null);
|
|
}, []);
|
|
|
|
const accent = stormMode ? '#F59E0B' : '#3B82F6';
|
|
|
|
// Merge live statuses into rep objects — passed to map + AI drawer
|
|
const effectiveReps = DISPATCH_REPS.map(r => ({
|
|
...r,
|
|
status: repStatuses[r.id] ?? r.status,
|
|
}));
|
|
|
|
// Efficiency score — what % of dispatches were AI-recommended (not manual override)
|
|
const efficiencyPct = totalCount > 0 ? Math.round((aiCount / totalCount) * 100) : 0;
|
|
|
|
// ── Live KPI derivations ──
|
|
const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
|
|
const assignedToday = dispatchLeads.filter(l => ['assigned', 'confirmed'].includes(l.status)).length;
|
|
const atRiskCount = dispatchLeads.filter(l => l.status === 'at_risk').length;
|
|
const emergencyCount = dispatchLeads.filter(l => l.urgency === 'emergency').length;
|
|
const alertCount = stormMode ? emergencyCount + atRiskCount : atRiskCount;
|
|
|
|
const avgWait = (() => {
|
|
const pending = dispatchLeads.filter(l => l.status === 'unassigned');
|
|
if (!pending.length) return '—';
|
|
const avg = Math.round(
|
|
pending.reduce((sum, l) => sum + (l.arrivedMinsAgo || 0), 0) / pending.length
|
|
);
|
|
return `${avg} min`;
|
|
})();
|
|
|
|
return (
|
|
<div className={`flex flex-col transition-colors duration-500 ${
|
|
stormMode ? 'bg-amber-50 dark:bg-[#09090b] storm-mode' : 'bg-zinc-50 dark:bg-[#09090b]'
|
|
}`}>
|
|
|
|
{/* Storm Banner */}
|
|
<AnimatePresence>
|
|
{stormMode && (
|
|
<motion.div
|
|
key="storm-banner"
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="overflow-hidden shrink-0"
|
|
>
|
|
<div className="flex items-center justify-center gap-2 bg-amber-500 px-4 py-2 text-xs font-bold uppercase tracking-widest text-white">
|
|
<CloudLightning size={14} className="animate-pulse" />
|
|
Storm Mode Active — Exterior Inspections Restricted — Route Planning Adjusted
|
|
<CloudLightning size={14} className="animate-pulse" />
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* ── Header ── */}
|
|
<header className="shrink-0 flex items-center justify-between gap-4 px-4 pt-2.5 pb-2">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div
|
|
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0 transition-colors duration-500"
|
|
style={{ backgroundColor: `${accent}20`, color: accent }}
|
|
>
|
|
<Zap size={20} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h1 className="text-lg font-black uppercase tracking-tight leading-none text-zinc-900 dark:text-white font-[Barlow_Condensed,sans-serif]">
|
|
LynkDispatch
|
|
</h1>
|
|
<div className="flex items-center gap-2">
|
|
<AnimatePresence mode="wait">
|
|
{stormMode ? (
|
|
<motion.p
|
|
key="storm-sub"
|
|
initial={{ opacity: 0, y: 4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -4 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="text-[11px] font-bold tracking-wide text-amber-500"
|
|
>
|
|
Storm Protocol Active
|
|
</motion.p>
|
|
) : (
|
|
<motion.p
|
|
key="normal-sub"
|
|
initial={{ opacity: 0, y: 4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -4 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="text-[11px] text-zinc-400 tracking-wide"
|
|
>
|
|
AI Dispatch Engine
|
|
</motion.p>
|
|
)}
|
|
</AnimatePresence>
|
|
<AnimatePresence>
|
|
{totalCount > 0 && (
|
|
<motion.span
|
|
key="efficiency"
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
title={`${efficiencyPct}% of dispatches used AI recommendation — ${100 - efficiencyPct}% were manual overrides`}
|
|
className="flex items-center gap-1 text-[10px] font-black cursor-default whitespace-nowrap"
|
|
style={{ color: stormMode ? '#F59E0B' : '#10B981' }}
|
|
>
|
|
<span className="w-1 h-1 rounded-full" style={{ backgroundColor: stormMode ? '#F59E0B' : '#10B981' }} />
|
|
{efficiencyPct}% AI-Dispatched
|
|
</motion.span>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
{/* LIVE / STORM ACTIVE badge */}
|
|
<AnimatePresence mode="wait">
|
|
{stormMode ? (
|
|
<motion.div
|
|
key="storm-badge"
|
|
initial={{ opacity: 0, scale: 0.88 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.88 }}
|
|
transition={{ duration: 0.18 }}
|
|
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-amber-50 dark:bg-amber-500/10 border border-amber-300 dark:border-amber-500/30"
|
|
>
|
|
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-amber-600 dark:text-amber-400">Storm Active</span>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="live-badge"
|
|
initial={{ opacity: 0, scale: 0.88 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.88 }}
|
|
transition={{ duration: 0.18 }}
|
|
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20"
|
|
>
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-emerald-600 dark:text-emerald-400">Live</span>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Log + Chart buttons */}
|
|
<div className="flex items-center gap-1.5">
|
|
<button
|
|
onClick={() => setLogDrawerOpen(true)}
|
|
title="Dispatch Log"
|
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs font-bold uppercase tracking-wider border transition-all duration-200 ${
|
|
stormMode
|
|
? 'bg-amber-500/10 border-amber-400/40 text-amber-600 dark:text-amber-400'
|
|
: 'bg-zinc-100 border-zinc-200 text-zinc-500 dark:bg-zinc-800/60 dark:border-white/10 dark:text-zinc-400 hover:border-zinc-300 dark:hover:border-white/20'
|
|
}`}
|
|
>
|
|
<History size={13} />
|
|
<span className="hidden sm:inline">Log</span>
|
|
{dispatchLog.length > 0 && (
|
|
<span
|
|
className="w-4 h-4 rounded-full text-[9px] font-black flex items-center justify-center text-white"
|
|
style={{ backgroundColor: accent }}
|
|
>
|
|
{dispatchLog.length > 9 ? '9+' : dispatchLog.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setChartModalOpen(true)}
|
|
title="Weekly Performance"
|
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs font-bold uppercase tracking-wider border transition-all duration-200 ${
|
|
stormMode
|
|
? 'bg-amber-500/10 border-amber-400/40 text-amber-600 dark:text-amber-400'
|
|
: 'bg-zinc-100 border-zinc-200 text-zinc-500 dark:bg-zinc-800/60 dark:border-white/10 dark:text-zinc-400 hover:border-zinc-300 dark:hover:border-white/20'
|
|
}`}
|
|
>
|
|
<BarChart2 size={13} />
|
|
<span className="hidden sm:inline">Stats</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Storm Mode Toggle */}
|
|
<button
|
|
onClick={() => setStormMode(v => !v)}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-xl text-xs font-bold uppercase tracking-wider border transition-all duration-300 ${
|
|
stormMode
|
|
? 'bg-amber-500/15 border-amber-400/50 text-amber-600 dark:text-amber-400 shadow-amber-200/40 dark:shadow-amber-500/10 shadow-md'
|
|
: 'bg-zinc-100 border-zinc-200 text-zinc-500 dark:bg-zinc-800/60 dark:border-white/10 dark:text-zinc-400 hover:border-amber-300 dark:hover:border-amber-500/40'
|
|
}`}
|
|
>
|
|
<CloudLightning size={13} className={stormMode ? 'text-amber-500' : ''} />
|
|
<span className="hidden sm:inline">Storm Mode</span>
|
|
{stormMode && <span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* ── 3-Panel Layout ──────────────────────────────────────────────── */}
|
|
{/* xl+ (1280px): flex row with drag handles (all iPads = tablet) */}
|
|
{/* < xl: single column, collapsible panels */}
|
|
<div className="flex flex-col xl:flex-row gap-2 xl:gap-0 px-4 pb-2">
|
|
|
|
{/* LEFT — Lead Queue */}
|
|
<Panel
|
|
className="w-full xl:overflow-hidden xl:shrink-0"
|
|
style={isDesktop ? { width: leftWidth } : undefined}
|
|
stormMode={stormMode}
|
|
>
|
|
<PanelHeader
|
|
title="Lead Queue"
|
|
subtitle={stormMode ? 'Storm Protocol — Emergency leads prioritized' : 'Live · auto-updates every 20s'}
|
|
accent={accent}
|
|
collapsible={!isDesktop}
|
|
isCollapsed={collapsed.queue}
|
|
onToggle={() => toggle('queue')}
|
|
right={
|
|
stormMode && emergencyCount > 0 ? (
|
|
<span
|
|
className="flex items-center gap-1 text-xs font-bold px-2 py-0.5 rounded-full animate-pulse"
|
|
style={{ backgroundColor: `${accent}20`, color: accent }}
|
|
>
|
|
<AlertTriangle size={10} />
|
|
{emergencyCount} critical
|
|
</span>
|
|
) : (
|
|
<span
|
|
className="text-xs font-bold px-2 py-0.5 rounded-full"
|
|
style={{ backgroundColor: `${accent}18`, color: accent }}
|
|
>
|
|
{dispatchLeads.length}+
|
|
</span>
|
|
)
|
|
}
|
|
/>
|
|
{isDesktop ? (
|
|
<DispatchLeadQueue
|
|
selectedLead={selectedLead}
|
|
onSelectLead={handleSelectLead}
|
|
onViewDetails={setViewDetailLead}
|
|
onQuickAssign={handleAssign}
|
|
stormMode={stormMode}
|
|
accent={accent}
|
|
/>
|
|
) : (
|
|
<CollapsePanel open={!collapsed.queue}>
|
|
<DispatchLeadQueue
|
|
selectedLead={selectedLead}
|
|
onSelectLead={handleSelectLead}
|
|
onViewDetails={setViewDetailLead}
|
|
onQuickAssign={handleAssign}
|
|
stormMode={stormMode}
|
|
accent={accent}
|
|
/>
|
|
</CollapsePanel>
|
|
)}
|
|
</Panel>
|
|
|
|
<ResizableHandle onDrag={dragLeft} />
|
|
|
|
{/* CENTER — Territory Map */}
|
|
<Panel className="w-full xl:flex-1 xl:overflow-hidden" stormMode={stormMode}>
|
|
<PanelHeader
|
|
title="Territory Map"
|
|
subtitle={selectedLead ? `Route: ${selectedLead.property.address}` : 'Plano, TX — Rep routes & lead pins'}
|
|
accent={accent}
|
|
collapsible={!isDesktop}
|
|
isCollapsed={collapsed.map}
|
|
onToggle={() => toggle('map')}
|
|
right={
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className="w-2 h-2 rounded-full"
|
|
style={{ backgroundColor: accent }}
|
|
/>
|
|
<span className="text-[10px] text-zinc-400 font-medium">
|
|
{DISPATCH_REPS.filter(r => r.status === 'available').length} available
|
|
</span>
|
|
</div>
|
|
}
|
|
/>
|
|
{isDesktop ? (
|
|
<DispatchMapPanel
|
|
selectedLead={selectedLead}
|
|
dispatchLeads={dispatchLeads}
|
|
reps={effectiveReps}
|
|
stormMode={stormMode}
|
|
accent={accent}
|
|
isDesktop={isDesktop}
|
|
mapHeight={null}
|
|
/>
|
|
) : (
|
|
<CollapsePanel open={!collapsed.map}>
|
|
<DispatchMapPanel
|
|
selectedLead={selectedLead}
|
|
dispatchLeads={dispatchLeads}
|
|
reps={effectiveReps}
|
|
stormMode={stormMode}
|
|
accent={accent}
|
|
isDesktop={isDesktop}
|
|
mapHeight={mapHeight}
|
|
/>
|
|
</CollapsePanel>
|
|
)}
|
|
</Panel>
|
|
|
|
<ResizableHandle onDrag={dragRight} />
|
|
|
|
{/* RIGHT — AI Recommendations */}
|
|
<div ref={aiPanelRef} className="w-full xl:contents">
|
|
<Panel
|
|
className="w-full xl:overflow-hidden xl:shrink-0"
|
|
style={isDesktop ? { width: rightWidth } : undefined}
|
|
stormMode={stormMode}
|
|
>
|
|
<PanelHeader
|
|
title="AI Recommendations"
|
|
subtitle={
|
|
isProcessing
|
|
? 'AI scoring reps...'
|
|
: selectedLead
|
|
? selectedLead.customer.name
|
|
: 'Select a lead to begin'
|
|
}
|
|
accent={accent}
|
|
collapsible={!isDesktop}
|
|
isCollapsed={collapsed.ai}
|
|
onToggle={() => toggle('ai')}
|
|
right={
|
|
<div
|
|
className="w-6 h-6 rounded-full flex items-center justify-center"
|
|
style={{ backgroundColor: `${accent}18`, color: accent }}
|
|
>
|
|
<Bot size={13} />
|
|
</div>
|
|
}
|
|
/>
|
|
{isDesktop ? (
|
|
<DispatchRecommendationDrawer
|
|
selectedLead={selectedLead}
|
|
isProcessing={isProcessing}
|
|
reps={effectiveReps}
|
|
stormMode={stormMode}
|
|
accent={accent}
|
|
onAssign={handleAssign}
|
|
onDismissAfterAssign={handleDismissAfterAssign}
|
|
onViewDetails={setViewDetailLead}
|
|
isDesktop={isDesktop}
|
|
/>
|
|
) : (
|
|
<CollapsePanel open={!collapsed.ai}>
|
|
<DispatchRecommendationDrawer
|
|
selectedLead={selectedLead}
|
|
isProcessing={isProcessing}
|
|
stormMode={stormMode}
|
|
accent={accent}
|
|
onAssign={handleAssign}
|
|
onDismissAfterAssign={handleDismissAfterAssign}
|
|
onViewDetails={setViewDetailLead}
|
|
isDesktop={isDesktop}
|
|
/>
|
|
</CollapsePanel>
|
|
)}
|
|
</Panel>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Lead Quick View Modal ── */}
|
|
<AnimatePresence>
|
|
{viewDetailLead && (
|
|
<LeadQuickViewModal
|
|
lead={viewDetailLead}
|
|
onClose={() => setViewDetailLead(null)}
|
|
accent={accent}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* ── KPI Bar ── */}
|
|
<div className="shrink-0 grid grid-cols-2 xl:grid-cols-4 gap-2 px-4 pt-3 pb-2">
|
|
<KpiCard
|
|
label="Leads in Queue"
|
|
value={unassigned}
|
|
icon={Inbox}
|
|
accent={stormMode ? '#F59E0B' : '#3B82F6'}
|
|
stormMode={stormMode}
|
|
pulse={stormMode && unassigned > 0}
|
|
/>
|
|
<KpiCard
|
|
label="Assigned Today"
|
|
value={assignedToday}
|
|
icon={CheckCircle}
|
|
accent="#10B981"
|
|
stormMode={stormMode}
|
|
pulse={false}
|
|
/>
|
|
<KpiCard
|
|
label="Avg Wait"
|
|
value={avgWait}
|
|
icon={Clock}
|
|
accent="#8B5CF6"
|
|
stormMode={stormMode}
|
|
pulse={false}
|
|
/>
|
|
<KpiCard
|
|
label={stormMode ? 'Storm Alerts' : 'At Risk'}
|
|
value={alertCount}
|
|
icon={AlertTriangle}
|
|
accent={alertCount > 0 ? '#F59E0B' : '#6B7280'}
|
|
stormMode={stormMode}
|
|
pulse={stormMode && alertCount > 0}
|
|
/>
|
|
</div>
|
|
|
|
{/* ── Dispatch Log Drawer ── */}
|
|
<AnimatePresence>
|
|
{logDrawerOpen && (
|
|
<DispatchLogDrawer
|
|
log={dispatchLog}
|
|
accent={accent}
|
|
stormMode={stormMode}
|
|
onClose={() => setLogDrawerOpen(false)}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* ── Weekly Chart Modal ── */}
|
|
<AnimatePresence>
|
|
{chartModalOpen && (
|
|
<DispatchChartModal
|
|
accent={accent}
|
|
stormMode={stormMode}
|
|
onClose={() => setChartModalOpen(false)}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
|
|
export default LynkDispatchPage;
|