diff --git a/src/components/dispatch/DispatchLeadQueue.jsx b/src/components/dispatch/DispatchLeadQueue.jsx
index a4f1e81..5e7e65a 100644
--- a/src/components/dispatch/DispatchLeadQueue.jsx
+++ b/src/components/dispatch/DispatchLeadQueue.jsx
@@ -1,7 +1,7 @@
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';
+import { Globe, MapPin, Users, Phone, Clock, CloudLightning, FileText, Search, X, Zap, CheckCircle } from 'lucide-react';
+import { useMockStore, DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
// ---------------------------------------------------------------------------
// Config maps
@@ -47,6 +47,14 @@ const TAB_STATUS_FILTER = {
confirmed: ['assigned', 'confirmed'],
};
+// Aging state — only shown on unassigned leads
+const getAgingState = (arrivedMinsAgo, status) => {
+ if (status !== 'unassigned') return null;
+ if (arrivedMinsAgo >= 40) return { label: 'Overdue', color: '#EF4444', pulse: true };
+ if (arrivedMinsAgo >= 20) return { label: 'Aging', color: '#F59E0B', pulse: false };
+ return null;
+};
+
// Fake leads that "drop in" every 20s during demo — cycled through pool
const DROP_POOL = [
{
@@ -72,14 +80,26 @@ const DROP_POOL = [
// ---------------------------------------------------------------------------
// LeadCard
// ---------------------------------------------------------------------------
-const LeadCard = ({ lead, isSelected, isNew, onSelect, stormMode, accent, index }) => {
+const LeadCard = ({ lead, isSelected, isNew, onSelect, onViewDetails, onQuickAssign, 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 timeLabel = lead.arrivedMinsAgo === 0 ? 'Just now' : `${lead.arrivedMinsAgo} min ago`;
const borderColor = isSelected ? accent : urgCfg.color;
+ const aging = getAgingState(lead.arrivedMinsAgo, lead.status);
+
+ const [quickAssigned, setQuickAssigned] = useState(false);
+ const topRec = DISPATCH_RECOMMENDATIONS[lead.id]?.[0] ?? null;
+
+ const handleQuickAssign = (e) => {
+ e.stopPropagation();
+ if (!onQuickAssign || !topRec || quickAssigned) return;
+ onQuickAssign(lead.id, topRec.repId, false);
+ setQuickAssigned(true);
+ setTimeout(() => setQuickAssigned(false), 2200);
+ };
return (
)}
+ {aging && (
+
+ {aging.label}
+
+ )}
@@ -177,14 +207,60 @@ const LeadCard = ({ lead, isSelected, isNew, onSelect, stormMode, accent, index
{statusCfg.label}
)}
-
+
{timeLabel}
- {/* Lead ID */}
- {lead.id}
+ {/* Footer: lead ID + actions */}
+
+
{lead.id}
+
+ {/* Quick Assign — unassigned leads with a top rec only */}
+ {onQuickAssign && lead.status === 'unassigned' && topRec && (
+
+ {quickAssigned ? (
+
+
+ Dispatched
+
+ ) : (
+
+
+ Quick Assign
+
+ )}
+
+ )}
+ {onViewDetails && (
+
+ )}
+
+
);
};
@@ -192,11 +268,12 @@ const LeadCard = ({ lead, isSelected, isNew, onSelect, stormMode, accent, index
// ---------------------------------------------------------------------------
// DispatchLeadQueue
// ---------------------------------------------------------------------------
-const DispatchLeadQueue = ({ selectedLead, onSelectLead, stormMode, accent }) => {
+const DispatchLeadQueue = ({ selectedLead, onSelectLead, onViewDetails, onQuickAssign, stormMode, accent }) => {
const { dispatchLeads } = useMockStore();
const [activeTab, setActiveTab] = useState('all');
const [droppedLeads, setDropped] = useState([]);
const [newLeadIds, setNewLeadIds] = useState(new Set());
+ const [search, setSearch] = useState('');
const dropIndexRef = useRef(0);
// 20-second interval: drop each lead in the pool once (max DROP_POOL.length drops)
@@ -243,6 +320,16 @@ const DispatchLeadQueue = ({ selectedLead, onSelectLead, stormMode, accent }) =>
? allLeads.filter(l => statusFilter.includes(l.status))
: allLeads;
+ // Filter by search text
+ if (search.trim()) {
+ const q = search.toLowerCase();
+ filteredLeads = filteredLeads.filter(l =>
+ l.customer.name.toLowerCase().includes(q) ||
+ l.property.address.toLowerCase().includes(q) ||
+ l.property.city.toLowerCase().includes(q)
+ );
+ }
+
// Storm mode: float emergency leads to top
if (stormMode) {
filteredLeads = [...filteredLeads].sort((a, b) => {
@@ -255,6 +342,28 @@ const DispatchLeadQueue = ({ selectedLead, onSelectLead, stormMode, accent }) =>
return (
+ {/* Search bar */}
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Search by name or address…"
+ className="w-full pl-7 pr-7 py-1.5 text-xs rounded-lg border border-zinc-200 dark:border-white/[0.08] bg-zinc-50 dark:bg-zinc-800/60 text-zinc-700 dark:text-zinc-200 placeholder-zinc-300 dark:placeholder-zinc-600 outline-none focus:border-zinc-400 dark:focus:border-white/20 transition-colors"
+ />
+ {search && (
+
+ )}
+
+
+
{/* Tab bar */}
{TABS.map(tab => {
@@ -306,6 +415,8 @@ const DispatchLeadQueue = ({ selectedLead, onSelectLead, stormMode, accent }) =>
isSelected={selectedLead?.id === lead.id}
isNew={newLeadIds.has(lead.id)}
onSelect={onSelectLead}
+ onViewDetails={onViewDetails}
+ onQuickAssign={onQuickAssign}
stormMode={stormMode}
accent={accent}
index={index}
diff --git a/src/components/dispatch/DispatchRecommendationDrawer.jsx b/src/components/dispatch/DispatchRecommendationDrawer.jsx
index adb124b..02fc770 100644
--- a/src/components/dispatch/DispatchRecommendationDrawer.jsx
+++ b/src/components/dispatch/DispatchRecommendationDrawer.jsx
@@ -3,8 +3,8 @@ import { motion, AnimatePresence } from 'framer-motion';
import {
Bot, CheckCircle, X, Star, Clock,
Navigation, CloudLightning, Zap, Users, ChevronRight,
+ AlertTriangle, Sparkles, FileText,
} from 'lucide-react';
-import { PieChart, Pie, Cell } from 'recharts';
import { toast } from 'sonner';
import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
import { AnimatedCounter } from '../AnimatedCounter';
@@ -154,28 +154,37 @@ const ScoreBar = ({ label, value, index }) => {
};
// ---------------------------------------------------------------------------
-// Capacity donut
+// Capacity donut — pure SVG, reliable at small sizes
// ---------------------------------------------------------------------------
const CapacityDonut = ({ used, max }) => {
- const pct = used / max;
- const color = pct >= 0.85 ? '#EF4444' : pct >= 0.6 ? '#F59E0B' : '#10B981';
- const data = [{ value: used }, { value: Math.max(0, max - used) }];
+ const pct = used / max;
+ const color = pct >= 0.85 ? '#EF4444' : pct >= 0.6 ? '#F59E0B' : '#10B981';
+ const r = 8;
+ const circumference = 2 * Math.PI * r;
+ const filled = circumference * Math.min(pct, 1);
return (
-
-
-
- |
- |
-
-
+
+
{used}/{max} slots
);
@@ -205,14 +214,13 @@ const RepAvatar = ({ initials, status }) => {
// ---------------------------------------------------------------------------
// Lead summary header
// ---------------------------------------------------------------------------
-const LeadSummary = ({ lead, stormMode }) => {
+const LeadSummary = ({ lead, stormMode, onViewDetails }) => {
const urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard;
return (
-
+
{lead.customer.name}
@@ -227,17 +235,26 @@ const LeadSummary = ({ lead, stormMode }) => {
{urgCfg.label}
-
-
- {LEAD_TYPE_LABELS[lead.leadType] || lead.leadType}
-
-
·
-
-
- {lead.estimatedDuration} min est.
-
-
·
-
{lead.id}
+
+
+
+ {LEAD_TYPE_LABELS[lead.leadType] || lead.leadType}
+
+ ·
+
+
+ {lead.estimatedDuration} min est.
+
+
+ {onViewDetails && (
+
+ )}
);
@@ -272,36 +289,70 @@ const RepCard = ({ rec, rep, rank, stormMode, accent, onAssign, onOverride }) =>
: 'border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-800/40'
}`}
>
- {/* Top-pick badge */}
- {rank === 0 && (
-
+ {/* Top-right: Top Pick badge (rank 0 only) stacked above Score */}
+
+ {rank === 0 && (
Top Pick
+ )}
+
+
Score
+
+
{rec.slotStart}
- )}
+
{/* Rep identity row */}
-
{rep.name}
-
- {REP_STATUS_LABELS[rep.status]}
-
+
{rep.name}
+ {/* Status + distance */}
+
+
+ {REP_STATUS_LABELS[rep.status]}
+
+ {rec.distanceMi && (
+ <>
+
·
+
+
+ {rec.distanceMi} mi
+
+ >
+ )}
+
+ {/* Rating + close rate */}
+
+
+
+ {rep.rating}
+
+ ·
+ {rep.closeRate}% close
+
- {/* AI Score — top-right */}
-
-
Score
-
-
{rec.slotStart}
-
+
+
+ {/* Arrival window */}
+
+
+
+ Arrives{' '}
+ {rec.slotStart}
+ {rec.slotEnd && (
+ <> · Done by{' '}
+ {rec.slotEnd}
+ >
+ )}
+
{/* Score breakdown bars */}
@@ -311,6 +362,29 @@ const RepCard = ({ rec, rep, rank, stormMode, accent, onAssign, onOverride }) =>
))}
+ {/* AI insight */}
+ {rec.aiInsight && (
+
+
+
+ {rec.aiInsight}
+
+
+ )}
+
+ {/* Conflict flag */}
+ {rec.conflictFlag && (
+
+
+
+ {rec.conflictFlag}
+
+
+ )}
+
{/* Reason chips */}
{reasons.map((r, i) => {
@@ -428,7 +502,7 @@ const DispatchedConfirmation = ({ info, accent }) => (
// ---------------------------------------------------------------------------
// Rep picker modal (Override)
// ---------------------------------------------------------------------------
-const RepPickerModal = ({ onClose, onSelect, accent }) => (
+const RepPickerModal = ({ onClose, onSelect, accent, reps }) => (
(
{/* Rep list */}
- {DISPATCH_REPS.map((rep, i) => {
+ {reps.map((rep, i) => {
const color = REP_STATUS_COLORS[rep.status] || '#6B7280';
const isFull = rep.todayAppointments >= rep.maxDaily;
@@ -506,10 +580,12 @@ const RepPickerModal = ({ onClose, onSelect, accent }) => (
const DispatchRecommendationDrawer = ({
selectedLead,
isProcessing,
+ reps = DISPATCH_REPS,
stormMode,
accent,
onAssign,
onDismissAfterAssign,
+ onViewDetails,
isDesktop,
}) => {
const [dispatchedInfo, setDispatchedInfo] = useState(null);
@@ -522,13 +598,13 @@ const DispatchRecommendationDrawer = ({
setDispatchedInfo(null);
}, [selectedLead?.id]);
- const handleAssign = useCallback((rep) => {
+ const handleAssign = useCallback((rep, isManual = false) => {
if (!selectedLead) return;
const recs = DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? [];
const repRec = recs.find(r => r.repId === rep.id);
- onAssign(selectedLead.id, rep.id);
+ onAssign(selectedLead.id, rep.id, isManual);
toast.success(`${rep.name.split(' ')[0]} dispatched`, {
description: `${selectedLead.property.address} · ${repRec?.slotStart ?? 'TBD'}`,
@@ -551,7 +627,7 @@ const DispatchRecommendationDrawer = ({
const handlePickerSelect = (rep) => {
setShowRepPicker(false);
- handleAssign(rep);
+ handleAssign(rep, true); // manual override
};
// Height: desktop = fixed to match lead list; mobile = min-h
@@ -586,7 +662,65 @@ const DispatchRecommendationDrawer = ({
transition={{ duration: 0.18 }}
className="p-3 space-y-3"
>
-
+
+
+ {/* AI Says block — natural language summary from top recommendation */}
+ {(() => {
+ const topRec = recommendations[0];
+ const topRep = topRec ? reps.find(r => r.id === topRec.repId) : null;
+ if (!topRec?.aiInsight || !topRep) return null;
+ const repColor = REP_STATUS_COLORS[topRep.status] || '#6B7280';
+ const scoreCol = scoreColor(topRec.score);
+ return (
+
+ {/* Header row: label */}
+
+
+
+ AI Says — Top Pick
+
+
+
+ {/* Rep identity row */}
+
+
+ {topRep.initials}
+
+
+
+ {topRep.name}
+
+
+ {REP_STATUS_LABELS[topRep.status]} · {topRec.distanceMi} mi
+
+
+
+
Score
+
+ {topRec.score}
+
+
+
+
+ {/* Quote */}
+
+ "{topRec.aiInsight}"
+
+
+ );
+ })()}
{/* Section divider */}
@@ -599,7 +733,7 @@ const DispatchRecommendationDrawer = ({
{/* Rep cards */}
{recommendations.map((rec, i) => {
- const rep = DISPATCH_REPS.find(r => r.id === rec.repId);
+ const rep = reps.find(r => r.id === rec.repId);
if (!rep) return null;
return (
setShowRepPicker(false)}
onSelect={handlePickerSelect}
accent={accent}
+ reps={reps}
/>
)}
diff --git a/src/components/dispatch/LeadQuickViewModal.jsx b/src/components/dispatch/LeadQuickViewModal.jsx
new file mode 100644
index 0000000..fcfd015
--- /dev/null
+++ b/src/components/dispatch/LeadQuickViewModal.jsx
@@ -0,0 +1,548 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import ReactDOM from 'react-dom';
+import { motion, AnimatePresence } from 'framer-motion';
+import {
+ X, Phone, Mail, MapPin, Copy, Check,
+ Pencil, AlertTriangle, Globe, PhoneCall,
+ UserCheck, Users, Clock, FileText,
+} from 'lucide-react';
+import { toast } from 'sonner';
+import { useMockStore } from '../../data/mockStore';
+
+// ---------------------------------------------------------------------------
+// Config maps
+// ---------------------------------------------------------------------------
+const URGENCY_CONFIG = {
+ emergency: { label: 'EMERGENCY', color: '#EF4444' },
+ high: { label: 'HIGH', color: '#F59E0B' },
+ standard: { label: 'STANDARD', color: '#6B7280' },
+};
+
+const STATUS_CONFIG = {
+ unassigned: { label: 'Unassigned', color: '#EF4444' },
+ at_risk: { label: 'At Risk', color: '#F59E0B' },
+ assigned: { label: 'Assigned', color: '#3B82F6' },
+ confirmed: { label: 'Confirmed', color: '#10B981' },
+};
+
+const SOURCE_CONFIG = {
+ call_center: { label: 'Call Center', color: '#3B82F6' },
+ canvassing: { label: 'Canvassing', color: '#10B981' },
+ referral: { label: 'Referral', color: '#8B5CF6' },
+ website_form: { label: 'Web Form', color: '#F59E0B' },
+};
+
+const LEAD_TYPE_LABELS = {
+ emergency_tarp: 'Emergency Tarp',
+ roof_inspection: 'Roof Inspection',
+ insurance_claim: 'Insurance Claim',
+ retail_estimate: 'Retail Estimate',
+};
+
+// ---------------------------------------------------------------------------
+// Micro helpers
+// ---------------------------------------------------------------------------
+const SectionLabel = ({ children }) => (
+
+ {children}
+
+);
+
+const Divider = () => (
+
+);
+
+// Copy-to-clipboard button — shows checkmark for 1.8s
+const CopyButton = ({ text }) => {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = useCallback((e) => {
+ e.stopPropagation();
+ navigator.clipboard.writeText(text).catch(() => {});
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1800);
+ }, [text]);
+
+ return (
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Source sub-detail — conditionally renders based on lead.source
+// All colors via inline styles to ensure correct dark/light rendering
+// ---------------------------------------------------------------------------
+const SourceDetail = ({ lead }) => {
+ if (lead.source === 'call_center' && lead.callCenter) {
+ const c = '#3B82F6';
+ return (
+
+
+
+
Center
+
{lead.callCenter.name}
+
{lead.callCenter.id}
+
+
+ );
+ }
+
+ if (lead.source === 'canvassing' && lead.canvassedBy) {
+ const c = '#10B981';
+ return (
+
+
+
+
+
+
Canvassed By
+
{lead.canvassedBy.name}
+
{lead.canvassedBy.agentId}
+
+
+ );
+ }
+
+ if (lead.source === 'referral' && lead.referredBy) {
+ const c = lead.referredBy.type === 'client' ? '#8B5CF6' : '#06B6D4';
+ const typeLabel = lead.referredBy.type === 'client' ? 'Client Referral' : 'Customer Referral';
+ return (
+
+
+
+
+
+
{typeLabel}
+
{lead.referredBy.name}
+
{lead.referredBy.id}
+
+
+ );
+ }
+
+ if (lead.source === 'website_form' && lead.webSource) {
+ const c = '#F59E0B';
+ return (
+
+
+
+
+
+
Campaign
+
{lead.webSource.campaign}
+
+
+ );
+ }
+
+ return null;
+};
+
+// ---------------------------------------------------------------------------
+// Notes section — read-only + inline edit with double-gate confirmation
+// ---------------------------------------------------------------------------
+const NotesSection = ({ lead, accent }) => {
+ const { updateLeadNotes } = useMockStore();
+ const [editing, setEditing] = useState(false);
+ const [editText, setEditText] = useState(lead.notes || '');
+ const [confirmMode, setConfirmMode] = useState(false);
+ const [agreed, setAgreed] = useState(false);
+
+ // Sync when lead changes (different lead opened)
+ useEffect(() => {
+ setEditing(false);
+ setEditText(lead.notes || '');
+ setConfirmMode(false);
+ setAgreed(false);
+ }, [lead.id, lead.notes]);
+
+ const cancelEdit = () => {
+ setEditing(false);
+ setEditText(lead.notes || '');
+ setConfirmMode(false);
+ setAgreed(false);
+ };
+
+ const confirmSave = () => {
+ updateLeadNotes(lead.id, editText.trim());
+ setEditing(false);
+ setConfirmMode(false);
+ setAgreed(false);
+ toast.success('Notes updated', {
+ description: lead.customer.name,
+ duration: 3000,
+ });
+ };
+
+ const isDirty = editText.trim() !== (lead.notes || '').trim();
+
+ return (
+
+
+
Notes & Issues
+ {!editing && (
+
+ )}
+
+
+ {/* Read-only */}
+ {!editing && (
+
+ {lead.notes || 'No notes on file.'}
+
+ )}
+
+ {/* Edit mode */}
+ {editing && (
+
+ )}
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Main Modal
+// ---------------------------------------------------------------------------
+const LeadQuickViewModal = ({ lead, onClose, accent }) => {
+ const urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard;
+ const statusCfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.unassigned;
+ const srcCfg = SOURCE_CONFIG[lead.source] || { label: lead.source, color: '#6B7280' };
+
+ // Escape to close
+ useEffect(() => {
+ const handler = (e) => { if (e.key === 'Escape') onClose(); };
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [onClose]);
+
+ // Initials from name
+ const initials = lead.customer.name
+ .split(' ')
+ .map(n => n[0])
+ .join('')
+ .slice(0, 2)
+ .toUpperCase();
+
+ const modal = (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Drawer — slides in from right */}
+ e.stopPropagation()}
+ >
+ {/* ── Header ── */}
+
+
+ {/* Pills row */}
+
+
+ {urgCfg.label}
+
+
+ {statusCfg.label}
+
+
+ {srcCfg.label}
+
+
+
+
+
+ Lead Quick View
+
+
+
+ {lead.id}
+
+
+
+
+
+ {/* ── Scrollable body ── */}
+
+
+ {/* § Client & Contact */}
+
+ Client & Contact
+
+ {/* Name with initials avatar */}
+
+
+ {initials}
+
+
+ {lead.customer.name}
+
+
+
+ {/* Contact details */}
+
+ {lead.customer.phone && (
+
+
+
+ {lead.customer.phone}
+
+
+
+ )}
+ {lead.customer.email && (
+
+
+
+ {lead.customer.email}
+
+
+
+ )}
+
+
+
+
+
+ {/* § Property */}
+
+ Property
+
+
+
+
+
+ {lead.property.address}
+
+
+ {lead.property.city}, {lead.property.state} {lead.property.zip}
+
+
+
+
+
+
+ {LEAD_TYPE_LABELS[lead.leadType] || lead.leadType}
+
+ ·
+
+
+ {lead.estimatedDuration} min est.
+
+
+
+
+
+
+ {/* § Lead Source */}
+
+ Lead Source
+
+
+ {srcCfg.label}
+
+
+
+
+
+
+
+ {/* § Notes */}
+
+
+ {/* Bottom breathing room */}
+
+
+
+ {/* ── Footer ── */}
+
+
{lead.id}
+
+
+
+ >
+ );
+
+ return ReactDOM.createPortal(modal, document.body);
+};
+
+export default LeadQuickViewModal;
diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx
index acb2171..8826914 100644
--- a/src/data/mockStore.jsx
+++ b/src/data/mockStore.jsx
@@ -2731,7 +2731,7 @@ export const DISPATCH_REPS = [
currentLat: 33.0612, currentLng: -96.7891,
todayAppointments: 3, maxDaily: 6,
skills: ['insurance_claim', 'retail_estimate'],
- performanceScore: 94,
+ performanceScore: 94, rating: 4.8, closeRate: 82,
},
{
id: 'REP-02', name: 'Aisha Kumar', initials: 'AK',
@@ -2739,7 +2739,7 @@ export const DISPATCH_REPS = [
currentLat: 33.0812, currentLng: -96.7234,
todayAppointments: 2, maxDaily: 6,
skills: ['roof_inspection', 'emergency_tarp'],
- performanceScore: 88,
+ performanceScore: 88, rating: 4.9, closeRate: 76,
},
{
id: 'REP-03', name: 'Tom Bradley', initials: 'TB',
@@ -2747,7 +2747,7 @@ export const DISPATCH_REPS = [
currentLat: 33.0389, currentLng: -96.7401,
todayAppointments: 5, maxDaily: 6,
skills: ['retail_estimate', 'roof_inspection'],
- performanceScore: 79,
+ performanceScore: 79, rating: 4.5, closeRate: 68,
},
{
id: 'REP-04', name: 'Nina Patel', initials: 'NP',
@@ -2755,7 +2755,7 @@ export const DISPATCH_REPS = [
currentLat: 33.0516, currentLng: -96.7512,
todayAppointments: 1, maxDaily: 6,
skills: ['insurance_claim', 'roof_inspection', 'emergency_tarp'],
- performanceScore: 91,
+ performanceScore: 91, rating: 4.7, closeRate: 79,
},
{
id: 'REP-05', name: 'Derek Walsh', initials: 'DW',
@@ -2763,81 +2763,105 @@ export const DISPATCH_REPS = [
currentLat: 33.0198, currentLng: -96.7234,
todayAppointments: 4, maxDaily: 6,
skills: ['retail_estimate', 'roof_inspection'],
- performanceScore: 82,
+ performanceScore: 82, rating: 4.6, closeRate: 74,
},
];
const DISPATCH_LEADS_INITIAL = [
{
id: 'LD-1001', source: 'call_center', leadType: 'emergency_tarp', urgency: 'emergency',
- customer: { name: 'John Smith', phone: '(972) 555-1001' },
+ customer: { name: 'John Smith', phone: '(972) 555-1001', email: 'john.smith@gmail.com' },
property: { address: '2612 Dunwick Dr', city: 'Plano', state: 'TX', zip: '75023', lat: 33.0708, lng: -96.7456 },
+ callCenter: { id: 'CC-01', name: 'Plano Command Center' },
+ notes: 'Customer reports major tarp blowoff after last night\'s storm — exposed decking on NE corner. Needs immediate coverage before rain resumes.',
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 8, assignedRepId: null,
},
{
id: 'LD-1002', source: 'website_form', leadType: 'roof_inspection', urgency: 'high',
- customer: { name: 'Maria Gonzalez', phone: '(972) 555-1002' },
+ customer: { name: 'Maria Gonzalez', phone: '(972) 555-1002', email: 'maria.gonzalez@gmail.com' },
property: { address: '4817 Shady Brook Ln', city: 'Plano', state: 'TX', zip: '75093', lat: 33.0652, lng: -96.8021 },
+ webSource: { campaign: 'Google Ads — Storm Season 2026' },
+ notes: 'Noticed curling shingles and granule loss in gutters. No active leak but concerned about upcoming storm season. Wants full inspection.',
estimatedDuration: 45, status: 'unassigned', arrivedMinsAgo: 14, assignedRepId: null,
},
{
id: 'LD-1003', source: 'canvassing', leadType: 'insurance_claim', urgency: 'high',
- customer: { name: 'Robert Chen', phone: '(972) 555-1003' },
+ customer: { name: 'Robert Chen', phone: '(972) 555-1003', email: 'robert.chen@outlook.com' },
property: { address: '1234 Oak Creek Blvd', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0421, lng: -96.7138 },
+ canvassedBy: { agentId: 'U-05', name: 'Marcus Webb' },
+ notes: 'Filed insurance claim after hail event 3/18. Adjuster visit scheduled 3/28 — needs estimate in hand beforehand. Cooperative and responsive.',
estimatedDuration: 45, status: 'assigned', arrivedMinsAgo: 22, assignedRepId: 'REP-01',
},
{
id: 'LD-1004', source: 'referral', leadType: 'retail_estimate', urgency: 'standard',
- customer: { name: 'Sarah Mitchell', phone: '(972) 555-1004' },
+ customer: { name: 'Sarah Mitchell', phone: '(972) 555-1004', email: 'sarah.mitchell@yahoo.com' },
property: { address: '890 Custer Rd', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0389, lng: -96.7334 },
+ referredBy: { type: 'client', id: 'CL-2201', name: 'Tom Hargrove' },
+ notes: 'Retail estimate for full reroof. No insurance involvement. Wants architectural shingles — GAF Timberline HDZ preferred. Budget-conscious.',
estimatedDuration: 30, status: 'confirmed', arrivedMinsAgo: 35, assignedRepId: 'REP-03',
},
{
id: 'LD-1005', source: 'call_center', leadType: 'roof_inspection', urgency: 'standard',
- customer: { name: 'James Wilson', phone: '(972) 555-1005' },
+ customer: { name: 'James Wilson', phone: '(972) 555-1005', email: 'j.wilson85@gmail.com' },
property: { address: '321 Independence Pkwy', city: 'Plano', state: 'TX', zip: '75023', lat: 33.0812, lng: -96.7521 },
+ callCenter: { id: 'CC-02', name: 'DFW Ops Hub' },
+ notes: 'At-risk — customer called twice to reschedule. Currently assigned but rep running behind. Possible missed appointment window.',
estimatedDuration: 45, status: 'at_risk', arrivedMinsAgo: 47, assignedRepId: 'REP-02',
},
{
id: 'LD-1006', source: 'website_form', leadType: 'insurance_claim', urgency: 'high',
- customer: { name: 'Lisa Thompson', phone: '(972) 555-1006' },
+ customer: { name: 'Lisa Thompson', phone: '(972) 555-1006', email: 'lisathompson@icloud.com' },
property: { address: '7654 Preston Rd', city: 'Plano', state: 'TX', zip: '75024', lat: 33.0934, lng: -96.8002 },
+ webSource: { campaign: 'Organic Search' },
+ notes: 'Large residential — ridge cap blown off, potential decking exposure. Adjuster already completed visit, needs supplement review before signing.',
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 5, assignedRepId: null,
},
{
id: 'LD-1007', source: 'canvassing', leadType: 'retail_estimate', urgency: 'standard',
- customer: { name: 'David Park', phone: '(972) 555-1007' },
+ customer: { name: 'David Park', phone: '(972) 555-1007', email: 'dpark.plano@gmail.com' },
property: { address: '2200 Alma Dr', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0516, lng: -96.7289 },
+ canvassedBy: { agentId: 'U-07', name: 'Priya Nair' },
+ notes: 'Customer confirmed appointment. Wants two options: repair patch vs full replacement. Existing roof is 19 years old.',
estimatedDuration: 30, status: 'confirmed', arrivedMinsAgo: 62, assignedRepId: 'REP-04',
},
{
id: 'LD-1008', source: 'referral', leadType: 'roof_inspection', urgency: 'standard',
- customer: { name: 'Angela Moore', phone: '(972) 555-1008' },
+ customer: { name: 'Angela Moore', phone: '(972) 555-1008', email: 'angela.moore72@gmail.com' },
property: { address: '555 Legacy Dr', city: 'Plano', state: 'TX', zip: '75023', lat: 33.0744, lng: -96.7612 },
+ referredBy: { type: 'customer', id: 'CU-0892', name: 'Daniel Reyes' },
+ notes: 'Referral from existing customer. Friendly homeowner, flexible scheduling. Roof is 12 years old with no known damage — proactive inspection only.',
estimatedDuration: 45, status: 'unassigned', arrivedMinsAgo: 28, assignedRepId: null,
},
{
id: 'LD-1009', source: 'call_center', leadType: 'emergency_tarp', urgency: 'emergency',
- customer: { name: 'Carlos Rivera', phone: '(972) 555-1009' },
+ customer: { name: 'Carlos Rivera', phone: '(972) 555-1009', email: 'c.rivera@hotmail.com' },
property: { address: '3301 Avenue K', city: 'Plano', state: 'TX', zip: '75074', lat: 33.0198, lng: -96.6989 },
+ callCenter: { id: 'CC-01', name: 'Plano Command Center' },
+ notes: 'ACTIVE LEAK — water through master bedroom ceiling. Family with newborn on premises. Must dispatch immediately regardless of weather conditions.',
estimatedDuration: 90, status: 'unassigned', arrivedMinsAgo: 3, assignedRepId: null,
},
{
id: 'LD-1010', source: 'website_form', leadType: 'retail_estimate', urgency: 'standard',
- customer: { name: 'Patricia Davis', phone: '(972) 555-1010' },
+ customer: { name: 'Patricia Davis', phone: '(972) 555-1010', email: 'pdavis.home@gmail.com' },
property: { address: '6711 W Spring Creek Pkwy', city: 'Plano', state: 'TX', zip: '75024', lat: 33.0888, lng: -96.7756 },
+ webSource: { campaign: 'Facebook — Spring Roofing Promo' },
+ notes: 'At-risk of going cold — no response to last two callbacks. Rep assigned but contact not confirmed. Try alternate number if available.',
estimatedDuration: 30, status: 'at_risk', arrivedMinsAgo: 55, assignedRepId: 'REP-05',
},
{
id: 'LD-1011', source: 'canvassing', leadType: 'insurance_claim', urgency: 'high',
- customer: { name: 'Michael Anderson', phone: '(972) 555-1011' },
+ customer: { name: 'Michael Anderson', phone: '(972) 555-1011', email: 'michael.a@outlook.com' },
property: { address: '1800 Jupiter Rd', city: 'Plano', state: 'TX', zip: '75074', lat: 33.0267, lng: -96.7102 },
+ canvassedBy: { agentId: 'U-05', name: 'Marcus Webb' },
+ notes: 'Multiple hail impacts on canvass route. Customer receptive, confirmed hail event on property. Adjuster not yet contacted — walk them through the process.',
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 11, assignedRepId: null,
},
{
id: 'LD-1012', source: 'referral', leadType: 'roof_inspection', urgency: 'standard',
- customer: { name: 'Jennifer White', phone: '(972) 555-1012' },
+ customer: { name: 'Jennifer White', phone: '(972) 555-1012', email: 'jwhite.plano@gmail.com' },
property: { address: '4400 W Parker Rd', city: 'Plano', state: 'TX', zip: '75093', lat: 33.0612, lng: -96.8145 },
+ referredBy: { type: 'client', id: 'CL-1178', name: 'Brenda Kowalski' },
+ notes: 'Confirmed appointment. Customer already has a competing estimate — come in strong on materials quality. GAF or Owens Corning preferred. Price-sensitive but quality-motivated.',
estimatedDuration: 45, status: 'confirmed', arrivedMinsAgo: 72, assignedRepId: 'REP-01',
},
];
@@ -2848,211 +2872,331 @@ export const DISPATCH_RECOMMENDATIONS = {
'LD-1001': [
{
repId: 'REP-04', score: 91, slotStart: '1:30 PM', slotEnd: '2:30 PM',
+ distanceMi: '2.1',
factors: { proximity: 88, driveTime: 90, calendarFit: 95, weather: 90, skillMatch: 100 },
reasons: ['Closest eligible rep — 2.1 mi', '12 min drive time estimated', 'Insurance claim certified', 'Buffer preserved before next appointment'],
stormReasons: ['Lightning risk before 2 PM — slot pushed to 1:30 PM', 'Rep carries emergency tarp kit'],
+ aiInsight: 'Calendar and proximity align perfectly — earliest safe window before afternoon storm risk',
+ conflictFlag: null,
routePath: [[33.0516, -96.7512], [33.0612, -96.7484], [33.0708, -96.7456]],
},
{
repId: 'REP-02', score: 79, slotStart: '3:00 PM', slotEnd: '4:00 PM',
+ distanceMi: '3.8',
factors: { proximity: 72, driveTime: 75, calendarFit: 82, weather: 80, skillMatch: 85 },
reasons: ['Available at 3 PM', '18 min drive — moderate traffic', 'Roof inspection certified'],
stormReasons: ['Afternoon slot carries weather risk — monitor closely'],
+ aiInsight: 'Solid backup with roof certification — afternoon slot viable but weather requires monitoring',
+ conflictFlag: 'Post-3 PM weather risk — confirm conditions before dispatch',
routePath: [[33.0812, -96.7234], [33.0760, -96.7345], [33.0708, -96.7456]],
},
{
repId: 'REP-01', score: 71, slotStart: '4:30 PM', slotEnd: '5:30 PM',
+ distanceMi: '5.2',
factors: { proximity: 62, driveTime: 60, calendarFit: 78, weather: 70, skillMatch: 100 },
reasons: ['Available later in the day', '24 min drive from current location', 'High performer — score 94'],
stormReasons: ['Late afternoon — weather conditions improving'],
+ aiInsight: 'High performer but distance and late slot reduce overall optimality for this job type',
+ conflictFlag: '20 min buffer only after completion — schedule at risk if job runs long',
routePath: [[33.0612, -96.7891], [33.0660, -96.7674], [33.0708, -96.7456]],
},
],
'LD-1002': [
{
repId: 'REP-01', score: 88, slotStart: '2:00 PM', slotEnd: '2:45 PM',
+ distanceMi: '1.4',
factors: { proximity: 92, driveTime: 88, calendarFit: 85, weather: 88, skillMatch: 100 },
reasons: ['Closest rep — 1.4 mi from lead location', '8 min drive time', 'Buffer maintained after current job'],
stormReasons: ['Weather clear until 3 PM — slot timing is safe', 'Rep familiar with West Plano territory'],
+ aiInsight: 'Nearest eligible rep with strong calendar fit — weather window closes cleanly after completion',
+ conflictFlag: null,
routePath: [[33.0612, -96.7891], [33.0632, -96.7956], [33.0652, -96.8021]],
},
{
repId: 'REP-04', score: 74, slotStart: '3:30 PM', slotEnd: '4:15 PM',
+ distanceMi: '4.1',
factors: { proximity: 70, driveTime: 68, calendarFit: 85, weather: 72, skillMatch: 100 },
reasons: ['Available at 3:30 PM', '22 min drive from current position', 'Insurance claim certified'],
stormReasons: ['Afternoon slot — weather risk moderate after 3 PM'],
+ aiInsight: 'Certified fallback with open afternoon — drive time and weather reduce confidence vs. top pick',
+ conflictFlag: 'Afternoon weather window tight — start punctually or reschedule',
routePath: [[33.0516, -96.7512], [33.0584, -96.7767], [33.0652, -96.8021]],
},
],
'LD-1003': [
{
repId: 'REP-01', score: 87, slotStart: '11:00 AM', slotEnd: '11:45 AM',
+ distanceMi: '2.8',
factors: { proximity: 85, driveTime: 88, calendarFit: 90, weather: 85, skillMatch: 100 },
reasons: ['Currently assigned — optimal for continuity', '11 min drive from current stop', 'Insurance claim specialist'],
stormReasons: ['Morning slot — no weather concerns'],
+ aiInsight: 'Continuity assignment maximizes close probability — same rep builds on prior customer rapport',
+ conflictFlag: null,
routePath: [[33.0612, -96.7891], [33.0517, -96.7515], [33.0421, -96.7138]],
},
{
repId: 'REP-05', score: 67, slotStart: '1:00 PM', slotEnd: '1:45 PM',
+ distanceMi: '4.4',
factors: { proximity: 65, driveTime: 62, calendarFit: 72, weather: 75, skillMatch: 60 },
reasons: ['Available afternoon slot', '19 min drive', 'Less specialized — retail estimate primary skill'],
stormReasons: ['Afternoon — monitor weather after 1 PM'],
+ aiInsight: 'Capable fallback but skill mismatch lowers confidence for insurance claim type',
+ conflictFlag: 'Skill mismatch — retail primary, insurance secondary skill only',
routePath: [[33.0198, -96.7234], [33.0310, -96.7186], [33.0421, -96.7138]],
},
],
'LD-1004': [
{
repId: 'REP-03', score: 95, slotStart: '10:30 AM', slotEnd: '11:00 AM',
+ distanceMi: '0.6',
factors: { proximity: 98, driveTime: 97, calendarFit: 88, weather: 95, skillMatch: 100 },
reasons: ['0.6 mi away — optimal proximity', '4 min drive time', 'Retail estimate specialist'],
stormReasons: ['Morning slot — conditions favorable'],
+ aiInsight: 'Near-perfect assignment — virtually on-site with ideal skill set and favorable morning conditions',
+ conflictFlag: null,
routePath: [[33.0389, -96.7401], [33.0389, -96.7368], [33.0389, -96.7334]],
},
{
repId: 'REP-05', score: 73, slotStart: '2:00 PM', slotEnd: '2:30 PM',
+ distanceMi: '3.3',
factors: { proximity: 70, driveTime: 68, calendarFit: 80, weather: 72, skillMatch: 80 },
reasons: ['Available afternoon', '16 min drive', 'Retail estimate capable'],
stormReasons: ['Afternoon slot — watch post-2 PM weather'],
+ aiInsight: 'Retail-capable with open afternoon — viable if top pick becomes unavailable',
+ conflictFlag: null,
routePath: [[33.0198, -96.7234], [33.0294, -96.7284], [33.0389, -96.7334]],
},
],
'LD-1005': [
{
repId: 'REP-02', score: 94, slotStart: '12:00 PM', slotEnd: '12:45 PM',
+ distanceMi: '0.3',
factors: { proximity: 97, driveTime: 96, calendarFit: 90, weather: 92, skillMatch: 95 },
- reasons: ['0.3 mi from lead — walking distance', '3 min drive time', 'Roof inspection specialist', 'Low daily load — 2 of 6 slots used'],
+ reasons: ['0.3 mi from lead — effectively on-site', '3 min drive time', 'Roof inspection specialist', 'Low daily load — 2 of 6 slots used'],
stormReasons: ['Midday window — weather acceptable', 'Rep carries safety equipment'],
+ aiInsight: 'Exceptional proximity with light workload — highest-confidence dispatch of the day',
+ conflictFlag: null,
routePath: [[33.0812, -96.7234], [33.0812, -96.7378], [33.0812, -96.7521]],
},
{
repId: 'REP-04', score: 77, slotStart: '2:30 PM', slotEnd: '3:15 PM',
+ distanceMi: '3.6',
factors: { proximity: 72, driveTime: 70, calendarFit: 88, weather: 78, skillMatch: 90 },
reasons: ['Available at 2:30 PM', '17 min drive', 'Roof inspection certified'],
stormReasons: ['Afternoon — post-3 PM weather worsening, start punctually'],
+ aiInsight: 'Certified backup with clean afternoon window — must start on time before weather closes in',
+ conflictFlag: 'Start before 3 PM — weather deteriorates sharply after',
routePath: [[33.0516, -96.7512], [33.0664, -96.7517], [33.0812, -96.7521]],
},
],
'LD-1006': [
{
repId: 'REP-01', score: 83, slotStart: '3:00 PM', slotEnd: '4:00 PM',
+ distanceMi: '3.1',
factors: { proximity: 80, driveTime: 78, calendarFit: 85, weather: 82, skillMatch: 100 },
reasons: ['Best available slot — 3 PM', '14 min drive from current route', 'Insurance claim specialist'],
stormReasons: ['Wind advisory active — exterior inspection may need delay', 'Insurance claim can proceed indoors'],
+ aiInsight: 'Best certified specialist available — indoor claim process can proceed despite active wind advisory',
+ conflictFlag: 'Wind advisory active — verify exterior conditions before starting roof portion',
routePath: [[33.0612, -96.7891], [33.0773, -96.7947], [33.0934, -96.8002]],
},
{
repId: 'REP-02', score: 69, slotStart: '4:30 PM', slotEnd: '5:30 PM',
+ distanceMi: '5.8',
factors: { proximity: 65, driveTime: 62, calendarFit: 75, weather: 68, skillMatch: 85 },
reasons: ['Late afternoon availability', '26 min drive', 'Roof inspection skilled'],
stormReasons: ['Late afternoon — conditions deteriorating, not recommended'],
+ aiInsight: 'Low-confidence option — weather and distance combine unfavorably for late afternoon exterior work',
+ conflictFlag: 'Not recommended — conditions deteriorating by 4:30 PM, daylight limited',
routePath: [[33.0812, -96.7234], [33.0873, -96.7618], [33.0934, -96.8002]],
},
],
'LD-1007': [
{
repId: 'REP-04', score: 87, slotStart: '11:30 AM', slotEnd: '12:00 PM',
+ distanceMi: '1.5',
factors: { proximity: 90, driveTime: 88, calendarFit: 92, weather: 90, skillMatch: 80 },
reasons: ['1.5 mi away — close proximity', '9 min drive time', 'Low daily load', 'Buffer before afternoon appointments'],
stormReasons: ['Morning window — safe conditions'],
+ aiInsight: 'Light daily load with close proximity — midday window maximizes buffer for afternoon pipeline',
+ conflictFlag: null,
routePath: [[33.0516, -96.7512], [33.0516, -96.7401], [33.0516, -96.7289]],
},
{
repId: 'REP-03', score: 78, slotStart: '1:30 PM', slotEnd: '2:00 PM',
+ distanceMi: '2.9',
factors: { proximity: 78, driveTime: 75, calendarFit: 70, weather: 82, skillMatch: 100 },
reasons: ['Retail estimate specialist', '12 min drive', 'Near current work site'],
stormReasons: ['Early afternoon — conditions acceptable'],
+ aiInsight: 'Specialist-level skill match but heavier daily load reduces calendar fit score',
+ conflictFlag: '5 appointments today — buffer is tight if any job runs over',
routePath: [[33.0389, -96.7401], [33.0453, -96.7345], [33.0516, -96.7289]],
},
],
'LD-1008': [
{
repId: 'REP-04', score: 86, slotStart: '2:00 PM', slotEnd: '2:45 PM',
+ distanceMi: '2.7',
factors: { proximity: 84, driveTime: 85, calendarFit: 90, weather: 85, skillMatch: 95 },
reasons: ['2.7 mi from lead — manageable drive', '15 min estimated travel', 'Roof inspection certified', 'Open afternoon slot'],
stormReasons: ['Weather acceptable until 3:30 PM', 'Book for 2 PM to maximize safe window'],
+ aiInsight: 'Certified for roof inspection with open slot — 2 PM booking maximizes safe weather window',
+ conflictFlag: null,
routePath: [[33.0516, -96.7512], [33.0630, -96.7562], [33.0744, -96.7612]],
},
{
repId: 'REP-02', score: 75, slotStart: '3:30 PM', slotEnd: '4:15 PM',
+ distanceMi: '4.1',
factors: { proximity: 72, driveTime: 70, calendarFit: 82, weather: 74, skillMatch: 90 },
reasons: ['Available at 3:30 PM', '19 min drive', 'Roof inspection specialist'],
stormReasons: ['Late afternoon — weather risk elevated after 3 PM'],
+ aiInsight: 'Roof specialist with afternoon availability — weather risk increases as slot approaches 4 PM',
+ conflictFlag: '15 min buffer after completion — schedule may be tight if delayed',
routePath: [[33.0812, -96.7234], [33.0778, -96.7423], [33.0744, -96.7612]],
},
{
repId: 'REP-01', score: 68, slotStart: '5:00 PM', slotEnd: '5:45 PM',
+ distanceMi: '5.5',
factors: { proximity: 62, driveTime: 58, calendarFit: 70, weather: 68, skillMatch: 80 },
reasons: ['End of day slot only', '22 min drive', 'High performer — score 94'],
stormReasons: ['Evening — conditions improving but daylight limited'],
+ aiInsight: 'High performer but 5 PM slot limits daylight for roof work — use only as last resort',
+ conflictFlag: 'Daylight limited at 5 PM — roof inspection may be incomplete',
routePath: [[33.0612, -96.7891], [33.0678, -96.7752], [33.0744, -96.7612]],
},
],
'LD-1009': [
{
repId: 'REP-05', score: 97, slotStart: '11:00 AM', slotEnd: '12:30 PM',
+ distanceMi: '0.8',
factors: { proximity: 99, driveTime: 98, calendarFit: 95, weather: 95, skillMatch: 90 },
reasons: ['0.8 mi away — nearest rep on site', '5 min drive time', 'Emergency certified', 'PRIORITY: emergency tarp needed'],
stormReasons: ['EMERGENCY — dispatch immediately', 'Rep stocked with emergency tarp materials'],
+ aiInsight: 'PRIORITY dispatch — nearest certified rep with emergency materials on hand, 5 min ETA',
+ conflictFlag: null,
routePath: [[33.0198, -96.7234], [33.0198, -96.7112], [33.0198, -96.6989]],
},
{
repId: 'REP-03', score: 71, slotStart: '1:00 PM', slotEnd: '2:30 PM',
+ distanceMi: '4.6',
factors: { proximity: 65, driveTime: 62, calendarFit: 75, weather: 72, skillMatch: 75 },
reasons: ['Backup option — 5 appointments today', '21 min drive', 'Roof inspection capable'],
stormReasons: ['Secondary option — use only if REP-05 unavailable'],
+ aiInsight: 'Emergency backup only — heavier daily load and longer drive make this a secondary option',
+ conflictFlag: '5 appointments today — minimal buffer if any prior job overruns',
routePath: [[33.0389, -96.7401], [33.0294, -96.7195], [33.0198, -96.6989]],
},
],
'LD-1010': [
{
repId: 'REP-01', score: 84, slotStart: '2:30 PM', slotEnd: '3:00 PM',
+ distanceMi: '2.9',
factors: { proximity: 82, driveTime: 80, calendarFit: 88, weather: 84, skillMatch: 80 },
reasons: ['Best proximity match — 2.9 mi', '16 min drive', 'High performer — score 94', 'Manageable daily load'],
stormReasons: ['Afternoon slot — monitor weather', 'Retail estimate can proceed partially indoors'],
+ aiInsight: 'Top performer with manageable load — retail estimate can proceed indoors if afternoon weather shifts',
+ conflictFlag: null,
routePath: [[33.0612, -96.7891], [33.0750, -96.7824], [33.0888, -96.7756]],
},
{
repId: 'REP-02', score: 72, slotStart: '4:00 PM', slotEnd: '4:30 PM',
+ distanceMi: '4.8',
factors: { proximity: 70, driveTime: 68, calendarFit: 78, weather: 70, skillMatch: 80 },
reasons: ['Available late afternoon', '22 min drive', 'Roof inspection certified'],
stormReasons: ['4 PM slot — weather conditions deteriorating, not ideal'],
+ aiInsight: 'Viable backup but 4 PM timing and weather trend reduce confidence for exterior estimate',
+ conflictFlag: '4 PM slot — low daylight and deteriorating conditions for exterior work',
routePath: [[33.0812, -96.7234], [33.0850, -96.7495], [33.0888, -96.7756]],
},
],
'LD-1011': [
{
repId: 'REP-05', score: 89, slotStart: '12:30 PM', slotEnd: '1:30 PM',
+ distanceMi: '1.6',
factors: { proximity: 90, driveTime: 88, calendarFit: 85, weather: 88, skillMatch: 90 },
reasons: ['Closest rep — 1.6 mi', '9 min drive time', 'Insurance claim proficient', 'Midday slot timing is optimal'],
stormReasons: ['Midday window — acceptable conditions', 'Insurance claim can be initiated outdoors then moved inside'],
+ aiInsight: 'Close proximity with optimal midday timing — insurance initiation can flex indoors if needed',
+ conflictFlag: null,
routePath: [[33.0198, -96.7234], [33.0233, -96.7168], [33.0267, -96.7102]],
},
{
repId: 'REP-03', score: 76, slotStart: '2:00 PM', slotEnd: '3:00 PM',
+ distanceMi: '3.5',
factors: { proximity: 74, driveTime: 70, calendarFit: 78, weather: 76, skillMatch: 80 },
reasons: ['Available at 2 PM', '18 min drive', 'Retail estimate skilled'],
stormReasons: ['Afternoon slot — conditions marginal after 2:30 PM'],
+ aiInsight: 'Partial skill match with afternoon availability — conditions deteriorate mid-appointment window',
+ conflictFlag: 'Skill alignment moderate — retail primary, insurance secondary only',
routePath: [[33.0389, -96.7401], [33.0328, -96.7252], [33.0267, -96.7102]],
},
],
'LD-1012': [
{
repId: 'REP-01', score: 92, slotStart: '10:00 AM', slotEnd: '10:45 AM',
+ distanceMi: '1.7',
factors: { proximity: 94, driveTime: 92, calendarFit: 88, weather: 92, skillMatch: 95 },
reasons: ['Currently assigned — optimal', '1.7 mi from current position', 'Morning slot — ideal weather window', 'Insurance + retail estimate certified'],
stormReasons: ['Morning slot — safe window before any afternoon weather'],
+ aiInsight: 'Morning assignment with ideal conditions — dual-certified rep maximizes flexibility for this job type',
+ conflictFlag: null,
routePath: [[33.0612, -96.7891], [33.0612, -96.8018], [33.0612, -96.8145]],
},
{
repId: 'REP-04', score: 70, slotStart: '1:00 PM', slotEnd: '1:45 PM',
+ distanceMi: '5.3',
factors: { proximity: 65, driveTime: 62, calendarFit: 82, weather: 70, skillMatch: 85 },
reasons: ['Afternoon availability', '24 min drive from current location', 'Roof inspection certified'],
stormReasons: ['Afternoon — conditions acceptable but watch post-2 PM'],
+ aiInsight: 'Longer drive reduces score significantly — afternoon window is viable if morning rep unavailable',
+ conflictFlag: '24 min drive cuts into slot buffer — delays cascade to next appointment',
routePath: [[33.0516, -96.7512], [33.0564, -96.7829], [33.0612, -96.8145]],
},
],
};
+// ---------------------------------------------------------------------------
+// Weekly performance data — Mon Mar 20 → Sun Mar 26 (current week)
+// repBreakdown keys match DISPATCH_REPS ids
+// ---------------------------------------------------------------------------
+export const DISPATCH_WEEKLY_STATS = [
+ {
+ day: 'Mon', date: 'Mar 20', dispatched: 7, avgWait: 18, emergency: 0,
+ stormDay: false, isToday: false,
+ repBreakdown: { 'REP-01': 2, 'REP-02': 2, 'REP-03': 1, 'REP-04': 1, 'REP-05': 1 },
+ },
+ {
+ day: 'Tue', date: 'Mar 21', dispatched: 9, avgWait: 14, emergency: 1,
+ stormDay: false, isToday: false,
+ repBreakdown: { 'REP-01': 2, 'REP-02': 2, 'REP-03': 2, 'REP-04': 2, 'REP-05': 1 },
+ },
+ {
+ day: 'Wed', date: 'Mar 22', dispatched: 6, avgWait: 22, emergency: 0,
+ stormDay: false, isToday: false,
+ repBreakdown: { 'REP-01': 1, 'REP-02': 2, 'REP-03': 1, 'REP-04': 1, 'REP-05': 1 },
+ },
+ {
+ day: 'Thu', date: 'Mar 23', dispatched: 14, avgWait: 9, emergency: 4,
+ stormDay: true, isToday: false,
+ repBreakdown: { 'REP-01': 3, 'REP-02': 3, 'REP-03': 3, 'REP-04': 3, 'REP-05': 2 },
+ },
+ {
+ day: 'Fri', date: 'Mar 24', dispatched: 10, avgWait: 13, emergency: 2,
+ stormDay: false, isToday: false,
+ repBreakdown: { 'REP-01': 2, 'REP-02': 2, 'REP-03': 2, 'REP-04': 2, 'REP-05': 2 },
+ },
+ {
+ day: 'Sat', date: 'Mar 25', dispatched: 5, avgWait: 20, emergency: 0,
+ stormDay: false, isToday: false,
+ repBreakdown: { 'REP-01': 1, 'REP-02': 1, 'REP-03': 1, 'REP-04': 1, 'REP-05': 1 },
+ },
+ {
+ day: 'Sun', date: 'Mar 26', dispatched: 3, avgWait: 24, emergency: 0,
+ stormDay: false, isToday: true,
+ repBreakdown: { 'REP-01': 1, 'REP-02': 1, 'REP-03': 0, 'REP-04': 1, 'REP-05': 0 },
+ },
+];
+
// --- CONTEXT SETUP ---
const MockStoreContext = createContext();
@@ -3180,6 +3324,11 @@ export const MockStoreProvider = ({ children }) => {
l.id === leadId ? { ...l, assignedRepId: repId, status: 'confirmed' } : l
));
},
+ updateLeadNotes: (leadId, notes) => {
+ setDispatchLeads(prev => prev.map(l =>
+ l.id === leadId ? { ...l, notes } : l
+ ));
+ },
updatePropertyStatus,
addMeeting,
diff --git a/src/pages/LynkDispatchPage.jsx b/src/pages/LynkDispatchPage.jsx
index eeff0a2..c8c8edb 100644
--- a/src/pages/LynkDispatchPage.jsx
+++ b/src/pages/LynkDispatchPage.jsx
@@ -1,16 +1,24 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
-import { Zap, CloudLightning, Radio, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown } from 'lucide-react';
+import { Zap, CloudLightning, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown, History, BarChart2 } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useMockStore, DISPATCH_REPS } from '../data/mockStore';
import DispatchLeadQueue from '../components/dispatch/DispatchLeadQueue';
import DispatchRecommendationDrawer from '../components/dispatch/DispatchRecommendationDrawer';
+import DispatchMapPanel from '../components/dispatch/DispatchMapPanel';
+import LeadQuickViewModal from '../components/dispatch/LeadQuickViewModal';
+import DispatchLogDrawer from '../components/dispatch/DispatchLogDrawer';
+import DispatchChartModal from '../components/dispatch/DispatchChartModal';
// ---------------------------------------------------------------------------
// Panel — themed container
// ---------------------------------------------------------------------------
-const Panel = ({ children, className = '', style }) => (
+const Panel = ({ children, className = '', style, stormMode }) => (
{children}
@@ -130,9 +138,22 @@ const CollapsePanel = ({ open, children }) => (
// ---------------------------------------------------------------------------
const LynkDispatchPage = () => {
const { dispatchLeads, assignDispatchLead } = useMockStore();
+ const [viewDetailLead, setViewDetailLead] = useState(null);
+ const [dispatchLog, setDispatchLog] = useState([]);
const [selectedLead, setSelectedLead] = useState(null);
const [stormMode, setStormMode] = useState(false);
+ const [logDrawerOpen, setLogDrawerOpen] = useState(false);
+ const [chartModalOpen, setChartModalOpen] = useState(false);
+
+ // Rep status live cycling — overrides static DISPATCH_REPS.status
+ const [repStatuses, setRepStatuses] = useState(
+ () => Object.fromEntries(DISPATCH_REPS.map(r => [r.id, r.status]))
+ );
+
+ // Dispatch efficiency tracking — seeded with today's prior activity
+ const [aiCount, setAiCount] = useState(8);
+ const [totalCount, setTotalCount] = useState(9);
const [isProcessing, setIsProcessing] = useState(false);
const [leftWidth, setLeftWidth] = useState(300);
const [rightWidth, setRightWidth] = useState(320);
@@ -173,19 +194,71 @@ const LynkDispatchPage = () => {
}
};
- const handleAssign = useCallback((leadId, repId) => {
+ const handleAssign = useCallback((leadId, repId, isManual = false) => {
assignDispatchLead(leadId, repId);
- }, [assignDispatchLead]);
+
+ // Cycle rep status: en_route for 60s, then back to available
+ setRepStatuses(prev => ({ ...prev, [repId]: 'en_route' }));
+ setTimeout(() => {
+ setRepStatuses(prev => ({ ...prev, [repId]: 'available' }));
+ }, 60000);
+
+ // Track AI vs manual efficiency
+ setTotalCount(n => n + 1);
+ if (!isManual) setAiCount(n => n + 1);
+
+ const lead = dispatchLeads.find(l => l.id === leadId);
+ const rep = DISPATCH_REPS.find(r => r.id === repId);
+ if (lead && rep) {
+ setDispatchLog(prev => [{
+ id: Date.now(),
+ repFirstName: rep.name.split(' ')[0],
+ repInitials: rep.initials,
+ repStatus: 'en_route',
+ customerName: lead.customer.name,
+ address: `${lead.property.address}, ${lead.property.city}`,
+ leadType: lead.leadType,
+ urgency: lead.urgency,
+ timestamp: Date.now(),
+ }, ...prev].slice(0, 20));
+ }
+ }, [assignDispatchLead, dispatchLeads]);
const handleDismissAfterAssign = useCallback(() => {
setSelectedLead(null);
}, []);
- const accent = stormMode ? '#F59E0B' : '#3B82F6';
- const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
+ const accent = stormMode ? '#F59E0B' : '#3B82F6';
+
+ // Merge live statuses into rep objects — passed to map + AI drawer
+ const effectiveReps = DISPATCH_REPS.map(r => ({
+ ...r,
+ status: repStatuses[r.id] ?? r.status,
+ }));
+
+ // Efficiency score — what % of dispatches were AI-recommended (not manual override)
+ const efficiencyPct = totalCount > 0 ? Math.round((aiCount / totalCount) * 100) : 0;
+
+ // ── Live KPI derivations ──
+ const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
+ const assignedToday = dispatchLeads.filter(l => ['assigned', 'confirmed'].includes(l.status)).length;
+ const atRiskCount = dispatchLeads.filter(l => l.status === 'at_risk').length;
+ const emergencyCount = dispatchLeads.filter(l => l.urgency === 'emergency').length;
+ const alertCount = stormMode ? emergencyCount + atRiskCount : atRiskCount;
+
+ const avgWait = (() => {
+ const pending = dispatchLeads.filter(l => l.status === 'unassigned');
+ if (!pending.length) return '—';
+ const avg = Math.round(
+ pending.reduce((sum, l) => sum + (l.arrivedMinsAgo || 0), 0) / pending.length
+ );
+ return `${avg} min`;
+ })();
return (
-
+
{/* Storm Banner */}
@@ -220,15 +293,118 @@ const LynkDispatchPage = () => {
LynkDispatch
- AI Dispatch Engine
+
+
+ {stormMode ? (
+
+ Storm Protocol Active
+
+ ) : (
+
+ AI Dispatch Engine
+
+ )}
+
+
+ {totalCount > 0 && (
+
+
+ {efficiencyPct}% AI-Dispatched
+
+ )}
+
+
- {/* LIVE badge */}
-
-
-
Live
+ {/* LIVE / STORM ACTIVE badge */}
+
+ {stormMode ? (
+
+
+ Storm Active
+
+ ) : (
+
+
+ Live
+
+ )}
+
+
+ {/* Log + Chart buttons */}
+
+
+
+
{/* Storm Mode Toggle */}
@@ -256,27 +432,39 @@ const LynkDispatchPage = () => {
toggle('queue')}
right={
-
- {dispatchLeads.length}+
-
+ stormMode && emergencyCount > 0 ? (
+
+
+ {emergencyCount} critical
+
+ ) : (
+
+ {dispatchLeads.length}+
+
+ )
}
/>
{isDesktop ? (
@@ -285,6 +473,8 @@ const LynkDispatchPage = () => {
@@ -295,34 +485,44 @@ const LynkDispatchPage = () => {
{/* CENTER — Territory Map */}
-
+
toggle('map')}
right={
-
- {DISPATCH_REPS.length} reps active
+
+
+ {DISPATCH_REPS.filter(r => r.status === 'available').length} available
+
}
/>
- {/* Placeholder — Phase 4 builds Leaflet map here */}
{isDesktop ? (
-
-
-
+
) : (
-
-
-
+
)}
@@ -334,6 +534,7 @@ const LynkDispatchPage = () => {
{
) : (
@@ -376,6 +579,7 @@ const LynkDispatchPage = () => {
accent={accent}
onAssign={handleAssign}
onDismissAfterAssign={handleDismissAfterAssign}
+ onViewDetails={setViewDetailLead}
isDesktop={isDesktop}
/>
@@ -384,74 +588,78 @@ const LynkDispatchPage = () => {
+ {/* ── Lead Quick View Modal ── */}
+
+ {viewDetailLead && (
+ setViewDetailLead(null)}
+ accent={accent}
+ />
+ )}
+
+
{/* ── KPI Bar ── */}
-
+
0}
/>
0 ? '#F59E0B' : '#6B7280'}
stormMode={stormMode}
- pulse={stormMode}
+ pulse={stormMode && alertCount > 0}
/>
+
+ {/* ── Dispatch Log Drawer ── */}
+
+ {logDrawerOpen && (
+ setLogDrawerOpen(false)}
+ />
+ )}
+
+
+ {/* ── Weekly Chart Modal ── */}
+
+ {chartModalOpen && (
+ setChartModalOpen(false)}
+ />
+ )}
+
);
};
-// ---------------------------------------------------------------------------
-// Extracted placeholder content (avoids duplication between desktop/mobile)
-// ---------------------------------------------------------------------------
-const MapPlaceholderContent = () => (
- <>
-
-
-
-
-
Interactive Map
-
Leaflet map with rep markers & route polylines — Phase 4
-
-
- {[
- { label: 'Available', count: DISPATCH_REPS.filter(r => r.status === 'available').length, color: '#10B981' },
- { label: 'En Route', count: DISPATCH_REPS.filter(r => r.status === 'en_route').length, color: '#3B82F6' },
- { label: 'Busy', count: DISPATCH_REPS.filter(r => r.status === 'busy').length, color: '#F59E0B' },
- ].map(s => (
-
- {s.count} {s.label}
-
- ))}
-
- >
-);
-
export default LynkDispatchPage;