From 1d6d587ebf80af05a6fec8dd957d3e7ced4f2379 Mon Sep 17 00:00:00 2001 From: Satyam <95536056+Satyam-Rastogi@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:53:00 +0530 Subject: [PATCH] =?UTF-8?q?feat(dispatch):=20Phase=202=20=E2=80=94=20Dispa?= =?UTF-8?q?tchLeadQueue=20with=20tabs,=20cards,=20and=20live=20drop-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/dispatch/DispatchLeadQueue.jsx | 317 ++++++++++++++++++ src/pages/LynkDispatchPage.jsx | 122 ++++--- 2 files changed, 392 insertions(+), 47 deletions(-) create mode 100644 src/components/dispatch/DispatchLeadQueue.jsx diff --git a/src/components/dispatch/DispatchLeadQueue.jsx b/src/components/dispatch/DispatchLeadQueue.jsx new file mode 100644 index 0000000..59a4063 --- /dev/null +++ b/src/components/dispatch/DispatchLeadQueue.jsx @@ -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 ( + 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 */} +
+
+ + {urgCfg.label} + + + {isNew && ( + + NEW + + )} + {stormMode && lead.leadType === 'emergency_tarp' && ( + + + PRIORITY + + )} + +
+
+ + {srcCfg.label} +
+
+ + {/* Customer name + lead type */} +

+ {lead.customer.name} +

+

+ {LEAD_TYPE_LABELS[lead.leadType] || lead.leadType} + · + {lead.estimatedDuration} min est. +

+ + {/* Address */} +

+ {lead.property.address}, {lead.property.city} +

+ + {/* Bottom row: status + time */} +
+ {assignedRep ? ( + +
+ {assignedRep.initials.charAt(0)} +
+ + {assignedRep.name.split(' ')[0]} + · {statusCfg.label} + +
+ ) : ( + + {statusCfg.label} + + )} + + + {timeLabel} + +
+ + {/* Lead ID */} +

{lead.id}

+
+ ); +}; + +// --------------------------------------------------------------------------- +// 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 ( +
+ + {/* Tab bar */} +
+ {TABS.map(tab => { + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+ + {/* Lead list */} +
+ + {filteredLeads.length === 0 ? ( + +

No leads in this view

+

New leads appear automatically

+
+ ) : ( + filteredLeads.map((lead, index) => ( + + )) + )} +
+
+
+ ); +}; + +export default DispatchLeadQueue; diff --git a/src/pages/LynkDispatchPage.jsx b/src/pages/LynkDispatchPage.jsx index 0be6e3f..6b845bb 100644 --- a/src/pages/LynkDispatchPage.jsx +++ b/src/pages/LynkDispatchPage.jsx @@ -3,6 +3,7 @@ import { Zap, CloudLightning, Radio, Inbox, CheckCircle, Clock, AlertTriangle, B import { motion, AnimatePresence } from 'framer-motion'; import { useTheme } from '../context/ThemeContext'; import { useMockStore, DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../data/mockStore'; +import DispatchLeadQueue from '../components/dispatch/DispatchLeadQueue'; // --------------------------------------------------------------------------- // Panel — themed container used by all 3 side panels @@ -63,6 +64,13 @@ const LynkDispatchPage = () => { const [stormMode, setStormMode] = 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 unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length; @@ -140,43 +148,23 @@ const LynkDispatchPage = () => { - {dispatchLeads.length} + {dispatchLeads.length}+ } /> - {/* Placeholder — Phase 2 builds this */} -
-
- -
-
-

{dispatchLeads.length} leads loaded

-

Lead queue renders in Phase 2

-
- {/* Quick stat pills */} -
- {[ - { 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 => ( - - {s.count} {s.label} - - ))} -
-
+
{/* CENTER — Map */} @@ -235,26 +223,66 @@ const LynkDispatchPage = () => { } /> - {/* Empty state — Phase 3 builds this */} + {/* Placeholder — Phase 3 replaces this with DispatchRecommendationDrawer */}
- - - -
-

- {selectedLead ? 'Processing...' : 'No lead selected'} -

-

- Click a lead in the queue to see AI-scored rep recommendations -

-
-

- Recommendation engine — Phase 3 -

+ + {isProcessing ? ( + +
+ +
+

Analyzing lead...

+

AI scoring reps

+
+ ) : selectedLead ? ( + +
+ +
+

{selectedLead.id}

+

{selectedLead.customer.name} · {selectedLead.property.address}

+

+ Recommendation engine — Phase 3 +

+
+ ) : ( + + + + +
+

No lead selected

+

Click a lead in the queue to see AI-scored rep recommendations

+
+
+ )} +