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:
Satyam
2026-03-26 14:14:42 +05:30
parent e4c75b7cfd
commit b804f7cff1
5 changed files with 1309 additions and 158 deletions
@@ -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>