feat(dispatch): Phase 2 — DispatchLeadQueue with tabs, cards, and live drop-in

- DispatchLeadQueue: 4-tab filter (All / Unassigned / At Risk / Confirmed)
  with live counts; AnimatePresence popLayout for smooth card entrance/exit
- LeadCard: urgency badge (EMERGENCY pulse / HIGH / STANDARD), source icon,
  customer name, lead type, address, status pill, assigned rep initials, time ago
  — colored left border by urgency, blue ring when selected
- Storm mode: emergency leads float to top via sort; PRIORITY badge on
  emergency_tarp cards; amber Unassigned pill in storm context
- 20s interval drops new leads from 3-lead pool; NEW badge fades after 5s
- LynkDispatchPage: left panel now renders DispatchLeadQueue; handleSelectLead
  triggers 1.2s isProcessing state; right panel shows processing/selected/empty
  states ready for Phase 3
This commit is contained in:
Satyam
2026-03-21 21:53:00 +05:30
parent 4648561e7a
commit 1d6d587ebf
2 changed files with 392 additions and 47 deletions
@@ -0,0 +1,317 @@
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Globe, MapPin, Users, Phone, Clock, CloudLightning } from 'lucide-react';
import { useMockStore, DISPATCH_REPS } from '../../data/mockStore';
// ---------------------------------------------------------------------------
// Config maps
// ---------------------------------------------------------------------------
const SOURCE_CONFIG = {
website_form: { icon: Globe, label: 'Web Form' },
canvassing: { icon: MapPin, label: 'Canvassing' },
referral: { icon: Users, label: 'Referral' },
call_center: { icon: Phone, label: 'Call Center' },
};
const LEAD_TYPE_LABELS = {
emergency_tarp: 'Emergency Tarp',
roof_inspection: 'Roof Inspection',
insurance_claim: 'Insurance Claim',
retail_estimate: 'Retail Estimate',
};
const URGENCY_CONFIG = {
emergency: { label: 'EMERGENCY', color: '#EF4444', bg: '#EF444415', pulse: true },
high: { label: 'HIGH', color: '#F59E0B', bg: '#F59E0B15', pulse: false },
standard: { label: 'STANDARD', color: '#6B7280', bg: '#6B728015', pulse: false },
};
const STATUS_CONFIG = {
unassigned: { label: 'Unassigned', color: '#EF4444', bg: '#EF444412' },
at_risk: { label: 'At Risk', color: '#F59E0B', bg: '#F59E0B12' },
assigned: { label: 'Assigned', color: '#3B82F6', bg: '#3B82F612' },
confirmed: { label: 'Confirmed', color: '#10B981', bg: '#10B98112' },
};
const TABS = [
{ id: 'all', label: 'All' },
{ id: 'unassigned', label: 'Unassigned' },
{ id: 'at_risk', label: 'At Risk' },
{ id: 'confirmed', label: 'Confirmed' },
];
const TAB_STATUS_FILTER = {
all: null,
unassigned: ['unassigned'],
at_risk: ['at_risk'],
confirmed: ['assigned', 'confirmed'],
};
// Fake leads that "drop in" every 20s during demo — cycled through pool
const DROP_POOL = [
{
source: 'call_center', leadType: 'roof_inspection', urgency: 'high',
customer: { name: 'Marcus Webb' },
property: { address: '1620 Mapleshade Ln', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0545, lng: -96.7634 },
estimatedDuration: 45, status: 'unassigned', arrivedMinsAgo: 0, assignedRepId: null,
},
{
source: 'website_form', leadType: 'emergency_tarp', urgency: 'emergency',
customer: { name: 'Sandra Kim' },
property: { address: '3488 Midway Rd', city: 'Plano', state: 'TX', zip: '75093', lat: 33.0712, lng: -96.8090 },
estimatedDuration: 90, status: 'unassigned', arrivedMinsAgo: 0, assignedRepId: null,
},
{
source: 'canvassing', leadType: 'insurance_claim', urgency: 'high',
customer: { name: 'David Okonkwo' },
property: { address: '892 McDermott Dr', city: 'Plano', state: 'TX', zip: '75024', lat: 33.0921, lng: -96.7812 },
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 0, assignedRepId: null,
},
];
// ---------------------------------------------------------------------------
// LeadCard
// ---------------------------------------------------------------------------
const LeadCard = ({ lead, isSelected, isNew, onSelect, stormMode, accent, index }) => {
const urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard;
const statusCfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.unassigned;
const srcCfg = SOURCE_CONFIG[lead.source] || SOURCE_CONFIG.call_center;
const SrcIcon = srcCfg.icon;
const assignedRep = lead.assignedRepId ? DISPATCH_REPS.find(r => r.id === lead.assignedRepId) : null;
const timeLabel = lead.arrivedMinsAgo === 0 ? 'Just now' : `${lead.arrivedMinsAgo} min ago`;
const borderColor = isSelected ? accent : urgCfg.color;
return (
<motion.div
layout
initial={{ opacity: 0, y: -14 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -16, transition: { duration: 0.18 } }}
transition={{ type: 'spring', stiffness: 420, damping: 32, delay: index * 0.03 }}
onClick={() => onSelect(lead)}
whileHover={{ y: -2 }}
className={`relative cursor-pointer rounded-xl border transition-all duration-200 p-3 select-none
${isSelected
? 'bg-blue-50/80 dark:bg-blue-500/10 border-blue-300 dark:border-blue-500/40 shadow-md shadow-blue-100/60 dark:shadow-blue-500/5'
: 'bg-white dark:bg-zinc-800/50 border-zinc-200 dark:border-white/[0.06] hover:border-zinc-300 dark:hover:border-white/[0.12] hover:shadow-md'
}
`}
style={{ borderLeftWidth: 3, borderLeftColor: borderColor }}
>
{/* Top row: urgency badge + NEW badge + source */}
<div className="flex items-start justify-between gap-2 mb-1.5">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span
className={`text-[10px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0 ${urgCfg.pulse ? 'animate-pulse' : ''}`}
style={{ backgroundColor: urgCfg.bg, color: urgCfg.color }}
>
{urgCfg.label}
</span>
<AnimatePresence>
{isNew && (
<motion.span
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 shrink-0"
>
NEW
</motion.span>
)}
{stormMode && lead.leadType === 'emergency_tarp' && (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center gap-0.5 text-[10px] font-bold px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-500/15 text-amber-600 dark:text-amber-400 shrink-0"
>
<CloudLightning size={9} />
PRIORITY
</motion.span>
)}
</AnimatePresence>
</div>
<div className="flex items-center gap-1 shrink-0 text-zinc-400 dark:text-zinc-500">
<SrcIcon size={11} />
<span className="text-[10px]">{srcCfg.label}</span>
</div>
</div>
{/* Customer name + lead type */}
<p className="text-sm font-semibold text-zinc-900 dark:text-white leading-snug">
{lead.customer.name}
</p>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mt-0.5">
{LEAD_TYPE_LABELS[lead.leadType] || lead.leadType}
<span className="text-zinc-300 dark:text-zinc-600 mx-1">·</span>
{lead.estimatedDuration} min est.
</p>
{/* Address */}
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 mt-1 truncate">
{lead.property.address}, {lead.property.city}
</p>
{/* Bottom row: status + time */}
<div className="flex items-center justify-between mt-2 gap-2">
{assignedRep ? (
<span className="flex items-center gap-1.5 text-[11px] font-medium min-w-0">
<div
className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold text-white shrink-0"
style={{ backgroundColor: statusCfg.color }}
>
{assignedRep.initials.charAt(0)}
</div>
<span
className="truncate"
style={{ color: statusCfg.color }}
>
{assignedRep.name.split(' ')[0]}
<span className="text-zinc-400 dark:text-zinc-600 font-normal"> · {statusCfg.label}</span>
</span>
</span>
) : (
<span
className="text-[10px] font-bold px-2 py-0.5 rounded-full shrink-0"
style={{ backgroundColor: statusCfg.bg, color: statusCfg.color }}
>
{statusCfg.label}
</span>
)}
<span className="flex items-center gap-1 text-[10px] text-zinc-400 shrink-0">
<Clock size={10} />
{timeLabel}
</span>
</div>
{/* Lead ID */}
<p className="text-[9px] text-zinc-200 dark:text-zinc-800 mt-1.5 font-mono">{lead.id}</p>
</motion.div>
);
};
// ---------------------------------------------------------------------------
// DispatchLeadQueue
// ---------------------------------------------------------------------------
const DispatchLeadQueue = ({ selectedLead, onSelectLead, stormMode, accent }) => {
const { dispatchLeads } = useMockStore();
const [activeTab, setActiveTab] = useState('all');
const [droppedLeads, setDropped] = useState([]);
const [newLeadIds, setNewLeadIds] = useState(new Set());
const dropIndexRef = useRef(0);
// 20-second interval: drop in a new lead from the pool
useEffect(() => {
const interval = setInterval(() => {
const template = DROP_POOL[dropIndexRef.current % DROP_POOL.length];
dropIndexRef.current++;
const lead = { ...template, id: `LD-D${Date.now()}` };
setDropped(prev => [lead, ...prev]);
setNewLeadIds(prev => new Set([...prev, lead.id]));
// Clear "New" badge after 5s
setTimeout(() => {
setNewLeadIds(prev => {
const next = new Set(prev);
next.delete(lead.id);
return next;
});
}, 5000);
}, 20000);
return () => clearInterval(interval);
}, []);
// Merge dropped leads (newest first) with store leads
const allLeads = [...droppedLeads, ...dispatchLeads];
// Tab counts
const counts = {
all: allLeads.length,
unassigned: allLeads.filter(l => l.status === 'unassigned').length,
at_risk: allLeads.filter(l => l.status === 'at_risk').length,
confirmed: allLeads.filter(l => ['assigned', 'confirmed'].includes(l.status)).length,
};
// Filter by tab
const statusFilter = TAB_STATUS_FILTER[activeTab];
let filteredLeads = statusFilter
? allLeads.filter(l => statusFilter.includes(l.status))
: allLeads;
// Storm mode: float emergency leads to top
if (stormMode) {
filteredLeads = [...filteredLeads].sort((a, b) => {
const aScore = a.urgency === 'emergency' ? 0 : a.leadType === 'emergency_tarp' ? 1 : 2;
const bScore = b.urgency === 'emergency' ? 0 : b.leadType === 'emergency_tarp' ? 1 : 2;
return aScore - bScore;
});
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Tab bar */}
<div className="shrink-0 flex items-center gap-1 p-2 border-b border-zinc-100 dark:border-white/[0.06] bg-zinc-50/50 dark:bg-zinc-900/30">
{TABS.map(tab => {
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all duration-200 flex-1 justify-center
${isActive
? 'bg-white dark:bg-zinc-700 shadow-sm text-zinc-900 dark:text-white'
: 'text-zinc-400 dark:text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}
`}
>
<span>{tab.label}</span>
<span
className={`text-[10px] font-black px-1 rounded min-w-[18px] text-center transition-all duration-200
${isActive ? 'text-white' : 'text-zinc-400 dark:text-zinc-600'}
`}
style={isActive ? { backgroundColor: accent } : {}}
>
{counts[tab.id]}
</span>
</button>
);
})}
</div>
{/* Lead list */}
<div className="flex-1 overflow-y-auto p-2 space-y-1.5 custom-scrollbar">
<AnimatePresence mode="popLayout" initial={false}>
{filteredLeads.length === 0 ? (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center justify-center py-12 text-center"
>
<p className="text-sm text-zinc-400 dark:text-zinc-500 font-medium">No leads in this view</p>
<p className="text-xs text-zinc-300 dark:text-zinc-700 mt-1">New leads appear automatically</p>
</motion.div>
) : (
filteredLeads.map((lead, index) => (
<LeadCard
key={lead.id}
lead={lead}
isSelected={selectedLead?.id === lead.id}
isNew={newLeadIds.has(lead.id)}
onSelect={onSelectLead}
stormMode={stormMode}
accent={accent}
index={index}
/>
))
)}
</AnimatePresence>
</div>
</div>
);
};
export default DispatchLeadQueue;
+75 -47
View File
@@ -3,6 +3,7 @@ import { Zap, CloudLightning, Radio, Inbox, CheckCircle, Clock, AlertTriangle, B
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useTheme } from '../context/ThemeContext'; import { useTheme } from '../context/ThemeContext';
import { useMockStore, DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../data/mockStore'; import { useMockStore, DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../data/mockStore';
import DispatchLeadQueue from '../components/dispatch/DispatchLeadQueue';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Panel — themed container used by all 3 side panels // Panel — themed container used by all 3 side panels
@@ -63,6 +64,13 @@ const LynkDispatchPage = () => {
const [stormMode, setStormMode] = useState(false); const [stormMode, setStormMode] = useState(false);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const handleSelectLead = (lead) => {
if (selectedLead?.id === lead.id) return;
setSelectedLead(lead);
setIsProcessing(true);
setTimeout(() => setIsProcessing(false), 1200);
};
const accent = stormMode ? '#F59E0B' : '#3B82F6'; const accent = stormMode ? '#F59E0B' : '#3B82F6';
const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length; const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
@@ -140,43 +148,23 @@ const LynkDispatchPage = () => {
<Panel className="lg:overflow-hidden"> <Panel className="lg:overflow-hidden">
<PanelHeader <PanelHeader
title="Lead Queue" title="Lead Queue"
subtitle={`${unassigned} unassigned · ${dispatchLeads.length} total`} subtitle="Live · auto-updates every 20s"
accent={accent} accent={accent}
right={ right={
<span <span
className="text-xs font-bold px-2 py-0.5 rounded-full" className="text-xs font-bold px-2 py-0.5 rounded-full"
style={{ backgroundColor: `${accent}18`, color: accent }} style={{ backgroundColor: `${accent}18`, color: accent }}
> >
{dispatchLeads.length} {dispatchLeads.length}+
</span> </span>
} }
/> />
{/* Placeholder — Phase 2 builds this */} <DispatchLeadQueue
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 text-center"> selectedLead={selectedLead}
<div className="w-12 h-12 rounded-2xl bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center"> onSelectLead={handleSelectLead}
<Inbox size={22} className="text-zinc-400" /> stormMode={stormMode}
</div> accent={accent}
<div> />
<p className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">{dispatchLeads.length} leads loaded</p>
<p className="text-xs text-zinc-400 mt-1">Lead queue renders in Phase 2</p>
</div>
{/* Quick stat pills */}
<div className="flex flex-wrap justify-center gap-2 mt-2">
{[
{ label: 'Unassigned', count: dispatchLeads.filter(l => l.status === 'unassigned').length, color: '#EF4444' },
{ label: 'At Risk', count: dispatchLeads.filter(l => l.status === 'at_risk').length, color: '#F59E0B' },
{ label: 'Confirmed', count: dispatchLeads.filter(l => l.status === 'confirmed').length, color: '#10B981' },
].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>
</Panel> </Panel>
{/* CENTER — Map */} {/* CENTER — Map */}
@@ -235,26 +223,66 @@ const LynkDispatchPage = () => {
</div> </div>
} }
/> />
{/* Empty state — Phase 3 builds this */} {/* Placeholder — Phase 3 replaces this with DispatchRecommendationDrawer */}
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 text-center"> <div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 text-center">
<motion.div <AnimatePresence mode="wait">
animate={{ scale: [1, 1.05, 1] }} {isProcessing ? (
transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut' }} <motion.div
className="w-14 h-14 rounded-2xl border-2 border-dashed border-zinc-200 dark:border-zinc-700 flex items-center justify-center" key="processing"
> initial={{ opacity: 0 }}
<Bot size={24} className="text-zinc-300 dark:text-zinc-600" /> animate={{ opacity: 1 }}
</motion.div> exit={{ opacity: 0 }}
<div> className="flex flex-col items-center gap-3"
<p className="text-sm font-semibold text-zinc-500 dark:text-zinc-400"> >
{selectedLead ? 'Processing...' : 'No lead selected'} <div className="w-14 h-14 rounded-2xl border-2 border-dashed flex items-center justify-center animate-pulse"
</p> style={{ borderColor: `${accent}60`, backgroundColor: `${accent}10` }}
<p className="text-xs text-zinc-400 mt-1"> >
Click a lead in the queue to see AI-scored rep recommendations <Bot size={24} style={{ color: accent }} />
</p> </div>
</div> <p className="text-sm font-semibold" style={{ color: accent }}>Analyzing lead...</p>
<p className="text-[10px] text-zinc-300 dark:text-zinc-700 mt-2 font-medium uppercase tracking-widest"> <p className="text-xs text-zinc-400">AI scoring reps</p>
Recommendation engine Phase 3 </motion.div>
</p> ) : 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> </div>
</Panel> </Panel>
</div> </div>