feat(dispatch): Phase 3 — AI recommendation drawer with scoring and dispatch flow
- Build DispatchRecommendationDrawer with empty/processing/results/dispatched states - Structured shimmer skeletons during AI processing with cycling analyze messages - Rep cards: animated score counter, 5-factor breakdown bars, reason chips, capacity donut - Top Pick badge, storm-aware weather chips (amber styling) - Assign button fires Sonner toast + updates lead status to confirmed in queue - Override button opens rep picker modal with capacity and performance data - Post-assign dispatched confirmation overlay (2.4s) then drawer resets - Mobile: tap lead auto-expands AI panel and smooth-scrolls to it
This commit is contained in:
@@ -0,0 +1,690 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Bot, CheckCircle, X, Star, Clock,
|
||||||
|
Navigation, CloudLightning, Zap, Users, ChevronRight,
|
||||||
|
} 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';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
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) }];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<PieChart width={24} height={24}>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
cx={12} cy={12}
|
||||||
|
innerRadius={6} outerRadius={11}
|
||||||
|
startAngle={90} endAngle={-270}
|
||||||
|
dataKey="value"
|
||||||
|
strokeWidth={0}
|
||||||
|
>
|
||||||
|
<Cell fill={color} />
|
||||||
|
<Cell fill="transparent" />
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
<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 }) => {
|
||||||
|
const urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard;
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border p-3 transition-all duration-500 ${
|
||||||
|
stormMode
|
||||||
|
? 'bg-amber-50/70 dark:bg-amber-500/8 border-amber-200 dark:border-amber-500/20'
|
||||||
|
: 'bg-zinc-50 dark:bg-zinc-800/40 border-zinc-200 dark:border-white/[0.07]'
|
||||||
|
}`}>
|
||||||
|
<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 gap-2.5 mt-2 flex-wrap">
|
||||||
|
<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>
|
||||||
|
<span className="text-zinc-300 dark:text-zinc-700">·</span>
|
||||||
|
<span className="text-[9px] font-mono text-zinc-300 dark:text-zinc-700">{lead.id}</span>
|
||||||
|
</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-pick badge */}
|
||||||
|
{rank === 0 && (
|
||||||
|
<div className="absolute top-2.5 right-2.5">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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-14">{rep.name}</p>
|
||||||
|
<p className="text-[11px] mt-0.5" style={{ color: REP_STATUS_COLORS[rep.status] || '#6B7280' }}>
|
||||||
|
{REP_STATUS_LABELS[rep.status]}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<CapacityDonut used={rep.todayAppointments} max={rep.maxDaily} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* AI Score — top-right */}
|
||||||
|
<div className="text-right shrink-0 absolute top-3 right-3 mt-0.5">
|
||||||
|
<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 text-right">{rec.slotStart}</p>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* 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 }) => (
|
||||||
|
<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">
|
||||||
|
{DISPATCH_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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main Drawer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const DispatchRecommendationDrawer = ({
|
||||||
|
selectedLead,
|
||||||
|
isProcessing,
|
||||||
|
stormMode,
|
||||||
|
accent,
|
||||||
|
onAssign,
|
||||||
|
onDismissAfterAssign,
|
||||||
|
isDesktop,
|
||||||
|
}) => {
|
||||||
|
const [dispatchedInfo, setDispatchedInfo] = useState(null);
|
||||||
|
const [showRepPicker, setShowRepPicker] = useState(false);
|
||||||
|
|
||||||
|
const recommendations = selectedLead ? (DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? []) : [];
|
||||||
|
|
||||||
|
// Clear dispatched overlay when selectedLead changes
|
||||||
|
useEffect(() => {
|
||||||
|
setDispatchedInfo(null);
|
||||||
|
}, [selectedLead?.id]);
|
||||||
|
|
||||||
|
const handleAssign = useCallback((rep) => {
|
||||||
|
if (!selectedLead) return;
|
||||||
|
|
||||||
|
const recs = DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? [];
|
||||||
|
const repRec = recs.find(r => r.repId === rep.id);
|
||||||
|
|
||||||
|
onAssign(selectedLead.id, rep.id);
|
||||||
|
|
||||||
|
toast.success(`${rep.name.split(' ')[0]} dispatched`, {
|
||||||
|
description: `${selectedLead.property.address} · ${repRec?.slotStart ?? 'TBD'}`,
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setDispatchedInfo({
|
||||||
|
repFirstName: rep.name.split(' ')[0],
|
||||||
|
address: `${selectedLead.property.address}, ${selectedLead.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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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} />
|
||||||
|
|
||||||
|
{/* 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 = DISPATCH_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>
|
||||||
|
|
||||||
|
/* ── 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DispatchRecommendationDrawer;
|
||||||
+257
-129
@@ -1,51 +1,68 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Zap, CloudLightning, Radio, Inbox, CheckCircle, Clock, AlertTriangle, Bot } from 'lucide-react';
|
import { Zap, CloudLightning, Radio, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
import { useMockStore, DISPATCH_REPS } from '../data/mockStore';
|
||||||
import { useMockStore, DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../data/mockStore';
|
|
||||||
import DispatchLeadQueue from '../components/dispatch/DispatchLeadQueue';
|
import DispatchLeadQueue from '../components/dispatch/DispatchLeadQueue';
|
||||||
|
import DispatchRecommendationDrawer from '../components/dispatch/DispatchRecommendationDrawer';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Panel — themed container used by all 3 side panels
|
// Panel — themed container
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const Panel = ({ children, className = '' }) => (
|
const Panel = ({ children, className = '', style }) => (
|
||||||
<div className={`flex flex-col rounded-2xl border overflow-hidden bg-white border-zinc-200 shadow-sm dark:bg-zinc-900/60 dark:border-white/[0.06] ${className}`}>
|
<div
|
||||||
|
className={`flex flex-col rounded-2xl border overflow-hidden bg-white border-zinc-200 shadow-sm dark:bg-zinc-900/60 dark:border-white/[0.06] ${className}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const PanelHeader = ({ title, subtitle, accent, right }) => (
|
// PanelHeader — supports optional collapse toggle on mobile/tablet
|
||||||
|
const PanelHeader = ({ title, subtitle, accent, right, collapsible, isCollapsed, onToggle }) => (
|
||||||
<div
|
<div
|
||||||
className="shrink-0 flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06]"
|
className={`shrink-0 flex items-center justify-between px-4 py-2 border-b border-zinc-100 dark:border-white/[0.06] transition-colors duration-150 ${collapsible ? 'cursor-pointer select-none active:bg-zinc-50 dark:active:bg-white/5' : ''}`}
|
||||||
style={{ borderTopColor: accent, borderTopWidth: 2 }}
|
style={{ borderTopColor: accent, borderTopWidth: 2 }}
|
||||||
|
onClick={collapsible ? onToggle : undefined}
|
||||||
>
|
>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h2 className="text-sm font-bold uppercase tracking-widest text-zinc-800 dark:text-white">{title}</h2>
|
<h2 className="text-sm font-bold uppercase tracking-widest text-zinc-800 dark:text-white">{title}</h2>
|
||||||
{subtitle && <p className="text-[11px] text-zinc-400 mt-0.5">{subtitle}</p>}
|
{subtitle && <p className="text-[11px] text-zinc-400 mt-0.5">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
{right && <div className="shrink-0">{right}</div>}
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{right && (
|
||||||
|
<div onClick={e => collapsible && e.stopPropagation()}>
|
||||||
|
{right}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{collapsible && (
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`text-zinc-400 transition-transform duration-200 ${isCollapsed ? '-rotate-90' : ''}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// KPI Card — bottom bar metric card
|
// KPI Card
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const KpiCard = ({ label, value, icon: Icon, accent, stormMode, pulse }) => (
|
const KpiCard = ({ label, value, icon: Icon, accent, stormMode, pulse }) => (
|
||||||
<div className={`flex items-center gap-3 rounded-xl px-4 py-3 border transition-all duration-500
|
<div className={`flex items-center gap-2.5 rounded-xl px-3 py-2 border transition-all duration-500
|
||||||
${stormMode && pulse
|
${stormMode && pulse
|
||||||
? 'bg-amber-50 border-amber-300 dark:bg-amber-500/10 dark:border-amber-500/40 shadow-amber-200/50 dark:shadow-amber-500/10 shadow-md'
|
? 'bg-amber-50 border-amber-300 dark:bg-amber-500/10 dark:border-amber-500/40 shadow-amber-200/50 dark:shadow-amber-500/10 shadow-md'
|
||||||
: 'bg-white border-zinc-200 shadow-sm dark:bg-zinc-900/60 dark:border-white/[0.06]'
|
: 'bg-white border-zinc-200 shadow-sm dark:bg-zinc-900/60 dark:border-white/[0.06]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
|
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
|
||||||
style={{ backgroundColor: `${accent}18`, color: accent }}
|
style={{ backgroundColor: `${accent}18`, color: accent }}
|
||||||
>
|
>
|
||||||
<Icon size={18} />
|
<Icon size={16} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400 truncate">{label}</p>
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-zinc-400 truncate">{label}</p>
|
||||||
<p className="text-xl font-black font-mono leading-tight text-zinc-900 dark:text-white">{value}</p>
|
<p className="text-lg font-black font-mono leading-tight text-zinc-900 dark:text-white">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
{stormMode && pulse && (
|
{stormMode && pulse && (
|
||||||
<span className="ml-auto w-2 h-2 rounded-full bg-amber-500 animate-pulse shrink-0" />
|
<span className="ml-auto w-2 h-2 rounded-full bg-amber-500 animate-pulse shrink-0" />
|
||||||
@@ -53,29 +70,122 @@ const KpiCard = ({ label, value, icon: Icon, accent, stormMode, pulse }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ResizableHandle — desktop only (xl+)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const ResizableHandle = ({ onDrag }) => {
|
||||||
|
const dragging = useRef(false);
|
||||||
|
const lastX = useRef(0);
|
||||||
|
|
||||||
|
const onMouseDown = useCallback((e) => {
|
||||||
|
dragging.current = true;
|
||||||
|
lastX.current = e.clientX;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const onMove = (ev) => {
|
||||||
|
if (!dragging.current) return;
|
||||||
|
const delta = ev.clientX - lastX.current;
|
||||||
|
lastX.current = ev.clientX;
|
||||||
|
onDrag(delta);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
dragging.current = false;
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}, [onDrag]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
className="hidden xl:flex shrink-0 w-1.5 cursor-col-resize items-center justify-center group self-stretch"
|
||||||
|
>
|
||||||
|
<div className="w-0.5 h-8 rounded-full bg-zinc-200 dark:bg-white/10 group-hover:bg-blue-400 dark:group-hover:bg-blue-500/60 transition-colors duration-150" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Animated collapse wrapper
|
||||||
|
const CollapsePanel = ({ open, children }) => (
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
key="content"
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.22, ease: 'easeInOut' }}
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main Page
|
// Main Page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const LynkDispatchPage = () => {
|
const LynkDispatchPage = () => {
|
||||||
const { theme } = useTheme();
|
const { dispatchLeads, assignDispatchLead } = useMockStore();
|
||||||
const { dispatchLeads } = useMockStore();
|
|
||||||
|
|
||||||
const [selectedLead, setSelectedLead] = useState(null);
|
const [selectedLead, setSelectedLead] = useState(null);
|
||||||
const [stormMode, setStormMode] = useState(false);
|
const [stormMode, setStormMode] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [leftWidth, setLeftWidth] = useState(300);
|
||||||
|
const [rightWidth, setRightWidth] = useState(320);
|
||||||
|
|
||||||
|
// xl = 1280px — treats all iPads (inc. Air 820/1180 & Pro 11" 834/1194) as tablet
|
||||||
|
const [isDesktop, setIsDesktop] = useState(() => window.innerWidth >= 1280);
|
||||||
|
const aiPanelRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setIsDesktop(window.innerWidth >= 1280);
|
||||||
|
window.addEventListener('resize', handler);
|
||||||
|
return () => window.removeEventListener('resize', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Collapse state — map & AI default-collapsed on small phones only
|
||||||
|
const [collapsed, setCollapsed] = useState(() => ({
|
||||||
|
queue: false,
|
||||||
|
map: window.innerWidth < 640,
|
||||||
|
ai: window.innerWidth < 640,
|
||||||
|
}));
|
||||||
|
const toggle = (key) => setCollapsed(p => ({ ...p, [key]: !p[key] }));
|
||||||
|
|
||||||
|
const dragLeft = useCallback((delta) => setLeftWidth(w => Math.min(440, Math.max(220, w + delta))), []);
|
||||||
|
const dragRight = useCallback((delta) => setRightWidth(w => Math.min(440, Math.max(260, w - delta))), []);
|
||||||
|
|
||||||
const handleSelectLead = (lead) => {
|
const handleSelectLead = (lead) => {
|
||||||
if (selectedLead?.id === lead.id) return;
|
if (selectedLead?.id === lead.id) return;
|
||||||
setSelectedLead(lead);
|
setSelectedLead(lead);
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
setTimeout(() => setIsProcessing(false), 1200);
|
setTimeout(() => setIsProcessing(false), 1200);
|
||||||
|
|
||||||
|
// Mobile/tablet: expand AI panel and scroll to it
|
||||||
|
if (!isDesktop) {
|
||||||
|
setCollapsed(p => ({ ...p, ai: false }));
|
||||||
|
setTimeout(() => {
|
||||||
|
aiPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}, 120);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const accent = stormMode ? '#F59E0B' : '#3B82F6';
|
const handleAssign = useCallback((leadId, repId) => {
|
||||||
const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
|
assignDispatchLead(leadId, repId);
|
||||||
|
}, [assignDispatchLead]);
|
||||||
|
|
||||||
|
const handleDismissAfterAssign = useCallback(() => {
|
||||||
|
setSelectedLead(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const accent = stormMode ? '#F59E0B' : '#3B82F6';
|
||||||
|
const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col h-full overflow-hidden bg-zinc-50 dark:bg-[#09090b] ${stormMode ? 'storm-mode' : ''}`}>
|
<div className={`flex flex-col bg-zinc-50 dark:bg-[#09090b] ${stormMode ? 'storm-mode' : ''}`}>
|
||||||
|
|
||||||
{/* Storm Banner */}
|
{/* Storm Banner */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -97,9 +207,8 @@ const LynkDispatchPage = () => {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
{/* ── Header ── */}
|
||||||
<header className="shrink-0 flex items-center justify-between gap-4 px-4 pt-4 pb-3">
|
<header className="shrink-0 flex items-center justify-between gap-4 px-4 pt-2.5 pb-2">
|
||||||
{/* Branding */}
|
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div
|
<div
|
||||||
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0 transition-colors duration-500"
|
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0 transition-colors duration-500"
|
||||||
@@ -115,7 +224,6 @@ const LynkDispatchPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right controls */}
|
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
{/* LIVE badge */}
|
{/* LIVE badge */}
|
||||||
<div className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20">
|
<div className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20">
|
||||||
@@ -139,17 +247,23 @@ const LynkDispatchPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── 3-Panel Grid ───────────────────────────────────────────────── */}
|
{/* ── 3-Panel Layout ──────────────────────────────────────────────── */}
|
||||||
{/* Desktop: [320px fixed] [flex-1 map] [340px fixed] */}
|
{/* xl+ (1280px): flex row with drag handles (all iPads = tablet) */}
|
||||||
{/* Mobile: single column, scrollable */}
|
{/* < xl: single column, collapsible panels */}
|
||||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-[320px_1fr_340px] gap-3 px-4 pb-3 overflow-hidden lg:overflow-hidden">
|
<div className="flex flex-col xl:flex-row gap-2 xl:gap-0 px-4 pb-2">
|
||||||
|
|
||||||
{/* LEFT — Lead Queue */}
|
{/* LEFT — Lead Queue */}
|
||||||
<Panel className="lg:overflow-hidden">
|
<Panel
|
||||||
|
className="w-full xl:overflow-hidden xl:shrink-0"
|
||||||
|
style={isDesktop ? { width: leftWidth } : undefined}
|
||||||
|
>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
title="Lead Queue"
|
title="Lead Queue"
|
||||||
subtitle="Live · auto-updates every 20s"
|
subtitle="Live · auto-updates every 20s"
|
||||||
accent={accent}
|
accent={accent}
|
||||||
|
collapsible={!isDesktop}
|
||||||
|
isCollapsed={collapsed.queue}
|
||||||
|
onToggle={() => toggle('queue')}
|
||||||
right={
|
right={
|
||||||
<span
|
<span
|
||||||
className="text-xs font-bold px-2 py-0.5 rounded-full"
|
className="text-xs font-bold px-2 py-0.5 rounded-full"
|
||||||
@@ -159,20 +273,36 @@ const LynkDispatchPage = () => {
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<DispatchLeadQueue
|
{isDesktop ? (
|
||||||
selectedLead={selectedLead}
|
<DispatchLeadQueue
|
||||||
onSelectLead={handleSelectLead}
|
selectedLead={selectedLead}
|
||||||
stormMode={stormMode}
|
onSelectLead={handleSelectLead}
|
||||||
accent={accent}
|
stormMode={stormMode}
|
||||||
/>
|
accent={accent}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CollapsePanel open={!collapsed.queue}>
|
||||||
|
<DispatchLeadQueue
|
||||||
|
selectedLead={selectedLead}
|
||||||
|
onSelectLead={handleSelectLead}
|
||||||
|
stormMode={stormMode}
|
||||||
|
accent={accent}
|
||||||
|
/>
|
||||||
|
</CollapsePanel>
|
||||||
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* CENTER — Map */}
|
<ResizableHandle onDrag={dragLeft} />
|
||||||
<Panel className="lg:overflow-hidden min-h-[280px]">
|
|
||||||
|
{/* CENTER — Territory Map */}
|
||||||
|
<Panel className="w-full xl:flex-1 xl:overflow-hidden">
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
title="Territory Map"
|
title="Territory Map"
|
||||||
subtitle="Plano, TX — Rep routes & lead pins"
|
subtitle="Plano, TX — Rep routes & lead pins"
|
||||||
accent={accent}
|
accent={accent}
|
||||||
|
collapsible={!isDesktop}
|
||||||
|
isCollapsed={collapsed.map}
|
||||||
|
onToggle={() => toggle('map')}
|
||||||
right={
|
right={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-2 h-2 rounded-full bg-blue-500" />
|
<span className="w-2 h-2 rounded-full bg-blue-500" />
|
||||||
@@ -181,39 +311,43 @@ const LynkDispatchPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Placeholder — Phase 4 builds Leaflet map here */}
|
{/* Placeholder — Phase 4 builds Leaflet map here */}
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 bg-zinc-50 dark:bg-zinc-950/40 text-center">
|
{isDesktop ? (
|
||||||
<div className="w-16 h-16 rounded-2xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 flex items-center justify-center">
|
<div
|
||||||
<Radio size={28} className="text-blue-400" />
|
className="flex flex-col items-center justify-center gap-3 p-6 bg-zinc-50 dark:bg-zinc-950/40 text-center"
|
||||||
|
style={{ height: 'calc(72rem + 2.375rem)' }}
|
||||||
|
>
|
||||||
|
<MapPlaceholderContent />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
) : (
|
||||||
<p className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">Interactive Map</p>
|
<CollapsePanel open={!collapsed.map}>
|
||||||
<p className="text-xs text-zinc-400 mt-1">Leaflet map with rep markers & route polylines — Phase 4</p>
|
<div className="flex flex-col items-center justify-center gap-3 p-6 bg-zinc-50 dark:bg-zinc-950/40 text-center min-h-[280px] sm:min-h-[360px] md:min-h-[420px]">
|
||||||
</div>
|
<MapPlaceholderContent />
|
||||||
{/* Rep status pills */}
|
</div>
|
||||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
</CollapsePanel>
|
||||||
{[
|
)}
|
||||||
{ 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 => (
|
|
||||||
<span
|
|
||||||
key={s.label}
|
|
||||||
className="text-[11px] font-semibold px-2.5 py-1 rounded-full"
|
|
||||||
style={{ backgroundColor: `${s.color}15`, color: s.color }}
|
|
||||||
>
|
|
||||||
{s.count} {s.label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* RIGHT — AI Recommendation Drawer */}
|
<ResizableHandle onDrag={dragRight} />
|
||||||
<Panel className="lg:overflow-hidden">
|
|
||||||
|
{/* RIGHT — AI Recommendations */}
|
||||||
|
<div ref={aiPanelRef} className="w-full xl:contents">
|
||||||
|
<Panel
|
||||||
|
className="w-full xl:overflow-hidden xl:shrink-0"
|
||||||
|
style={isDesktop ? { width: rightWidth } : undefined}
|
||||||
|
>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
title="AI Recommendations"
|
title="AI Recommendations"
|
||||||
subtitle={selectedLead ? `Analyzing ${selectedLead.id}` : 'Select a lead to begin'}
|
subtitle={
|
||||||
|
isProcessing
|
||||||
|
? 'AI scoring reps...'
|
||||||
|
: selectedLead
|
||||||
|
? selectedLead.customer.name
|
||||||
|
: 'Select a lead to begin'
|
||||||
|
}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
|
collapsible={!isDesktop}
|
||||||
|
isCollapsed={collapsed.ai}
|
||||||
|
onToggle={() => toggle('ai')}
|
||||||
right={
|
right={
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 rounded-full flex items-center justify-center"
|
className="w-6 h-6 rounded-full flex items-center justify-center"
|
||||||
@@ -223,72 +357,35 @@ const LynkDispatchPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Placeholder — Phase 3 replaces this with DispatchRecommendationDrawer */}
|
{isDesktop ? (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
<DispatchRecommendationDrawer
|
||||||
<AnimatePresence mode="wait">
|
selectedLead={selectedLead}
|
||||||
{isProcessing ? (
|
isProcessing={isProcessing}
|
||||||
<motion.div
|
stormMode={stormMode}
|
||||||
key="processing"
|
accent={accent}
|
||||||
initial={{ opacity: 0 }}
|
onAssign={handleAssign}
|
||||||
animate={{ opacity: 1 }}
|
onDismissAfterAssign={handleDismissAfterAssign}
|
||||||
exit={{ opacity: 0 }}
|
isDesktop={isDesktop}
|
||||||
className="flex flex-col items-center gap-3"
|
/>
|
||||||
>
|
) : (
|
||||||
<div className="w-14 h-14 rounded-2xl border-2 border-dashed flex items-center justify-center animate-pulse"
|
<CollapsePanel open={!collapsed.ai}>
|
||||||
style={{ borderColor: `${accent}60`, backgroundColor: `${accent}10` }}
|
<DispatchRecommendationDrawer
|
||||||
>
|
selectedLead={selectedLead}
|
||||||
<Bot size={24} style={{ color: accent }} />
|
isProcessing={isProcessing}
|
||||||
</div>
|
stormMode={stormMode}
|
||||||
<p className="text-sm font-semibold" style={{ color: accent }}>Analyzing lead...</p>
|
accent={accent}
|
||||||
<p className="text-xs text-zinc-400">AI scoring reps</p>
|
onAssign={handleAssign}
|
||||||
</motion.div>
|
onDismissAfterAssign={handleDismissAfterAssign}
|
||||||
) : selectedLead ? (
|
isDesktop={isDesktop}
|
||||||
<motion.div
|
/>
|
||||||
key="selected"
|
</CollapsePanel>
|
||||||
initial={{ opacity: 0 }}
|
)}
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="flex flex-col items-center gap-2"
|
|
||||||
>
|
|
||||||
<div className="w-14 h-14 rounded-2xl flex items-center justify-center"
|
|
||||||
style={{ backgroundColor: `${accent}18`, color: accent }}
|
|
||||||
>
|
|
||||||
<Bot size={24} />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">{selectedLead.id}</p>
|
|
||||||
<p className="text-xs text-zinc-400">{selectedLead.customer.name} · {selectedLead.property.address}</p>
|
|
||||||
<p className="text-[10px] text-zinc-300 dark:text-zinc-700 mt-2 font-medium uppercase tracking-widest">
|
|
||||||
Recommendation engine — Phase 3
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
|
||||||
) : (
|
|
||||||
<motion.div
|
|
||||||
key="empty"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="flex flex-col items-center gap-3"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
animate={{ scale: [1, 1.05, 1] }}
|
|
||||||
transition={{ duration: 2.5, 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">Click a lead in the queue to see AI-scored rep recommendations</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── KPI Bar ────────────────────────────────────────────────────── */}
|
{/* ── KPI Bar ── */}
|
||||||
<div className="shrink-0 grid grid-cols-2 lg:grid-cols-4 gap-3 px-4 pb-4">
|
<div className="shrink-0 grid grid-cols-2 xl:grid-cols-4 gap-2 px-4 py-3">
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Leads in Queue"
|
label="Leads in Queue"
|
||||||
value={unassigned}
|
value={unassigned}
|
||||||
@@ -326,4 +423,35 @@ const LynkDispatchPage = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Extracted placeholder content (avoids duplication between desktop/mobile)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const MapPlaceholderContent = () => (
|
||||||
|
<>
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 flex items-center justify-center">
|
||||||
|
<Radio size={28} className="text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">Interactive Map</p>
|
||||||
|
<p className="text-xs text-zinc-400 mt-1">Leaflet map with rep markers & route polylines — Phase 4</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<span
|
||||||
|
key={s.label}
|
||||||
|
className="text-[11px] font-semibold px-2.5 py-1 rounded-full"
|
||||||
|
style={{ backgroundColor: `${s.color}15`, color: s.color }}
|
||||||
|
>
|
||||||
|
{s.count} {s.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
export default LynkDispatchPage;
|
export default LynkDispatchPage;
|
||||||
|
|||||||
Reference in New Issue
Block a user