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
+
+
+ )}
+