feat(dispatch): Phase 3 — AI recommendation drawer with scoring and dispatch flow

- Build DispatchRecommendationDrawer with empty/processing/results/dispatched states
- Structured shimmer skeletons during AI processing with cycling analyze messages
- Rep cards: animated score counter, 5-factor breakdown bars, reason chips, capacity donut
- Top Pick badge, storm-aware weather chips (amber styling)
- Assign button fires Sonner toast + updates lead status to confirmed in queue
- Override button opens rep picker modal with capacity and performance data
- Post-assign dispatched confirmation overlay (2.4s) then drawer resets
- Mobile: tap lead auto-expands AI panel and smooth-scrolls to it
This commit is contained in:
Satyam
2026-03-26 01:57:49 +05:30
parent 2fdb55e24c
commit c0706077d3
2 changed files with 947 additions and 129 deletions
+257 -129
View File
@@ -1,51 +1,68 @@
import React, { useState } from 'react';
import { Zap, CloudLightning, Radio, Inbox, CheckCircle, Clock, AlertTriangle, Bot } from 'lucide-react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Zap, CloudLightning, Radio, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useTheme } from '../context/ThemeContext';
import { useMockStore, DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../data/mockStore';
import { useMockStore, DISPATCH_REPS } from '../data/mockStore';
import DispatchLeadQueue from '../components/dispatch/DispatchLeadQueue';
import DispatchRecommendationDrawer from '../components/dispatch/DispatchRecommendationDrawer';
// ---------------------------------------------------------------------------
// Panel — themed container used by all 3 side panels
// Panel — themed container
// ---------------------------------------------------------------------------
const Panel = ({ children, className = '' }) => (
<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}`}>
const Panel = ({ children, className = '', style }) => (
<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}`}
style={style}
>
{children}
</div>
);
const PanelHeader = ({ title, subtitle, accent, right }) => (
// 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-3 border-b border-zinc-100 dark:border-white/[0.06]"
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>
<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>
{right && <div className="shrink-0">{right}</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 — bottom bar metric card
// KPI Card
// ---------------------------------------------------------------------------
const KpiCard = ({ label, value, icon: Icon, accent, stormMode, pulse }) => (
<div className={`flex items-center gap-3 rounded-xl px-4 py-3 border transition-all duration-500
<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-9 h-9 rounded-lg flex items-center justify-center shrink-0"
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ backgroundColor: `${accent}18`, color: accent }}
>
<Icon size={18} />
<Icon size={16} />
</div>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400 truncate">{label}</p>
<p className="text-xl font-black font-mono leading-tight text-zinc-900 dark:text-white">{value}</p>
<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" />
@@ -53,29 +70,122 @@ const KpiCard = ({ label, value, icon: Icon, accent, stormMode, pulse }) => (
</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 { theme } = useTheme();
const { dispatchLeads } = useMockStore();
const { dispatchLeads, assignDispatchLead } = useMockStore();
const [selectedLead, setSelectedLead] = useState(null);
const [stormMode, setStormMode] = useState(false);
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);
useEffect(() => {
const handler = () => setIsDesktop(window.innerWidth >= 1280);
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))), []);
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 accent = stormMode ? '#F59E0B' : '#3B82F6';
const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
const handleAssign = useCallback((leadId, repId) => {
assignDispatchLead(leadId, repId);
}, [assignDispatchLead]);
const handleDismissAfterAssign = useCallback(() => {
setSelectedLead(null);
}, []);
const accent = stormMode ? '#F59E0B' : '#3B82F6';
const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
return (
<div className={`flex flex-col h-full overflow-hidden bg-zinc-50 dark:bg-[#09090b] ${stormMode ? 'storm-mode' : ''}`}>
<div className={`flex flex-col bg-zinc-50 dark:bg-[#09090b] ${stormMode ? 'storm-mode' : ''}`}>
{/* Storm Banner */}
<AnimatePresence>
@@ -97,9 +207,8 @@ const LynkDispatchPage = () => {
)}
</AnimatePresence>
{/* ── Header ─────────────────────────────────────────────────────── */}
<header className="shrink-0 flex items-center justify-between gap-4 px-4 pt-4 pb-3">
{/* Branding */}
{/* ── 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"
@@ -115,7 +224,6 @@ const LynkDispatchPage = () => {
</div>
</div>
{/* Right controls */}
<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">
@@ -139,17 +247,23 @@ const LynkDispatchPage = () => {
</div>
</header>
{/* ── 3-Panel Grid ───────────────────────────────────────────────── */}
{/* Desktop: [320px fixed] [flex-1 map] [340px fixed] */}
{/* Mobile: single column, scrollable */}
<div className="flex-1 grid grid-cols-1 lg:grid-cols-[320px_1fr_340px] gap-3 px-4 pb-3 overflow-hidden lg:overflow-hidden">
{/* ── 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="lg:overflow-hidden">
<Panel
className="w-full xl:overflow-hidden xl:shrink-0"
style={isDesktop ? { width: leftWidth } : undefined}
>
<PanelHeader
title="Lead Queue"
subtitle="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"
@@ -159,20 +273,36 @@ const LynkDispatchPage = () => {
</span>
}
/>
<DispatchLeadQueue
selectedLead={selectedLead}
onSelectLead={handleSelectLead}
stormMode={stormMode}
accent={accent}
/>
{isDesktop ? (
<DispatchLeadQueue
selectedLead={selectedLead}
onSelectLead={handleSelectLead}
stormMode={stormMode}
accent={accent}
/>
) : (
<CollapsePanel open={!collapsed.queue}>
<DispatchLeadQueue
selectedLead={selectedLead}
onSelectLead={handleSelectLead}
stormMode={stormMode}
accent={accent}
/>
</CollapsePanel>
)}
</Panel>
{/* CENTER — Map */}
<Panel className="lg:overflow-hidden min-h-[280px]">
<ResizableHandle onDrag={dragLeft} />
{/* CENTER — Territory Map */}
<Panel className="w-full xl:flex-1 xl:overflow-hidden">
<PanelHeader
title="Territory Map"
subtitle="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" />
@@ -181,39 +311,43 @@ const LynkDispatchPage = () => {
}
/>
{/* Placeholder — Phase 4 builds Leaflet map here */}
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 bg-zinc-50 dark:bg-zinc-950/40 text-center">
<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" />
{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>
<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 &amp; route polylines Phase 4</p>
</div>
{/* Rep status pills */}
<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>
</div>
) : (
<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>
</CollapsePanel>
)}
</Panel>
{/* RIGHT — AI Recommendation Drawer */}
<Panel className="lg:overflow-hidden">
<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}
>
<PanelHeader
title="AI Recommendations"
subtitle={selectedLead ? `Analyzing ${selectedLead.id}` : 'Select a lead to begin'}
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"
@@ -223,72 +357,35 @@ const LynkDispatchPage = () => {
</div>
}
/>
{/* Placeholder — Phase 3 replaces this with DispatchRecommendationDrawer */}
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 text-center">
<AnimatePresence mode="wait">
{isProcessing ? (
<motion.div
key="processing"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center gap-3"
>
<div className="w-14 h-14 rounded-2xl border-2 border-dashed flex items-center justify-center animate-pulse"
style={{ borderColor: `${accent}60`, backgroundColor: `${accent}10` }}
>
<Bot size={24} style={{ color: accent }} />
</div>
<p className="text-sm font-semibold" style={{ color: accent }}>Analyzing lead...</p>
<p className="text-xs text-zinc-400">AI scoring reps</p>
</motion.div>
) : selectedLead ? (
<motion.div
key="selected"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center gap-2"
>
<div className="w-14 h-14 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: `${accent}18`, color: accent }}
>
<Bot size={24} />
</div>
<p className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">{selectedLead.id}</p>
<p className="text-xs text-zinc-400">{selectedLead.customer.name} · {selectedLead.property.address}</p>
<p className="text-[10px] text-zinc-300 dark:text-zinc-700 mt-2 font-medium uppercase tracking-widest">
Recommendation engine Phase 3
</p>
</motion.div>
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center gap-3"
>
<motion.div
animate={{ scale: [1, 1.05, 1] }}
transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut' }}
className="w-14 h-14 rounded-2xl border-2 border-dashed border-zinc-200 dark:border-zinc-700 flex items-center justify-center"
>
<Bot size={24} className="text-zinc-300 dark:text-zinc-600" />
</motion.div>
<div>
<p className="text-sm font-semibold text-zinc-500 dark:text-zinc-400">No lead selected</p>
<p className="text-xs text-zinc-400 mt-1">Click a lead in the queue to see AI-scored rep recommendations</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{isDesktop ? (
<DispatchRecommendationDrawer
selectedLead={selectedLead}
isProcessing={isProcessing}
stormMode={stormMode}
accent={accent}
onAssign={handleAssign}
onDismissAfterAssign={handleDismissAfterAssign}
isDesktop={isDesktop}
/>
) : (
<CollapsePanel open={!collapsed.ai}>
<DispatchRecommendationDrawer
selectedLead={selectedLead}
isProcessing={isProcessing}
stormMode={stormMode}
accent={accent}
onAssign={handleAssign}
onDismissAfterAssign={handleDismissAfterAssign}
isDesktop={isDesktop}
/>
</CollapsePanel>
)}
</Panel>
</div>
</div>
{/* ── KPI Bar ────────────────────────────────────────────────────── */}
<div className="shrink-0 grid grid-cols-2 lg:grid-cols-4 gap-3 px-4 pb-4">
{/* ── KPI Bar ── */}
<div className="shrink-0 grid grid-cols-2 xl:grid-cols-4 gap-2 px-4 py-3">
<KpiCard
label="Leads in Queue"
value={unassigned}
@@ -326,4 +423,35 @@ const LynkDispatchPage = () => {
);
};
// ---------------------------------------------------------------------------
// 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 &amp; 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;