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 (
);
};
// ---------------------------------------------------------------------------
// 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 (
{used}/{max} slots
);
};
// ---------------------------------------------------------------------------
// Rep avatar with status dot
// ---------------------------------------------------------------------------
const RepAvatar = ({ initials, status }) => {
const color = REP_STATUS_COLORS[status] || '#6B7280';
return (
);
};
// ---------------------------------------------------------------------------
// 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
)}
{/* 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 && (
)}
{/* Conflict flag */}
{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
{/* 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
{/* Quote */}
"{topRec.aiInsight}"
);
})()}
{/* Section divider */}
{/* 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;