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
+119 -8
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Globe, MapPin, Users, Phone, Clock, CloudLightning } from 'lucide-react';
import { useMockStore, DISPATCH_REPS } from '../../data/mockStore';
import { Globe, MapPin, Users, Phone, Clock, CloudLightning, FileText, Search, X, Zap, CheckCircle } from 'lucide-react';
import { useMockStore, DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
// ---------------------------------------------------------------------------
// Config maps
@@ -47,6 +47,14 @@ const TAB_STATUS_FILTER = {
confirmed: ['assigned', 'confirmed'],
};
// Aging state — only shown on unassigned leads
const getAgingState = (arrivedMinsAgo, status) => {
if (status !== 'unassigned') return null;
if (arrivedMinsAgo >= 40) return { label: 'Overdue', color: '#EF4444', pulse: true };
if (arrivedMinsAgo >= 20) return { label: 'Aging', color: '#F59E0B', pulse: false };
return null;
};
// Fake leads that "drop in" every 20s during demo — cycled through pool
const DROP_POOL = [
{
@@ -72,14 +80,26 @@ const DROP_POOL = [
// ---------------------------------------------------------------------------
// LeadCard
// ---------------------------------------------------------------------------
const LeadCard = ({ lead, isSelected, isNew, onSelect, stormMode, accent, index }) => {
const LeadCard = ({ lead, isSelected, isNew, onSelect, onViewDetails, onQuickAssign, stormMode, accent, index }) => {
const urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard;
const statusCfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.unassigned;
const srcCfg = SOURCE_CONFIG[lead.source] || SOURCE_CONFIG.call_center;
const SrcIcon = srcCfg.icon;
const assignedRep = lead.assignedRepId ? DISPATCH_REPS.find(r => r.id === lead.assignedRepId) : null;
const timeLabel = lead.arrivedMinsAgo === 0 ? 'Just now' : `${lead.arrivedMinsAgo} min ago`;
const timeLabel = lead.arrivedMinsAgo === 0 ? 'Just now' : `${lead.arrivedMinsAgo} min ago`;
const borderColor = isSelected ? accent : urgCfg.color;
const aging = getAgingState(lead.arrivedMinsAgo, lead.status);
const [quickAssigned, setQuickAssigned] = useState(false);
const topRec = DISPATCH_RECOMMENDATIONS[lead.id]?.[0] ?? null;
const handleQuickAssign = (e) => {
e.stopPropagation();
if (!onQuickAssign || !topRec || quickAssigned) return;
onQuickAssign(lead.id, topRec.repId, false);
setQuickAssigned(true);
setTimeout(() => setQuickAssigned(false), 2200);
};
return (
<motion.div
@@ -128,6 +148,16 @@ const LeadCard = ({ lead, isSelected, isNew, onSelect, stormMode, accent, index
PRIORITY
</motion.span>
)}
{aging && (
<motion.span
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className={`text-[10px] font-bold px-1.5 py-0.5 rounded shrink-0 ${aging.pulse ? 'animate-pulse' : ''}`}
style={{ backgroundColor: `${aging.color}18`, color: aging.color }}
>
{aging.label}
</motion.span>
)}
</AnimatePresence>
</div>
<div className="flex items-center gap-1 shrink-0 text-zinc-400 dark:text-zinc-500">
@@ -177,14 +207,60 @@ const LeadCard = ({ lead, isSelected, isNew, onSelect, stormMode, accent, index
{statusCfg.label}
</span>
)}
<span className="flex items-center gap-1 text-[10px] text-zinc-400 shrink-0">
<span
className="flex items-center gap-1 text-[10px] shrink-0 font-medium"
style={{ color: aging ? aging.color : '#9CA3AF' }}
>
<Clock size={10} />
{timeLabel}
</span>
</div>
{/* Lead ID */}
<p className="text-[9px] text-zinc-200 dark:text-zinc-800 mt-1.5 font-mono">{lead.id}</p>
{/* Footer: lead ID + actions */}
<div className="flex items-center justify-between mt-1.5 gap-2">
<p className="text-[9px] text-zinc-200 dark:text-zinc-800 font-mono shrink-0">{lead.id}</p>
<div className="flex items-center gap-2 min-w-0">
{/* Quick Assign — unassigned leads with a top rec only */}
{onQuickAssign && lead.status === 'unassigned' && topRec && (
<AnimatePresence mode="wait">
{quickAssigned ? (
<motion.span
key="qa-done"
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
className="flex items-center gap-1 text-[10px] font-bold text-emerald-500 shrink-0"
>
<CheckCircle size={10} />
Dispatched
</motion.span>
) : (
<motion.button
key="qa-btn"
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.85 }}
onClick={handleQuickAssign}
className="flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded-full shrink-0 transition-opacity hover:opacity-80"
style={{ backgroundColor: `${accent}18`, color: accent }}
>
<Zap size={9} />
Quick Assign
</motion.button>
)}
</AnimatePresence>
)}
{onViewDetails && (
<button
onClick={(e) => { e.stopPropagation(); 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>
</motion.div>
);
};
@@ -192,11 +268,12 @@ const LeadCard = ({ lead, isSelected, isNew, onSelect, stormMode, accent, index
// ---------------------------------------------------------------------------
// DispatchLeadQueue
// ---------------------------------------------------------------------------
const DispatchLeadQueue = ({ selectedLead, onSelectLead, stormMode, accent }) => {
const DispatchLeadQueue = ({ selectedLead, onSelectLead, onViewDetails, onQuickAssign, stormMode, accent }) => {
const { dispatchLeads } = useMockStore();
const [activeTab, setActiveTab] = useState('all');
const [droppedLeads, setDropped] = useState([]);
const [newLeadIds, setNewLeadIds] = useState(new Set());
const [search, setSearch] = useState('');
const dropIndexRef = useRef(0);
// 20-second interval: drop each lead in the pool once (max DROP_POOL.length drops)
@@ -243,6 +320,16 @@ const DispatchLeadQueue = ({ selectedLead, onSelectLead, stormMode, accent }) =>
? allLeads.filter(l => statusFilter.includes(l.status))
: allLeads;
// Filter by search text
if (search.trim()) {
const q = search.toLowerCase();
filteredLeads = filteredLeads.filter(l =>
l.customer.name.toLowerCase().includes(q) ||
l.property.address.toLowerCase().includes(q) ||
l.property.city.toLowerCase().includes(q)
);
}
// Storm mode: float emergency leads to top
if (stormMode) {
filteredLeads = [...filteredLeads].sort((a, b) => {
@@ -255,6 +342,28 @@ const DispatchLeadQueue = ({ selectedLead, onSelectLead, stormMode, accent }) =>
return (
<div className="flex flex-col">
{/* Search bar */}
<div className="shrink-0 px-2 pt-2 pb-1">
<div className="relative">
<Search size={11} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search by name or address…"
className="w-full pl-7 pr-7 py-1.5 text-xs rounded-lg border border-zinc-200 dark:border-white/[0.08] bg-zinc-50 dark:bg-zinc-800/60 text-zinc-700 dark:text-zinc-200 placeholder-zinc-300 dark:placeholder-zinc-600 outline-none focus:border-zinc-400 dark:focus:border-white/20 transition-colors"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-300 hover:text-zinc-500 dark:hover:text-zinc-300 transition-colors"
>
<X size={11} />
</button>
)}
</div>
</div>
{/* Tab bar */}
<div className="shrink-0 flex items-center gap-1 px-2 py-1.5 border-b border-zinc-100 dark:border-white/[0.06] bg-zinc-50/50 dark:bg-zinc-900/30 overflow-x-auto scrollbar-none">
{TABS.map(tab => {
@@ -306,6 +415,8 @@ const DispatchLeadQueue = ({ selectedLead, onSelectLead, stormMode, accent }) =>
isSelected={selectedLead?.id === lead.id}
isNew={newLeadIds.has(lead.id)}
onSelect={onSelectLead}
onViewDetails={onViewDetails}
onQuickAssign={onQuickAssign}
stormMode={stormMode}
accent={accent}
index={index}
@@ -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>
@@ -0,0 +1,548 @@
import React, { useState, useEffect, useCallback } from 'react';
import ReactDOM from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
X, Phone, Mail, MapPin, Copy, Check,
Pencil, AlertTriangle, Globe, PhoneCall,
UserCheck, Users, Clock, FileText,
} from 'lucide-react';
import { toast } from 'sonner';
import { useMockStore } from '../../data/mockStore';
// ---------------------------------------------------------------------------
// Config maps
// ---------------------------------------------------------------------------
const URGENCY_CONFIG = {
emergency: { label: 'EMERGENCY', color: '#EF4444' },
high: { label: 'HIGH', color: '#F59E0B' },
standard: { label: 'STANDARD', color: '#6B7280' },
};
const STATUS_CONFIG = {
unassigned: { label: 'Unassigned', color: '#EF4444' },
at_risk: { label: 'At Risk', color: '#F59E0B' },
assigned: { label: 'Assigned', color: '#3B82F6' },
confirmed: { label: 'Confirmed', color: '#10B981' },
};
const SOURCE_CONFIG = {
call_center: { label: 'Call Center', color: '#3B82F6' },
canvassing: { label: 'Canvassing', color: '#10B981' },
referral: { label: 'Referral', color: '#8B5CF6' },
website_form: { label: 'Web Form', color: '#F59E0B' },
};
const LEAD_TYPE_LABELS = {
emergency_tarp: 'Emergency Tarp',
roof_inspection: 'Roof Inspection',
insurance_claim: 'Insurance Claim',
retail_estimate: 'Retail Estimate',
};
// ---------------------------------------------------------------------------
// Micro helpers
// ---------------------------------------------------------------------------
const SectionLabel = ({ children }) => (
<p className="text-[9px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500 mb-2.5">
{children}
</p>
);
const Divider = () => (
<div className="border-t border-zinc-100 dark:border-white/[0.05] my-4" />
);
// Copy-to-clipboard button — shows checkmark for 1.8s
const CopyButton = ({ text }) => {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback((e) => {
e.stopPropagation();
navigator.clipboard.writeText(text).catch(() => {});
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}, [text]);
return (
<button
onClick={handleCopy}
title="Copy"
className="ml-1 p-0.5 rounded text-zinc-300 dark:text-zinc-600 hover:text-zinc-500 dark:hover:text-zinc-300 transition-colors shrink-0"
>
<AnimatePresence mode="wait">
{copied ? (
<motion.span
key="check"
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.5, opacity: 0 }}
transition={{ duration: 0.15 }}
>
<Check size={11} className="text-emerald-500" />
</motion.span>
) : (
<motion.span
key="copy"
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.5, opacity: 0 }}
transition={{ duration: 0.15 }}
>
<Copy size={11} />
</motion.span>
)}
</AnimatePresence>
</button>
);
};
// ---------------------------------------------------------------------------
// Source sub-detail — conditionally renders based on lead.source
// All colors via inline styles to ensure correct dark/light rendering
// ---------------------------------------------------------------------------
const SourceDetail = ({ lead }) => {
if (lead.source === 'call_center' && lead.callCenter) {
const c = '#3B82F6';
return (
<div
className="flex items-center gap-2.5 mt-2.5 p-2.5 rounded-xl border"
style={{ backgroundColor: `${c}12`, borderColor: `${c}28` }}
>
<div className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0" style={{ backgroundColor: `${c}22` }}>
<PhoneCall size={12} style={{ color: c }} />
</div>
<div className="min-w-0">
<p className="text-[9px] font-bold uppercase tracking-wider" style={{ color: c }}>Center</p>
<p className="text-xs font-semibold text-zinc-800 dark:text-zinc-100">{lead.callCenter.name}</p>
<p className="text-[9px] font-mono text-zinc-400 dark:text-zinc-600 mt-0.5">{lead.callCenter.id}</p>
</div>
</div>
);
}
if (lead.source === 'canvassing' && lead.canvassedBy) {
const c = '#10B981';
return (
<div
className="flex items-center gap-2.5 mt-2.5 p-2.5 rounded-xl border"
style={{ backgroundColor: `${c}12`, borderColor: `${c}28` }}
>
<div className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0" style={{ backgroundColor: `${c}22` }}>
<UserCheck size={12} style={{ color: c }} />
</div>
<div className="min-w-0">
<p className="text-[9px] font-bold uppercase tracking-wider" style={{ color: c }}>Canvassed By</p>
<p className="text-xs font-semibold text-zinc-800 dark:text-zinc-100">{lead.canvassedBy.name}</p>
<p className="text-[9px] font-mono text-zinc-400 dark:text-zinc-600 mt-0.5">{lead.canvassedBy.agentId}</p>
</div>
</div>
);
}
if (lead.source === 'referral' && lead.referredBy) {
const c = lead.referredBy.type === 'client' ? '#8B5CF6' : '#06B6D4';
const typeLabel = lead.referredBy.type === 'client' ? 'Client Referral' : 'Customer Referral';
return (
<div
className="flex items-center gap-2.5 mt-2.5 p-2.5 rounded-xl border"
style={{ backgroundColor: `${c}12`, borderColor: `${c}28` }}
>
<div className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0" style={{ backgroundColor: `${c}22` }}>
<Users size={12} style={{ color: c }} />
</div>
<div className="min-w-0">
<p className="text-[9px] font-bold uppercase tracking-wider" style={{ color: c }}>{typeLabel}</p>
<p className="text-xs font-semibold text-zinc-800 dark:text-zinc-100">{lead.referredBy.name}</p>
<p className="text-[9px] font-mono text-zinc-400 dark:text-zinc-600 mt-0.5">{lead.referredBy.id}</p>
</div>
</div>
);
}
if (lead.source === 'website_form' && lead.webSource) {
const c = '#F59E0B';
return (
<div
className="flex items-center gap-2.5 mt-2.5 p-2.5 rounded-xl border"
style={{ backgroundColor: `${c}12`, borderColor: `${c}28` }}
>
<div className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0" style={{ backgroundColor: `${c}22` }}>
<Globe size={12} style={{ color: c }} />
</div>
<div className="min-w-0">
<p className="text-[9px] font-bold uppercase tracking-wider" style={{ color: c }}>Campaign</p>
<p className="text-xs font-semibold text-zinc-800 dark:text-zinc-100">{lead.webSource.campaign}</p>
</div>
</div>
);
}
return null;
};
// ---------------------------------------------------------------------------
// Notes section — read-only + inline edit with double-gate confirmation
// ---------------------------------------------------------------------------
const NotesSection = ({ lead, accent }) => {
const { updateLeadNotes } = useMockStore();
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState(lead.notes || '');
const [confirmMode, setConfirmMode] = useState(false);
const [agreed, setAgreed] = useState(false);
// Sync when lead changes (different lead opened)
useEffect(() => {
setEditing(false);
setEditText(lead.notes || '');
setConfirmMode(false);
setAgreed(false);
}, [lead.id, lead.notes]);
const cancelEdit = () => {
setEditing(false);
setEditText(lead.notes || '');
setConfirmMode(false);
setAgreed(false);
};
const confirmSave = () => {
updateLeadNotes(lead.id, editText.trim());
setEditing(false);
setConfirmMode(false);
setAgreed(false);
toast.success('Notes updated', {
description: lead.customer.name,
duration: 3000,
});
};
const isDirty = editText.trim() !== (lead.notes || '').trim();
return (
<div>
<div className="flex items-center justify-between mb-2.5">
<SectionLabel>Notes &amp; Issues</SectionLabel>
{!editing && (
<button
onClick={() => setEditing(true)}
className="flex items-center gap-1 text-[10px] font-semibold text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors -mt-0.5"
>
<Pencil size={10} />
Edit
</button>
)}
</div>
{/* Read-only */}
{!editing && (
<p className={`text-xs leading-relaxed ${
lead.notes
? 'text-zinc-600 dark:text-zinc-300'
: 'text-zinc-300 dark:text-zinc-600 italic'
}`}>
{lead.notes || 'No notes on file.'}
</p>
)}
{/* Edit mode */}
{editing && (
<div className="space-y-2">
<textarea
value={editText}
onChange={e => setEditText(e.target.value)}
rows={4}
maxLength={500}
autoFocus
placeholder="Add notes about this lead..."
className="w-full rounded-xl border border-zinc-200 dark:border-white/[0.1] bg-zinc-50 dark:bg-zinc-800/60 text-xs text-zinc-800 dark:text-zinc-100 placeholder-zinc-300 dark:placeholder-zinc-600 px-3 py-2.5 resize-none outline-none focus:border-zinc-400 dark:focus:border-white/[0.25] transition-colors leading-relaxed"
/>
<div className="flex items-center justify-between">
<span className="text-[9px] text-zinc-300 dark:text-zinc-700">
{editText.length}/500
</span>
<div className="flex items-center gap-2">
<button
onClick={cancelEdit}
className="text-[11px] font-semibold text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors px-2 py-1"
>
Cancel
</button>
<button
onClick={() => setConfirmMode(true)}
disabled={!isDirty}
className="text-[11px] font-bold px-3 py-1.5 rounded-lg text-white transition-all disabled:opacity-35 disabled:cursor-not-allowed"
style={{ backgroundColor: isDirty ? accent : '#6B7280' }}
>
Save
</button>
</div>
</div>
{/* Inline confirmation — slides in below */}
<AnimatePresence>
{confirmMode && (
<motion.div
key="confirm"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.22, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
<div className="rounded-xl border p-3 space-y-3" style={{ backgroundColor: '#F59E0B12', borderColor: '#F59E0B30' }}>
<div className="flex items-start gap-2">
<AlertTriangle size={13} className="text-amber-500 shrink-0 mt-px" />
<p className="text-[11px] text-amber-700 dark:text-amber-300 leading-snug">
This will permanently update notes for{' '}
<span className="font-bold">{lead.customer.name}</span>.
</p>
</div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={agreed}
onChange={e => setAgreed(e.target.checked)}
className="w-3.5 h-3.5 rounded accent-amber-500 cursor-pointer shrink-0"
/>
<span className="text-[11px] text-amber-700 dark:text-amber-400 font-medium">
I have reviewed this change
</span>
</label>
<div className="flex gap-2">
<button
onClick={() => { setConfirmMode(false); setAgreed(false); }}
className="flex-1 text-[11px] font-semibold py-1.5 rounded-lg border text-amber-600 dark:text-amber-400 transition-colors"
style={{ borderColor: '#F59E0B40' }}
>
Go Back
</button>
<button
onClick={confirmSave}
disabled={!agreed}
className="flex-1 text-[11px] font-bold py-1.5 rounded-lg text-white bg-amber-500 hover:bg-amber-600 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Confirm Save
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
);
};
// ---------------------------------------------------------------------------
// Main Modal
// ---------------------------------------------------------------------------
const LeadQuickViewModal = ({ lead, onClose, accent }) => {
const urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard;
const statusCfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.unassigned;
const srcCfg = SOURCE_CONFIG[lead.source] || { label: lead.source, color: '#6B7280' };
// Escape to close
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
// Initials from name
const initials = lead.customer.name
.split(' ')
.map(n => n[0])
.join('')
.slice(0, 2)
.toUpperCase();
const modal = (
<>
{/* Backdrop */}
<motion.div
key="qlv-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-[200] bg-black/45 backdrop-blur-sm"
onClick={onClose}
/>
{/* Drawer — slides in from right */}
<motion.div
key="qlv-drawer"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', stiffness: 360, damping: 34 }}
className="fixed right-0 top-0 bottom-0 z-[201] w-full sm:w-[420px] bg-white dark:bg-zinc-900 border-l border-zinc-200 dark:border-white/[0.07] shadow-2xl flex flex-col"
onClick={e => e.stopPropagation()}
>
{/* ── Header ── */}
<div
className="shrink-0 flex items-start justify-between gap-3 px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06]"
style={{ borderTopWidth: 3, borderTopColor: urgCfg.color }}
>
<div className="min-w-0 flex-1 pt-0.5">
{/* Pills row */}
<div className="flex items-center gap-1.5 flex-wrap mb-1.5">
<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>
<span
className="text-[9px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0"
style={{ backgroundColor: `${statusCfg.color}15`, color: statusCfg.color }}
>
{statusCfg.label}
</span>
<span
className="text-[9px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0"
style={{ backgroundColor: `${srcCfg.color}15`, color: srcCfg.color }}
>
{srcCfg.label}
</span>
</div>
<div className="flex items-center gap-2">
<FileText size={13} className="text-zinc-400 shrink-0" />
<p className="text-sm font-black uppercase tracking-tight text-zinc-900 dark:text-white">
Lead Quick View
</p>
</div>
<p className="text-[9px] font-mono text-zinc-300 dark:text-zinc-600 mt-0.5 ml-5">
{lead.id}
</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 shrink-0 mt-0.5"
>
<X size={15} />
</button>
</div>
{/* ── Scrollable body ── */}
<div className="flex-1 overflow-y-auto custom-scrollbar px-4 py-4">
{/* § Client & Contact */}
<section>
<SectionLabel>Client &amp; Contact</SectionLabel>
{/* Name with initials avatar */}
<div className="flex items-center gap-2.5 mb-3">
<div
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0 text-xs font-black text-white"
style={{ backgroundColor: `${urgCfg.color}CC` }}
>
{initials}
</div>
<p className="text-sm font-bold text-zinc-900 dark:text-white leading-snug">
{lead.customer.name}
</p>
</div>
{/* Contact details */}
<div className="space-y-2 ml-1">
{lead.customer.phone && (
<div className="flex items-center gap-2">
<Phone size={12} className="text-zinc-400 shrink-0" />
<span className="text-xs font-medium text-zinc-600 dark:text-zinc-300">
{lead.customer.phone}
</span>
<CopyButton text={lead.customer.phone} />
</div>
)}
{lead.customer.email && (
<div className="flex items-center gap-2 min-w-0">
<Mail size={12} className="text-zinc-400 shrink-0" />
<span className="text-xs font-medium text-zinc-600 dark:text-zinc-300 truncate">
{lead.customer.email}
</span>
<CopyButton text={lead.customer.email} />
</div>
)}
</div>
</section>
<Divider />
{/* § Property */}
<section>
<SectionLabel>Property</SectionLabel>
<div className="flex items-start gap-2 ml-0.5">
<MapPin size={13} className="text-zinc-400 shrink-0 mt-px" />
<div>
<p className="text-xs font-semibold text-zinc-800 dark:text-zinc-100 leading-snug">
{lead.property.address}
</p>
<p className="text-xs text-zinc-400 mt-0.5">
{lead.property.city}, {lead.property.state} {lead.property.zip}
</p>
</div>
</div>
<div className="flex items-center gap-2 mt-2.5 ml-5 flex-wrap">
<span className="text-[10px] font-medium text-zinc-500 dark:text-zinc-400">
{LEAD_TYPE_LABELS[lead.leadType] || lead.leadType}
</span>
<span className="text-zinc-300 dark:text-zinc-600">·</span>
<span className="flex items-center gap-1 text-[10px] text-zinc-400">
<Clock size={9} />
{lead.estimatedDuration} min est.
</span>
</div>
</section>
<Divider />
{/* § Lead Source */}
<section>
<SectionLabel>Lead Source</SectionLabel>
<div
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-bold uppercase tracking-wider"
style={{ backgroundColor: `${srcCfg.color}14`, color: srcCfg.color }}
>
{srcCfg.label}
</div>
<SourceDetail lead={lead} />
</section>
<Divider />
{/* § Notes */}
<section>
<NotesSection lead={lead} accent={accent} />
</section>
{/* Bottom breathing room */}
<div className="h-4" />
</div>
{/* ── Footer ── */}
<div className="shrink-0 flex items-center justify-between px-4 py-2.5 border-t border-zinc-100 dark:border-white/[0.06] bg-zinc-50/50 dark:bg-zinc-900/50">
<p className="text-[9px] font-mono text-zinc-200 dark:text-zinc-700">{lead.id}</p>
<button
onClick={onClose}
className="text-[11px] font-bold uppercase tracking-wider text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors px-2 py-1 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
Close
</button>
</div>
</motion.div>
</>
);
return ReactDOM.createPortal(modal, document.body);
};
export default LeadQuickViewModal;