Files
LynkedUpPro_CRM/src/components/dispatch/DispatchRecommendationDrawer.jsx
T

937 lines
46 KiB
React

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 (
<AnimatePresence mode="wait">
<motion.p
key={idx}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className="text-xs font-semibold"
style={{ color: accent }}
>
{ANALYZING_MSGS[idx]}
</motion.p>
</AnimatePresence>
);
};
const RepCardSkeleton = ({ delay = 0 }) => (
<div className="rounded-xl border border-zinc-100 dark:border-white/[0.05] p-3 space-y-2.5">
{/* Avatar + name + score */}
<div className="flex items-start gap-2.5">
<div className="w-10 h-10 rounded-xl shrink-0 animate-pulse bg-zinc-100 dark:bg-zinc-800/60" style={{ animationDelay: `${delay}ms` }} />
<div className="flex-1 space-y-1.5 pt-0.5">
<div className="h-3 rounded-md w-3/5 animate-pulse bg-zinc-100 dark:bg-zinc-800/60" style={{ animationDelay: `${delay}ms` }} />
<div className="h-2.5 rounded-md w-2/5 animate-pulse bg-zinc-100 dark:bg-zinc-800/60" style={{ animationDelay: `${delay + 60}ms` }} />
<div className="h-2 rounded-md w-1/2 animate-pulse bg-zinc-100 dark:bg-zinc-800/60" style={{ animationDelay: `${delay + 120}ms` }} />
</div>
<div className="w-10 h-10 rounded-lg shrink-0 animate-pulse bg-zinc-100 dark:bg-zinc-800/60" style={{ animationDelay: `${delay + 40}ms` }} />
</div>
{/* Score bars */}
<div className="space-y-1.5">
{[100, 87, 93, 79, 95].map((w, i) => (
<div key={i} className="flex items-center gap-2">
<div className="h-2 w-14 rounded animate-pulse bg-zinc-100 dark:bg-zinc-800/60" style={{ animationDelay: `${delay + i * 40}ms` }} />
<div className="flex-1 h-1.5 rounded-full animate-pulse bg-zinc-100 dark:bg-zinc-800/60" style={{ width: `${w}%`, animationDelay: `${delay + i * 40}ms` }} />
<div className="h-2 w-5 rounded animate-pulse bg-zinc-100 dark:bg-zinc-800/60" style={{ animationDelay: `${delay + i * 40}ms` }} />
</div>
))}
</div>
{/* Chips */}
<div className="flex gap-1.5 flex-wrap">
{[56, 72, 48].map((w, i) => (
<div key={i} className={`h-4 rounded-full animate-pulse bg-zinc-100 dark:bg-zinc-800/60 w-${w === 56 ? '14' : w === 72 ? '20' : '12'}`}
style={{ width: w, animationDelay: `${delay + i * 60}ms` }} />
))}
</div>
{/* Buttons */}
<div className="flex gap-2">
<div className="flex-1 h-7 rounded-xl animate-pulse bg-zinc-100 dark:bg-zinc-800/60" style={{ animationDelay: `${delay}ms` }} />
<div className="w-20 h-7 rounded-xl animate-pulse bg-zinc-100 dark:bg-zinc-800/60" style={{ animationDelay: `${delay + 80}ms` }} />
</div>
</div>
);
const ProcessingSkeleton = ({ accent }) => (
<div className="p-3 space-y-3">
{/* Lead summary shimmer */}
<div className="h-16 rounded-xl animate-pulse bg-zinc-100 dark:bg-zinc-800/50" />
{/* Analyzing indicator */}
<div className="flex items-center justify-center gap-2 py-1">
<div
className="w-3.5 h-3.5 rounded-full border-2 animate-spin"
style={{ borderColor: `${accent}30`, borderTopColor: accent }}
/>
<AnalyzingText accent={accent} />
</div>
{/* Rep card shimmers */}
<RepCardSkeleton delay={0} />
<RepCardSkeleton delay={120} />
<RepCardSkeleton delay={240} />
</div>
);
// ---------------------------------------------------------------------------
// Score breakdown bar
// ---------------------------------------------------------------------------
const ScoreBar = ({ label, value, index }) => {
const color = scoreColor(value);
return (
<div className="flex items-center gap-2">
<span className="text-[10px] text-zinc-400 dark:text-zinc-500 w-[3.75rem] shrink-0 font-medium truncate">{label}</span>
<div className="flex-1 h-1.5 rounded-full bg-zinc-100 dark:bg-zinc-700/50 overflow-hidden">
<motion.div
className="h-full rounded-full"
style={{ backgroundColor: color }}
initial={{ width: 0 }}
animate={{ width: `${value}%` }}
transition={{ duration: 0.55, ease: 'easeOut', delay: 0.1 + index * 0.07 }}
/>
</div>
<span className="text-[10px] font-bold w-5 text-right shrink-0" style={{ color }}>{value}</span>
</div>
);
};
// ---------------------------------------------------------------------------
// 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 (
<div className="flex items-center gap-1.5">
<svg width="22" height="22" viewBox="0 0 22 22" style={{ flexShrink: 0 }}>
{/* Track */}
<circle
cx="11" cy="11" r={r}
fill="none"
stroke="currentColor"
strokeWidth="3"
className="text-zinc-200 dark:text-zinc-700"
/>
{/* Fill */}
<circle
cx="11" cy="11" r={r}
fill="none"
stroke={color}
strokeWidth="3"
strokeDasharray={`${filled} ${circumference - filled}`}
strokeLinecap="round"
transform="rotate(-90 11 11)"
/>
</svg>
<span className="text-[10px] text-zinc-400 font-medium">{used}/{max} slots</span>
</div>
);
};
// ---------------------------------------------------------------------------
// Rep avatar with status dot
// ---------------------------------------------------------------------------
const RepAvatar = ({ initials, status }) => {
const color = REP_STATUS_COLORS[status] || '#6B7280';
return (
<div className="relative shrink-0">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-sm font-black text-white"
style={{ backgroundColor: `${color}CC` }}
>
{initials}
</div>
<span
className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-white dark:border-zinc-900"
style={{ backgroundColor: color }}
/>
</div>
);
};
// ---------------------------------------------------------------------------
// Lead summary header
// ---------------------------------------------------------------------------
const LeadSummary = ({ lead, stormMode, onViewDetails }) => {
const urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard;
return (
<div
className="rounded-xl border p-3 transition-all duration-500 bg-zinc-50 dark:bg-zinc-800/40 border-zinc-200 dark:border-white/[0.07]"
style={stormMode ? { backgroundColor: '#F59E0B0E', borderColor: '#F59E0B30' } : undefined}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-xs font-bold text-zinc-900 dark:text-white truncate">{lead.customer.name}</p>
<p className="text-[11px] text-zinc-400 truncate mt-0.5">
{lead.property.address}, {lead.property.city}
</p>
</div>
<span
className="text-[9px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0"
style={{ backgroundColor: `${urgCfg.color}18`, color: urgCfg.color }}
>
{urgCfg.label}
</span>
</div>
<div className="flex items-center justify-between mt-2 gap-2">
<div className="flex items-center gap-2 flex-wrap min-w-0">
<span className="text-[10px] text-zinc-500 dark:text-zinc-400 font-medium">
{LEAD_TYPE_LABELS[lead.leadType] || lead.leadType}
</span>
<span className="text-zinc-300 dark:text-zinc-700">·</span>
<span className="flex items-center gap-1 text-[10px] text-zinc-400">
<Clock size={9} />
{lead.estimatedDuration} min est.
</span>
</div>
{onViewDetails && (
<button
onClick={() => onViewDetails(lead)}
className="flex items-center gap-1 text-[10px] font-semibold text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors shrink-0"
>
<FileText size={10} />
View Details
</button>
)}
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// 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 (
<motion.div
layout
initial={{ opacity: 0, x: 18 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ type: 'spring', stiffness: 360, damping: 30, delay: rank * 0.08 }}
className={`relative rounded-xl border p-3 transition-colors duration-300 ${
rank === 0
? 'border-emerald-200 dark:border-emerald-500/20 bg-emerald-50/50 dark:bg-emerald-500/5'
: 'border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-800/40'
}`}
>
{/* Top-right: Top Pick badge (rank 0 only) stacked above Score */}
<div className="absolute top-2.5 right-2.5 flex flex-col items-end gap-1.5">
{rank === 0 && (
<span className="flex items-center gap-1 text-[9px] font-black uppercase tracking-widest px-1.5 py-0.5 rounded bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400">
<Star size={7} fill="currentColor" />
Top Pick
</span>
)}
<div className="text-right">
<p className="text-[9px] font-bold uppercase tracking-widest text-zinc-400 mb-0.5">Score</p>
<div className="text-[1.6rem] font-black font-mono leading-none" style={{ color }}>
<AnimatedCounter from={0} to={rec.score} duration={0.85} />
</div>
<p className="text-[9px] text-zinc-400 mt-0.5">{rec.slotStart}</p>
</div>
</div>
{/* Rep identity row */}
<div className="flex items-start gap-2.5 mb-2.5">
<RepAvatar initials={rep.initials} status={rep.status} />
<div className="min-w-0 flex-1 pt-0.5">
<p className="text-sm font-bold text-zinc-900 dark:text-white leading-tight pr-20">{rep.name}</p>
{/* Status + distance */}
<div className="flex items-center gap-1.5 mt-0.5">
<p className="text-[11px]" style={{ color: REP_STATUS_COLORS[rep.status] || '#6B7280' }}>
{REP_STATUS_LABELS[rep.status]}
</p>
{rec.distanceMi && (
<>
<span className="text-zinc-300 dark:text-zinc-600">·</span>
<span className="flex items-center gap-0.5 text-[10px] font-semibold text-zinc-400">
<Navigation size={9} />
{rec.distanceMi} mi
</span>
</>
)}
</div>
{/* Rating + close rate */}
<div className="flex items-center gap-1.5 mt-0.5">
<span className="flex items-center gap-0.5 text-[10px] font-semibold text-amber-500">
<Star size={8} fill="currentColor" />
{rep.rating}
</span>
<span className="text-zinc-300 dark:text-zinc-600">·</span>
<span className="text-[10px] text-zinc-400 font-medium">{rep.closeRate}% close</span>
</div>
<div className="mt-1.5">
<CapacityDonut used={rep.todayAppointments} max={rep.maxDaily} />
</div>
</div>
</div>
{/* Arrival window */}
<div className="flex items-center gap-1.5 mb-2.5 px-2 py-1.5 rounded-lg bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-white/[0.05]">
<Clock size={10} className="text-zinc-400 shrink-0" />
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
Arrives{' '}
<span className="font-semibold text-zinc-700 dark:text-zinc-200">{rec.slotStart}</span>
{rec.slotEnd && (
<> · Done by{' '}
<span className="font-semibold text-zinc-700 dark:text-zinc-200">{rec.slotEnd}</span>
</>
)}
</span>
</div>
{/* Score breakdown bars */}
<div className="space-y-1.5 mb-2.5">
{Object.entries(rec.factors).map(([key, val], i) => (
<ScoreBar key={key} label={FACTOR_LABELS[key] || key} value={val} index={i} />
))}
</div>
{/* AI insight */}
{rec.aiInsight && (
<div
className="flex items-start gap-1.5 mb-2 px-2 py-1.5 rounded-lg"
style={{ backgroundColor: `${accent}12` }}
>
<Sparkles size={10} style={{ color: accent }} className="shrink-0 mt-px" />
<p className="text-[10px] italic leading-snug" style={{ color: accent }}>
{rec.aiInsight}
</p>
</div>
)}
{/* Conflict flag */}
{rec.conflictFlag && (
<div className="flex items-start gap-1.5 mb-2 px-2 py-1.5 rounded-lg bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20">
<AlertTriangle size={10} className="text-amber-500 shrink-0 mt-px" />
<p className="text-[10px] text-amber-700 dark:text-amber-400 leading-snug">
{rec.conflictFlag}
</p>
</div>
)}
{/* Reason chips */}
<div className="flex flex-wrap gap-1 mb-3">
{reasons.map((r, i) => {
const isWeatherChip = r.toLowerCase().includes('lightning') ||
r.toLowerCase().includes('weather') ||
r.toLowerCase().includes('storm') ||
r.toLowerCase().includes('risk');
return (
<motion.span
key={i}
initial={{ opacity: 0, scale: 0.75 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: 'spring', stiffness: 480, damping: 28, delay: rank * 0.08 + i * 0.04 }}
className={`inline-flex items-center gap-1 text-[10px] font-medium px-2 py-0.5 rounded-full ${
isWeatherChip
? 'bg-amber-100 dark:bg-amber-500/15 text-amber-700 dark:text-amber-400'
: 'bg-zinc-100 dark:bg-zinc-700/50 text-zinc-600 dark:text-zinc-300'
}`}
>
{isWeatherChip && <CloudLightning size={8} />}
{r}
</motion.span>
);
})}
</div>
{/* Action buttons */}
<AnimatePresence mode="wait">
{assigned ? (
<motion.div
key="dispatched"
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={{ duration: 0.18 }}
className="flex items-center justify-center gap-2 py-2 rounded-xl bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-400"
>
<CheckCircle size={14} />
<span className="text-xs font-bold uppercase tracking-wide">Dispatched</span>
</motion.div>
) : (
<motion.div
key="actions"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex gap-2"
>
<motion.button
whileHover={{ opacity: 0.9 }}
whileTap={{ scale: 0.97 }}
onClick={handleAssign}
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-xl text-xs font-bold uppercase tracking-wider text-white transition-all duration-200 shadow-sm"
style={{ backgroundColor: accent }}
>
<Zap size={11} />
Assign
</motion.button>
<button
onClick={onOverride}
className="px-3 py-1.5 rounded-xl text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 border border-zinc-200 dark:border-white/[0.1] hover:border-zinc-300 dark:hover:border-white/[0.18] hover:text-zinc-700 dark:hover:text-zinc-200 transition-all duration-200"
>
Override
</button>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
};
// ---------------------------------------------------------------------------
// Dispatched confirmation overlay (full-drawer, 2.2s, then drawer resets)
// ---------------------------------------------------------------------------
const DispatchedConfirmation = ({ info, accent }) => (
<motion.div
key="dispatched-overlay"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.22 }}
className="flex flex-col items-center justify-center gap-4 p-10 text-center h-full min-h-[320px]"
>
<motion.div
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 400, damping: 22, delay: 0.05 }}
className="w-16 h-16 rounded-2xl flex items-center justify-center bg-emerald-100 dark:bg-emerald-500/20"
>
<CheckCircle size={32} className="text-emerald-500" />
</motion.div>
<div>
<p className="text-base font-black uppercase tracking-tight text-zinc-900 dark:text-white">
{info.repFirstName} Dispatched
</p>
<p className="text-xs text-zinc-400 mt-1 max-w-[200px] mx-auto leading-relaxed">
{info.address}
</p>
{info.slot && (
<p className="text-xs font-semibold mt-1.5" style={{ color: accent }}>
Slot: {info.slot}
</p>
)}
</div>
<motion.div
initial={{ width: '100%' }}
animate={{ width: '0%' }}
transition={{ duration: 2.2, ease: 'linear' }}
className="h-0.5 rounded-full self-stretch mx-auto max-w-[120px]"
style={{ backgroundColor: accent, transformOrigin: 'left' }}
/>
</motion.div>
);
// ---------------------------------------------------------------------------
// Rep picker modal (Override)
// ---------------------------------------------------------------------------
const RepPickerModal = ({ onClose, onSelect, accent, reps }) => (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, y: 32 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 32 }}
transition={{ type: 'spring', stiffness: 360, damping: 30 }}
className="w-full max-w-sm rounded-2xl border bg-white dark:bg-zinc-900 border-zinc-200 dark:border-white/[0.08] shadow-2xl overflow-hidden"
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06]"
style={{ borderTopWidth: 2, borderTopColor: accent }}
>
<div>
<h3 className="text-sm font-bold uppercase tracking-widest text-zinc-900 dark:text-white">
Override Assignment
</h3>
<p className="text-[11px] text-zinc-400 mt-0.5">Select a rep manually</p>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-400 transition-colors"
>
<X size={15} />
</button>
</div>
{/* Rep list */}
<div className="p-3 space-y-1.5 max-h-[60vh] overflow-y-auto custom-scrollbar">
{reps.map((rep, i) => {
const color = REP_STATUS_COLORS[rep.status] || '#6B7280';
const isFull = rep.todayAppointments >= rep.maxDaily;
return (
<motion.button
key={rep.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.045 }}
onClick={() => !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'
}`}
>
<RepAvatar initials={rep.initials} status={rep.status} />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-zinc-900 dark:text-white">{rep.name}</p>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-[10px] font-medium" style={{ color }}>{REP_STATUS_LABELS[rep.status]}</p>
<span className="text-zinc-300 dark:text-zinc-700">·</span>
<p className="text-[10px] text-zinc-400">{rep.todayAppointments}/{rep.maxDaily} slots</p>
</div>
</div>
<div className="text-right shrink-0">
<p className="text-xs font-black" style={{ color: scoreColor(rep.performanceScore) }}>
{rep.performanceScore}
</p>
<p className="text-[9px] text-zinc-400 mt-0.5">perf.</p>
</div>
{!isFull && <ChevronRight size={13} className="text-zinc-300 dark:text-zinc-600 shrink-0" />}
</motion.button>
);
})}
</div>
</motion.div>
</div>
);
// ---------------------------------------------------------------------------
// 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 (
<motion.div
key="proactive"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="p-3 space-y-3"
>
{/* Ambient-AI banner */}
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full w-fit" style={{ backgroundColor: `${accent}14`, color: accent }}>
<Sparkles size={11} className="shrink-0" />
<span className="text-[9px] font-black uppercase tracking-widest">Ambient AI · Suggested Dispatch</span>
</div>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 leading-snug -mt-1">
Your highest-priority unassigned lead with a ready best-fit rep. Dispatch in one tap, or review the full slate.
</p>
<LeadSummary lead={lead} stormMode={stormMode} onViewDetails={onViewDetails} />
{/* Best-fit rep */}
<div className="rounded-xl border px-3 py-2.5 space-y-2" style={{ backgroundColor: `${accent}0D`, borderColor: `${accent}30` }}>
<div className="flex items-center gap-2">
<RepAvatar initials={rep.initials} status={rep.status} />
<div className="flex-1 min-w-0">
<p className="text-[12px] font-bold text-zinc-900 dark:text-white leading-tight truncate">{rep.name}</p>
<p className="text-[10px] truncate" style={{ color: repColor }}>
{REP_STATUS_LABELS[rep.status]} · {rec.distanceMi} mi · {rep.closeRate}% close
</p>
</div>
<div className="text-right shrink-0">
<p className="text-[9px] font-bold uppercase tracking-widest text-zinc-400 leading-none mb-0.5">Score</p>
<p className="text-[1.4rem] font-black font-mono leading-none" style={{ color: scoreCol }}>{rec.score}</p>
</div>
</div>
{/* Top reasons */}
<div className="flex flex-wrap gap-1 pt-2 border-t" style={{ borderColor: `${accent}25` }}>
{topReasons.map((r, i) => (
<span key={i} className="inline-flex items-center text-[10px] font-medium px-2 py-0.5 rounded-full bg-white/70 dark:bg-zinc-800/70 text-zinc-600 dark:text-zinc-300">
{r}
</span>
))}
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<motion.button
whileHover={{ opacity: 0.9 }}
whileTap={{ scale: 0.97 }}
onClick={() => 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 }}
>
<Zap size={12} />
Dispatch {rep.name.split(' ')[0]}
</motion.button>
<button
onClick={() => onReview(lead)}
className="px-3 py-2 rounded-xl text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400 border border-zinc-200 dark:border-white/[0.1] hover:border-zinc-300 dark:hover:border-white/[0.18] hover:text-zinc-700 dark:hover:text-zinc-200 transition-all duration-200"
>
Review
</button>
</div>
</motion.div>
);
};
// ---------------------------------------------------------------------------
// 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 (
<>
<div
className={`overflow-y-auto custom-scrollbar ${mobileClass}`}
style={fixedStyle}
>
<AnimatePresence mode="wait">
{/* ── DISPATCHED CONFIRMATION ── */}
{dispatchedInfo ? (
<DispatchedConfirmation key="confirm" info={dispatchedInfo} accent={accent} />
/* ── AI PROCESSING ── */
) : isProcessing ? (
<motion.div key="processing" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<ProcessingSkeleton accent={accent} />
</motion.div>
/* ── RESULTS ── */
) : selectedLead && recommendations.length > 0 ? (
<motion.div
key={`results-${selectedLead.id}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="p-3 space-y-3"
>
<LeadSummary lead={selectedLead} stormMode={stormMode} onViewDetails={onViewDetails} />
{/* 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 (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, delay: 0.05 }}
className="rounded-xl border px-3 py-2.5 space-y-2"
style={{
backgroundColor: `${accent}0D`,
borderColor: `${accent}30`,
}}
>
{/* Header row: label */}
<div className="flex items-center gap-1.5">
<Bot size={11} style={{ color: accent }} className="shrink-0" />
<span className="text-[9px] font-black uppercase tracking-widest" style={{ color: accent }}>
AI Says Top Pick
</span>
</div>
{/* Rep identity row */}
<div className="flex items-center gap-2">
<div
className="w-7 h-7 rounded-lg flex items-center justify-center text-[10px] font-black text-white shrink-0"
style={{ backgroundColor: `${repColor}CC` }}
>
{topRep.initials}
</div>
<div className="flex-1 min-w-0">
<p className="text-[12px] font-bold text-zinc-900 dark:text-white leading-tight truncate">
{topRep.name}
</p>
<p className="text-[10px]" style={{ color: repColor }}>
{REP_STATUS_LABELS[topRep.status]} · {topRec.distanceMi} mi
</p>
</div>
<div className="text-right shrink-0">
<p className="text-[9px] font-bold uppercase tracking-widest text-zinc-400 leading-none mb-0.5">Score</p>
<p className="text-[1.4rem] font-black font-mono leading-none" style={{ color: scoreCol }}>
{topRec.score}
</p>
</div>
</div>
{/* Quote */}
<p className="text-[11px] leading-relaxed text-zinc-600 dark:text-zinc-300 italic border-t pt-2" style={{ borderColor: `${accent}25` }}>
"{topRec.aiInsight}"
</p>
</motion.div>
);
})()}
{/* Section divider */}
<div className="flex items-center gap-2">
<div className="flex-1 h-px bg-zinc-100 dark:bg-white/[0.06]" />
<span className="text-[9px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-600 whitespace-nowrap">
Top AI Recommendations
</span>
<div className="flex-1 h-px bg-zinc-100 dark:bg-white/[0.06]" />
</div>
{/* Rep cards */}
{recommendations.map((rec, i) => {
const rep = reps.find(r => r.id === rec.repId);
if (!rep) return null;
return (
<RepCard
key={rec.repId}
rec={rec}
rep={rep}
rank={i}
stormMode={stormMode}
accent={accent}
onAssign={handleAssign}
onOverride={handleOverride}
/>
);
})}
</motion.div>
/* ── NO RECS FOR THIS LEAD ── */
) : selectedLead ? (
<motion.div
key="no-recs"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center justify-center gap-3 p-10 text-center min-h-[280px]"
>
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: `${accent}15`, color: accent }}
>
<Users size={24} />
</div>
<div>
<p className="text-sm font-semibold text-zinc-600 dark:text-zinc-300">No reps available</p>
<p className="text-xs text-zinc-400 mt-1 max-w-[180px] mx-auto leading-relaxed">
All reps are at capacity or unavailable for this slot
</p>
</div>
<button
onClick={handleOverride}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold uppercase tracking-wider border border-zinc-200 dark:border-white/[0.1] text-zinc-500 dark:text-zinc-400 hover:border-zinc-300 dark:hover:border-white/20 hover:text-zinc-700 dark:hover:text-zinc-200 transition-all duration-200"
>
<Navigation size={11} />
Manual Override
</button>
</motion.div>
/* ── PROACTIVE DEFAULT (ambient AI) ── */
) : suggestion ? (
<ProactiveSuggestion
key="proactive"
suggestion={suggestion}
stormMode={stormMode}
accent={accent}
onDispatch={handleAssign}
onReview={onSelectLead}
onViewDetails={onViewDetails}
/>
/* ── EMPTY STATE ── */
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center justify-center gap-4 p-10 text-center min-h-[280px]"
>
<motion.div
animate={{ scale: [1, 1.06, 1] }}
transition={{ duration: 2.8, repeat: Infinity, ease: 'easeInOut' }}
className="w-14 h-14 rounded-2xl border-2 border-dashed border-zinc-200 dark:border-zinc-700 flex items-center justify-center"
>
<Bot size={24} className="text-zinc-300 dark:text-zinc-600" />
</motion.div>
<div>
<p className="text-sm font-semibold text-zinc-500 dark:text-zinc-400">No lead selected</p>
<p className="text-xs text-zinc-400 mt-1.5 max-w-[180px] mx-auto leading-relaxed">
Click any lead in the queue to see AI-scored rep recommendations
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Override modal */}
<AnimatePresence>
{showRepPicker && (
<RepPickerModal
onClose={() => setShowRepPicker(false)}
onSelect={handlePickerSelect}
accent={accent}
reps={reps}
/>
)}
</AnimatePresence>
</>
);
};
export default DispatchRecommendationDrawer;