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 ( -
- - - - - - +
+ + {/* Track */} + + {/* Fill */} + + {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 && ( +
+