import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Bot, CheckCircle, X, Star, Clock, Navigation, CloudLightning, Zap, Users, ChevronRight, AlertTriangle, Sparkles, FileText, } from 'lucide-react'; import { toast } from 'sonner'; import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore'; import { AnimatedCounter } from '../AnimatedCounter'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const scoreColor = (s) => s >= 85 ? '#10B981' : s >= 70 ? '#F59E0B' : '#EF4444'; const URGENCY_CONFIG = { emergency: { label: 'EMERGENCY', color: '#EF4444' }, high: { label: 'HIGH', color: '#F59E0B' }, standard: { label: 'STANDARD', color: '#6B7280' }, }; const LEAD_TYPE_LABELS = { emergency_tarp: 'Emergency Tarp', roof_inspection: 'Roof Inspection', insurance_claim: 'Insurance Claim', retail_estimate: 'Retail Estimate', }; const FACTOR_LABELS = { proximity: 'Proximity', driveTime: 'Drive Time', calendarFit: 'Calendar', weather: 'Weather', skillMatch: 'Skill Match', }; const REP_STATUS_LABELS = { available: 'Available', en_route: 'En Route', busy: 'Busy' }; const REP_STATUS_COLORS = { available: '#10B981', en_route: '#3B82F6', busy: '#F59E0B' }; // --------------------------------------------------------------------------- // Processing skeleton — structured to mirror rep card shape // --------------------------------------------------------------------------- const ANALYZING_MSGS = [ 'Analyzing lead requirements...', 'Scoring field reps...', 'Checking calendar availability...', 'Calculating drive times...', 'Factoring weather conditions...', 'Ranking recommendations...', ]; const AnalyzingText = ({ accent }) => { const [idx, setIdx] = useState(0); useEffect(() => { const t = setInterval(() => setIdx(i => (i + 1) % ANALYZING_MSGS.length), 380); return () => clearInterval(t); }, []); return ( {ANALYZING_MSGS[idx]} ); }; const RepCardSkeleton = ({ delay = 0 }) => (
{/* Avatar + name + score */}
{/* Score bars */}
{[100, 87, 93, 79, 95].map((w, i) => (
))}
{/* Chips */}
{[56, 72, 48].map((w, i) => (
))}
{/* Buttons */}
); const ProcessingSkeleton = ({ accent }) => (
{/* Lead summary shimmer */}
{/* Analyzing indicator */}
{/* Rep card shimmers */}
); // --------------------------------------------------------------------------- // Score breakdown bar // --------------------------------------------------------------------------- const ScoreBar = ({ label, value, index }) => { const color = scoreColor(value); return (
{label}
{value}
); }; // --------------------------------------------------------------------------- // 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 r = 8; const circumference = 2 * Math.PI * r; const filled = circumference * Math.min(pct, 1); return (
{/* Track */} {/* Fill */} {used}/{max} slots
); }; // --------------------------------------------------------------------------- // Rep avatar with status dot // --------------------------------------------------------------------------- const RepAvatar = ({ initials, status }) => { const color = REP_STATUS_COLORS[status] || '#6B7280'; return (
{initials}
); }; // --------------------------------------------------------------------------- // Lead summary header // --------------------------------------------------------------------------- const LeadSummary = ({ lead, stormMode, onViewDetails }) => { const urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard; return (

{lead.customer.name}

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

{urgCfg.label}
{LEAD_TYPE_LABELS[lead.leadType] || lead.leadType} · {lead.estimatedDuration} min est.
{onViewDetails && ( )}
); }; // --------------------------------------------------------------------------- // Rep card // --------------------------------------------------------------------------- const RepCard = ({ rec, rep, rank, stormMode, accent, onAssign, onOverride }) => { const [assigned, setAssigned] = useState(false); const color = scoreColor(rec.score); const reasons = (stormMode && rec.stormReasons?.length) ? [...rec.reasons, ...rec.stormReasons] : rec.reasons; const handleAssign = () => { setAssigned(true); onAssign(rep); setTimeout(() => setAssigned(false), 2400); }; return ( {/* Top-right: Top Pick badge (rank 0 only) stacked above Score */}
{rank === 0 && ( Top Pick )}

Score

{rec.slotStart}

{/* Rep identity row */}

{rep.name}

{/* Status + distance */}

{REP_STATUS_LABELS[rep.status]}

{rec.distanceMi && ( <> · {rec.distanceMi} mi )}
{/* Rating + close rate */}
{rep.rating} · {rep.closeRate}% close
{/* Arrival window */}
Arrives{' '} {rec.slotStart} {rec.slotEnd && ( <> · Done by{' '} {rec.slotEnd} )}
{/* Score breakdown bars */}
{Object.entries(rec.factors).map(([key, val], i) => ( ))}
{/* AI insight */} {rec.aiInsight && (

{rec.aiInsight}

)} {/* Conflict flag */} {rec.conflictFlag && (

{rec.conflictFlag}

)} {/* Reason chips */}
{reasons.map((r, i) => { const isWeatherChip = r.toLowerCase().includes('lightning') || r.toLowerCase().includes('weather') || r.toLowerCase().includes('storm') || r.toLowerCase().includes('risk'); return ( {isWeatherChip && } {r} ); })}
{/* Action buttons */} {assigned ? ( Dispatched ) : ( Assign )}
); }; // --------------------------------------------------------------------------- // Dispatched confirmation overlay (full-drawer, 2.2s, then drawer resets) // --------------------------------------------------------------------------- const DispatchedConfirmation = ({ info, accent }) => (

{info.repFirstName} Dispatched

{info.address}

{info.slot && (

Slot: {info.slot}

)}
); // --------------------------------------------------------------------------- // Rep picker modal (Override) // --------------------------------------------------------------------------- const RepPickerModal = ({ onClose, onSelect, accent, reps }) => (
{/* Header */}

Override Assignment

Select a rep manually

{/* Rep list */}
{reps.map((rep, i) => { const color = REP_STATUS_COLORS[rep.status] || '#6B7280'; const isFull = rep.todayAppointments >= rep.maxDaily; return ( !isFull && onSelect(rep)} disabled={isFull} className={`w-full flex items-center gap-3 p-2.5 rounded-xl border text-left transition-all duration-150 ${ isFull ? 'opacity-40 cursor-not-allowed border-zinc-100 dark:border-white/[0.04]' : 'border-zinc-200 dark:border-white/[0.07] hover:border-zinc-300 dark:hover:border-white/[0.14] hover:bg-zinc-50 dark:hover:bg-zinc-800/60 cursor-pointer' }`} >

{rep.name}

{REP_STATUS_LABELS[rep.status]}

·

{rep.todayAppointments}/{rep.maxDaily} slots

{rep.performanceScore}

perf.

{!isFull && }
); })}
); // --------------------------------------------------------------------------- // Proactive (ambient-AI) default — shown when no lead is selected so the panel // is useful on load: surfaces the highest-priority unassigned lead and its // best-fit rep with a one-tap dispatch. // --------------------------------------------------------------------------- const URGENCY_RANK = { emergency: 0, high: 1, standard: 2 }; const ProactiveSuggestion = ({ suggestion, stormMode, accent, onDispatch, onReview, onViewDetails }) => { const { lead, rec, rep } = suggestion; const repColor = REP_STATUS_COLORS[rep.status] || '#6B7280'; const scoreCol = scoreColor(rec.score); const topReasons = rec.reasons.slice(0, 3); return ( {/* Ambient-AI banner */}
Ambient AI · Suggested Dispatch

Your highest-priority unassigned lead with a ready best-fit rep. Dispatch in one tap, or review the full slate.

{/* Best-fit rep */}

{rep.name}

{REP_STATUS_LABELS[rep.status]} · {rec.distanceMi} mi · {rep.closeRate}% close

Score

{rec.score}

{/* Top reasons */}
{topReasons.map((r, i) => ( {r} ))}
{/* Actions */}
onDispatch(rep, false, lead)} className="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-xl text-xs font-bold uppercase tracking-wider text-white transition-all duration-200 shadow-sm" style={{ backgroundColor: accent }} > Dispatch {rep.name.split(' ')[0]}
); }; // --------------------------------------------------------------------------- // Main Drawer // --------------------------------------------------------------------------- const DispatchRecommendationDrawer = ({ selectedLead, isProcessing, reps = DISPATCH_REPS, leads = [], stormMode, accent, onAssign, onSelectLead, onDismissAfterAssign, onViewDetails, isDesktop, }) => { const [dispatchedInfo, setDispatchedInfo] = useState(null); const [showRepPicker, setShowRepPicker] = useState(false); const recommendations = selectedLead ? (DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? []) : []; // Ambient-AI default — when nothing is selected, surface the top unassigned // lead (by urgency, then longest-waiting) and its best-fit rep. const suggestion = useMemo(() => { if (selectedLead) return null; const lead = (leads || []) .filter(l => l.status === 'unassigned' && (DISPATCH_RECOMMENDATIONS[l.id]?.length)) .sort((a, b) => (URGENCY_RANK[a.urgency] ?? 9) - (URGENCY_RANK[b.urgency] ?? 9) || (b.arrivedMinsAgo || 0) - (a.arrivedMinsAgo || 0) )[0]; if (!lead) return null; const rec = DISPATCH_RECOMMENDATIONS[lead.id][0]; const rep = reps.find(r => r.id === rec.repId); return rep ? { lead, rec, rep } : null; }, [selectedLead, leads, reps]); // Clear dispatched overlay when selectedLead changes useEffect(() => { setDispatchedInfo(null); }, [selectedLead?.id]); const handleAssign = useCallback((rep, isManual = false, lead = selectedLead) => { if (!lead) return; const recs = DISPATCH_RECOMMENDATIONS[lead.id] ?? []; const repRec = recs.find(r => r.repId === rep.id); onAssign(lead.id, rep.id, isManual); toast.success(`${rep.name.split(' ')[0]} dispatched`, { description: `${lead.property.address} · ${repRec?.slotStart ?? 'TBD'}`, duration: 4000, }); setDispatchedInfo({ repFirstName: rep.name.split(' ')[0], address: `${lead.property.address}, ${lead.property.city}`, slot: repRec?.slotStart ?? null, }); setTimeout(() => { setDispatchedInfo(null); onDismissAfterAssign?.(); }, 2400); }, [selectedLead, onAssign, onDismissAfterAssign]); const handleOverride = () => setShowRepPicker(true); const handlePickerSelect = (rep) => { setShowRepPicker(false); handleAssign(rep, true); // manual override }; // Height: desktop = fixed to match lead list; mobile = min-h const fixedStyle = isDesktop ? { height: 'calc(72rem + 2.375rem)' } : undefined; const mobileClass = !isDesktop ? 'min-h-[240px] sm:min-h-[320px] md:min-h-[380px]' : ''; return ( <>
{/* ── DISPATCHED CONFIRMATION ── */} {dispatchedInfo ? ( /* ── AI PROCESSING ── */ ) : isProcessing ? ( /* ── RESULTS ── */ ) : selectedLead && recommendations.length > 0 ? ( {/* 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 */}
Top AI Recommendations
{/* Rep cards */} {recommendations.map((rec, i) => { const rep = reps.find(r => r.id === rec.repId); if (!rep) return null; return ( ); })} /* ── NO RECS FOR THIS LEAD ── */ ) : selectedLead ? (

No reps available

All reps are at capacity or unavailable for this slot

/* ── PROACTIVE DEFAULT (ambient AI) ── */ ) : suggestion ? ( /* ── EMPTY STATE ── */ ) : (

No lead selected

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

)}
{/* Override modal */} {showRepPicker && ( setShowRepPicker(false)} onSelect={handlePickerSelect} accent={accent} reps={reps} /> )} ); }; export default DispatchRecommendationDrawer;