feat(dispatch): additional features — quick assign, lead quick view, rep cycling, AI efficiency score, AI Says block
- Quick Assign: accent Zap pill on unassigned lead cards; dispatches top AI rec in one tap; 2.2s confirmation state - LeadQuickViewModal: right-drawer with client contact (copy buttons), property, source sub-details, editable notes with double-gate confirmation - Rep Status Live Cycling: repStatuses state map overrides DISPATCH_REPS.status; effectiveReps prop passed to map and AI drawer; assign sets rep to en_route, reverts to available after 60s - Dispatch Efficiency Score: aiCount/totalCount seeded 8/9 (89%); AI assign increments both, manual override increments total only; shown as badge in header - Efficiency badge label updated to '89% AI-Dispatched' with tooltip explaining the metric - AI Says block in RecommendationDrawer: rep identity row (avatar, name, status/distance, large score) + italic AI insight quote above recommendations - Lead aging badges: amber Aging (20+ min), red pulsing Overdue (40+ min) on unassigned leads - Search bar in Lead Queue: filters by customer name, address, city; combines with active tab filter - mockStore enrichment: email + notes + source sub-details on all 12 leads; rating/closeRate on all 5 reps; distanceMi/aiInsight/conflictFlag on all recommendations; updateLeadNotes() action; DISPATCH_WEEKLY_STATS export - LynkDispatchPage wired for Phase 5 KPIs, storm mode, chart modal, log drawer, and all new features
This commit is contained in:
@@ -3,8 +3,8 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Bot, CheckCircle, X, Star, Clock,
|
||||
Navigation, CloudLightning, Zap, Users, ChevronRight,
|
||||
AlertTriangle, Sparkles, FileText,
|
||||
} from 'lucide-react';
|
||||
import { PieChart, Pie, Cell } from 'recharts';
|
||||
import { toast } from 'sonner';
|
||||
import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
|
||||
import { AnimatedCounter } from '../AnimatedCounter';
|
||||
@@ -154,28 +154,37 @@ const ScoreBar = ({ label, value, index }) => {
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capacity donut
|
||||
// Capacity donut — pure SVG, reliable at small sizes
|
||||
// ---------------------------------------------------------------------------
|
||||
const CapacityDonut = ({ used, max }) => {
|
||||
const pct = used / max;
|
||||
const color = pct >= 0.85 ? '#EF4444' : pct >= 0.6 ? '#F59E0B' : '#10B981';
|
||||
const data = [{ value: used }, { value: Math.max(0, max - used) }];
|
||||
const pct = used / max;
|
||||
const color = pct >= 0.85 ? '#EF4444' : pct >= 0.6 ? '#F59E0B' : '#10B981';
|
||||
const r = 8;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const filled = circumference * Math.min(pct, 1);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
@@ -205,14 +214,13 @@ const RepAvatar = ({ initials, status }) => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lead summary header
|
||||
// ---------------------------------------------------------------------------
|
||||
const LeadSummary = ({ lead, stormMode }) => {
|
||||
const LeadSummary = ({ lead, stormMode, onViewDetails }) => {
|
||||
const urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard;
|
||||
return (
|
||||
<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="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>
|
||||
@@ -227,17 +235,26 @@ const LeadSummary = ({ lead, stormMode }) => {
|
||||
{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 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>
|
||||
);
|
||||
@@ -272,36 +289,70 @@ const RepCard = ({ rec, rep, rank, stormMode, accent, onAssign, onOverride }) =>
|
||||
: 'border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-800/40'
|
||||
}`}
|
||||
>
|
||||
{/* Top-pick badge */}
|
||||
{rank === 0 && (
|
||||
<div className="absolute top-2.5 right-2.5">
|
||||
{/* 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-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>
|
||||
<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>
|
||||
{/* 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>
|
||||
|
||||
{/* 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 */}
|
||||
@@ -311,6 +362,29 @@ const RepCard = ({ rec, rep, rank, stormMode, accent, onAssign, onOverride }) =>
|
||||
))}
|
||||
</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) => {
|
||||
@@ -428,7 +502,7 @@ const DispatchedConfirmation = ({ info, accent }) => (
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rep picker modal (Override)
|
||||
// ---------------------------------------------------------------------------
|
||||
const RepPickerModal = ({ onClose, onSelect, accent }) => (
|
||||
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 }}
|
||||
@@ -458,7 +532,7 @@ const RepPickerModal = ({ onClose, onSelect, accent }) => (
|
||||
|
||||
{/* Rep list */}
|
||||
<div className="p-3 space-y-1.5 max-h-[60vh] overflow-y-auto custom-scrollbar">
|
||||
{DISPATCH_REPS.map((rep, i) => {
|
||||
{reps.map((rep, i) => {
|
||||
const color = REP_STATUS_COLORS[rep.status] || '#6B7280';
|
||||
const isFull = rep.todayAppointments >= rep.maxDaily;
|
||||
|
||||
@@ -506,10 +580,12 @@ const RepPickerModal = ({ onClose, onSelect, accent }) => (
|
||||
const DispatchRecommendationDrawer = ({
|
||||
selectedLead,
|
||||
isProcessing,
|
||||
reps = DISPATCH_REPS,
|
||||
stormMode,
|
||||
accent,
|
||||
onAssign,
|
||||
onDismissAfterAssign,
|
||||
onViewDetails,
|
||||
isDesktop,
|
||||
}) => {
|
||||
const [dispatchedInfo, setDispatchedInfo] = useState(null);
|
||||
@@ -522,13 +598,13 @@ const DispatchRecommendationDrawer = ({
|
||||
setDispatchedInfo(null);
|
||||
}, [selectedLead?.id]);
|
||||
|
||||
const handleAssign = useCallback((rep) => {
|
||||
const handleAssign = useCallback((rep, isManual = false) => {
|
||||
if (!selectedLead) return;
|
||||
|
||||
const recs = DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? [];
|
||||
const repRec = recs.find(r => r.repId === rep.id);
|
||||
|
||||
onAssign(selectedLead.id, rep.id);
|
||||
onAssign(selectedLead.id, rep.id, isManual);
|
||||
|
||||
toast.success(`${rep.name.split(' ')[0]} dispatched`, {
|
||||
description: `${selectedLead.property.address} · ${repRec?.slotStart ?? 'TBD'}`,
|
||||
@@ -551,7 +627,7 @@ const DispatchRecommendationDrawer = ({
|
||||
|
||||
const handlePickerSelect = (rep) => {
|
||||
setShowRepPicker(false);
|
||||
handleAssign(rep);
|
||||
handleAssign(rep, true); // manual override
|
||||
};
|
||||
|
||||
// Height: desktop = fixed to match lead list; mobile = min-h
|
||||
@@ -586,7 +662,65 @@ const DispatchRecommendationDrawer = ({
|
||||
transition={{ duration: 0.18 }}
|
||||
className="p-3 space-y-3"
|
||||
>
|
||||
<LeadSummary lead={selectedLead} stormMode={stormMode} />
|
||||
<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">
|
||||
@@ -599,7 +733,7 @@ const DispatchRecommendationDrawer = ({
|
||||
|
||||
{/* Rep cards */}
|
||||
{recommendations.map((rec, i) => {
|
||||
const rep = DISPATCH_REPS.find(r => r.id === rec.repId);
|
||||
const rep = reps.find(r => r.id === rec.repId);
|
||||
if (!rep) return null;
|
||||
return (
|
||||
<RepCard
|
||||
@@ -680,6 +814,7 @@ const DispatchRecommendationDrawer = ({
|
||||
onClose={() => setShowRepPicker(false)}
|
||||
onSelect={handlePickerSelect}
|
||||
accent={accent}
|
||||
reps={reps}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
Reference in New Issue
Block a user