feat(dispatch): additional features — quick assign, lead quick view, rep cycling, AI efficiency score, AI Says block
- Quick Assign: accent Zap pill on unassigned lead cards; dispatches top AI rec in one tap; 2.2s confirmation state - LeadQuickViewModal: right-drawer with client contact (copy buttons), property, source sub-details, editable notes with double-gate confirmation - Rep Status Live Cycling: repStatuses state map overrides DISPATCH_REPS.status; effectiveReps prop passed to map and AI drawer; assign sets rep to en_route, reverts to available after 60s - Dispatch Efficiency Score: aiCount/totalCount seeded 8/9 (89%); AI assign increments both, manual override increments total only; shown as badge in header - Efficiency badge label updated to '89% AI-Dispatched' with tooltip explaining the metric - AI Says block in RecommendationDrawer: rep identity row (avatar, name, status/distance, large score) + italic AI insight quote above recommendations - Lead aging badges: amber Aging (20+ min), red pulsing Overdue (40+ min) on unassigned leads - Search bar in Lead Queue: filters by customer name, address, city; combines with active tab filter - mockStore enrichment: email + notes + source sub-details on all 12 leads; rating/closeRate on all 5 reps; distanceMi/aiInsight/conflictFlag on all recommendations; updateLeadNotes() action; DISPATCH_WEEKLY_STATS export - LynkDispatchPage wired for Phase 5 KPIs, storm mode, chart modal, log drawer, and all new features
This commit is contained in:
+281
-73
@@ -1,16 +1,24 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Zap, CloudLightning, Radio, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown } from 'lucide-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 }) => (
|
||||
const Panel = ({ children, className = '', style, stormMode }) => (
|
||||
<div
|
||||
className={`flex flex-col rounded-2xl border overflow-hidden bg-white border-zinc-200 shadow-sm dark:bg-zinc-900/60 dark:border-white/[0.06] ${className}`}
|
||||
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}
|
||||
@@ -130,9 +138,22 @@ const CollapsePanel = ({ open, children }) => (
|
||||
// ---------------------------------------------------------------------------
|
||||
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);
|
||||
@@ -173,19 +194,71 @@ const LynkDispatchPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = useCallback((leadId, repId) => {
|
||||
const handleAssign = useCallback((leadId, repId, isManual = false) => {
|
||||
assignDispatchLead(leadId, repId);
|
||||
}, [assignDispatchLead]);
|
||||
|
||||
// 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';
|
||||
const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
|
||||
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 bg-zinc-50 dark:bg-[#09090b] ${stormMode ? 'storm-mode' : ''}`}>
|
||||
<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>
|
||||
@@ -220,15 +293,118 @@ const LynkDispatchPage = () => {
|
||||
<h1 className="text-lg font-black uppercase tracking-tight leading-none text-zinc-900 dark:text-white font-[Barlow_Condensed,sans-serif]">
|
||||
LynkDispatch
|
||||
</h1>
|
||||
<p className="text-[11px] text-zinc-400 tracking-wide">AI Dispatch Engine</p>
|
||||
<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"
|
||||
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 badge */}
|
||||
<div 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>
|
||||
{/* 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 */}
|
||||
@@ -256,27 +432,39 @@ const LynkDispatchPage = () => {
|
||||
<Panel
|
||||
className="w-full xl:overflow-hidden xl:shrink-0"
|
||||
style={isDesktop ? { width: leftWidth } : undefined}
|
||||
stormMode={stormMode}
|
||||
>
|
||||
<PanelHeader
|
||||
title="Lead Queue"
|
||||
subtitle="Live · auto-updates every 20s"
|
||||
subtitle={stormMode ? 'Storm Protocol — Emergency leads prioritized' : 'Live · auto-updates every 20s'}
|
||||
accent={accent}
|
||||
collapsible={!isDesktop}
|
||||
isCollapsed={collapsed.queue}
|
||||
onToggle={() => toggle('queue')}
|
||||
right={
|
||||
<span
|
||||
className="text-xs font-bold px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: `${accent}18`, color: accent }}
|
||||
>
|
||||
{dispatchLeads.length}+
|
||||
</span>
|
||||
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}
|
||||
stormMode={stormMode}
|
||||
accent={accent}
|
||||
/>
|
||||
@@ -285,6 +473,8 @@ const LynkDispatchPage = () => {
|
||||
<DispatchLeadQueue
|
||||
selectedLead={selectedLead}
|
||||
onSelectLead={handleSelectLead}
|
||||
onViewDetails={setViewDetailLead}
|
||||
onQuickAssign={handleAssign}
|
||||
stormMode={stormMode}
|
||||
accent={accent}
|
||||
/>
|
||||
@@ -295,34 +485,44 @@ const LynkDispatchPage = () => {
|
||||
<ResizableHandle onDrag={dragLeft} />
|
||||
|
||||
{/* CENTER — Territory Map */}
|
||||
<Panel className="w-full xl:flex-1 xl:overflow-hidden">
|
||||
<Panel className="w-full xl:flex-1 xl:overflow-hidden" stormMode={stormMode}>
|
||||
<PanelHeader
|
||||
title="Territory Map"
|
||||
subtitle="Plano, TX — Rep routes & lead pins"
|
||||
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 bg-blue-500" />
|
||||
<span className="text-[10px] text-zinc-400 font-medium">{DISPATCH_REPS.length} reps active</span>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
{/* Placeholder — Phase 4 builds Leaflet map here */}
|
||||
{isDesktop ? (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-3 p-6 bg-zinc-50 dark:bg-zinc-950/40 text-center"
|
||||
style={{ height: 'calc(72rem + 2.375rem)' }}
|
||||
>
|
||||
<MapPlaceholderContent />
|
||||
</div>
|
||||
<DispatchMapPanel
|
||||
selectedLead={selectedLead}
|
||||
dispatchLeads={dispatchLeads}
|
||||
reps={effectiveReps}
|
||||
stormMode={stormMode}
|
||||
accent={accent}
|
||||
isDesktop={isDesktop}
|
||||
/>
|
||||
) : (
|
||||
<CollapsePanel open={!collapsed.map}>
|
||||
<div className="flex flex-col items-center justify-center gap-3 p-6 bg-zinc-50 dark:bg-zinc-950/40 text-center min-h-[280px] sm:min-h-[360px] md:min-h-[420px]">
|
||||
<MapPlaceholderContent />
|
||||
</div>
|
||||
<DispatchMapPanel
|
||||
selectedLead={selectedLead}
|
||||
dispatchLeads={dispatchLeads}
|
||||
stormMode={stormMode}
|
||||
accent={accent}
|
||||
isDesktop={isDesktop}
|
||||
/>
|
||||
</CollapsePanel>
|
||||
)}
|
||||
</Panel>
|
||||
@@ -334,6 +534,7 @@ const LynkDispatchPage = () => {
|
||||
<Panel
|
||||
className="w-full xl:overflow-hidden xl:shrink-0"
|
||||
style={isDesktop ? { width: rightWidth } : undefined}
|
||||
stormMode={stormMode}
|
||||
>
|
||||
<PanelHeader
|
||||
title="AI Recommendations"
|
||||
@@ -361,10 +562,12 @@ const LynkDispatchPage = () => {
|
||||
<DispatchRecommendationDrawer
|
||||
selectedLead={selectedLead}
|
||||
isProcessing={isProcessing}
|
||||
reps={effectiveReps}
|
||||
stormMode={stormMode}
|
||||
accent={accent}
|
||||
onAssign={handleAssign}
|
||||
onDismissAfterAssign={handleDismissAfterAssign}
|
||||
onViewDetails={setViewDetailLead}
|
||||
isDesktop={isDesktop}
|
||||
/>
|
||||
) : (
|
||||
@@ -376,6 +579,7 @@ const LynkDispatchPage = () => {
|
||||
accent={accent}
|
||||
onAssign={handleAssign}
|
||||
onDismissAfterAssign={handleDismissAfterAssign}
|
||||
onViewDetails={setViewDetailLead}
|
||||
isDesktop={isDesktop}
|
||||
/>
|
||||
</CollapsePanel>
|
||||
@@ -384,74 +588,78 @@ const LynkDispatchPage = () => {
|
||||
</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 py-3">
|
||||
<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={false}
|
||||
pulse={stormMode && unassigned > 0}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Assigned Today"
|
||||
value={28}
|
||||
value={assignedToday}
|
||||
icon={CheckCircle}
|
||||
accent="#10B981"
|
||||
stormMode={stormMode}
|
||||
pulse={false}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Avg Response"
|
||||
value="4.2 min"
|
||||
label="Avg Wait"
|
||||
value={avgWait}
|
||||
icon={Clock}
|
||||
accent="#8B5CF6"
|
||||
stormMode={stormMode}
|
||||
pulse={false}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Storm Alerts"
|
||||
value={stormMode ? 3 : 2}
|
||||
label={stormMode ? 'Storm Alerts' : 'At Risk'}
|
||||
value={alertCount}
|
||||
icon={AlertTriangle}
|
||||
accent={stormMode ? '#F59E0B' : '#6B7280'}
|
||||
accent={alertCount > 0 ? '#F59E0B' : '#6B7280'}
|
||||
stormMode={stormMode}
|
||||
pulse={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>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extracted placeholder content (avoids duplication between desktop/mobile)
|
||||
// ---------------------------------------------------------------------------
|
||||
const MapPlaceholderContent = () => (
|
||||
<>
|
||||
<div className="w-16 h-16 rounded-2xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 flex items-center justify-center">
|
||||
<Radio size={28} className="text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">Interactive Map</p>
|
||||
<p className="text-xs text-zinc-400 mt-1">Leaflet map with rep markers & route polylines — Phase 4</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||
{[
|
||||
{ label: 'Available', count: DISPATCH_REPS.filter(r => r.status === 'available').length, color: '#10B981' },
|
||||
{ label: 'En Route', count: DISPATCH_REPS.filter(r => r.status === 'en_route').length, color: '#3B82F6' },
|
||||
{ label: 'Busy', count: DISPATCH_REPS.filter(r => r.status === 'busy').length, color: '#F59E0B' },
|
||||
].map(s => (
|
||||
<span
|
||||
key={s.label}
|
||||
className="text-[11px] font-semibold px-2.5 py-1 rounded-full"
|
||||
style={{ backgroundColor: `${s.color}15`, color: s.color }}
|
||||
>
|
||||
{s.count} {s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
export default LynkDispatchPage;
|
||||
|
||||
Reference in New Issue
Block a user