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:
@@ -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;
|
||||
Reference in New Issue
Block a user