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:
@@ -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 & 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 & 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;
|
||||
Reference in New Issue
Block a user