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;