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 React, { useState, useEffect, useRef } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Globe, MapPin, Users, Phone, Clock, CloudLightning } from 'lucide-react';
|
import { Globe, MapPin, Users, Phone, Clock, CloudLightning, FileText, Search, X, Zap, CheckCircle } from 'lucide-react';
|
||||||
import { useMockStore, DISPATCH_REPS } from '../../data/mockStore';
|
import { useMockStore, DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Config maps
|
// Config maps
|
||||||
@@ -47,6 +47,14 @@ const TAB_STATUS_FILTER = {
|
|||||||
confirmed: ['assigned', 'confirmed'],
|
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
|
// Fake leads that "drop in" every 20s during demo — cycled through pool
|
||||||
const DROP_POOL = [
|
const DROP_POOL = [
|
||||||
{
|
{
|
||||||
@@ -72,14 +80,26 @@ const DROP_POOL = [
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// LeadCard
|
// 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 urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard;
|
||||||
const statusCfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.unassigned;
|
const statusCfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.unassigned;
|
||||||
const srcCfg = SOURCE_CONFIG[lead.source] || SOURCE_CONFIG.call_center;
|
const srcCfg = SOURCE_CONFIG[lead.source] || SOURCE_CONFIG.call_center;
|
||||||
const SrcIcon = srcCfg.icon;
|
const SrcIcon = srcCfg.icon;
|
||||||
const assignedRep = lead.assignedRepId ? DISPATCH_REPS.find(r => r.id === lead.assignedRepId) : null;
|
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 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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -128,6 +148,16 @@ const LeadCard = ({ lead, isSelected, isNew, onSelect, stormMode, accent, index
|
|||||||
PRIORITY
|
PRIORITY
|
||||||
</motion.span>
|
</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>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0 text-zinc-400 dark:text-zinc-500">
|
<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}
|
{statusCfg.label}
|
||||||
</span>
|
</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} />
|
<Clock size={10} />
|
||||||
{timeLabel}
|
{timeLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lead ID */}
|
{/* Footer: lead ID + actions */}
|
||||||
<p className="text-[9px] text-zinc-200 dark:text-zinc-800 mt-1.5 font-mono">{lead.id}</p>
|
<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>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -192,11 +268,12 @@ const LeadCard = ({ lead, isSelected, isNew, onSelect, stormMode, accent, index
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DispatchLeadQueue
|
// DispatchLeadQueue
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const DispatchLeadQueue = ({ selectedLead, onSelectLead, stormMode, accent }) => {
|
const DispatchLeadQueue = ({ selectedLead, onSelectLead, onViewDetails, onQuickAssign, stormMode, accent }) => {
|
||||||
const { dispatchLeads } = useMockStore();
|
const { dispatchLeads } = useMockStore();
|
||||||
const [activeTab, setActiveTab] = useState('all');
|
const [activeTab, setActiveTab] = useState('all');
|
||||||
const [droppedLeads, setDropped] = useState([]);
|
const [droppedLeads, setDropped] = useState([]);
|
||||||
const [newLeadIds, setNewLeadIds] = useState(new Set());
|
const [newLeadIds, setNewLeadIds] = useState(new Set());
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
const dropIndexRef = useRef(0);
|
const dropIndexRef = useRef(0);
|
||||||
|
|
||||||
// 20-second interval: drop each lead in the pool once (max DROP_POOL.length drops)
|
// 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(l => statusFilter.includes(l.status))
|
||||||
: allLeads;
|
: 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
|
// Storm mode: float emergency leads to top
|
||||||
if (stormMode) {
|
if (stormMode) {
|
||||||
filteredLeads = [...filteredLeads].sort((a, b) => {
|
filteredLeads = [...filteredLeads].sort((a, b) => {
|
||||||
@@ -255,6 +342,28 @@ const DispatchLeadQueue = ({ selectedLead, onSelectLead, stormMode, accent }) =>
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<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 */}
|
{/* 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">
|
<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 => {
|
{TABS.map(tab => {
|
||||||
@@ -306,6 +415,8 @@ const DispatchLeadQueue = ({ selectedLead, onSelectLead, stormMode, accent }) =>
|
|||||||
isSelected={selectedLead?.id === lead.id}
|
isSelected={selectedLead?.id === lead.id}
|
||||||
isNew={newLeadIds.has(lead.id)}
|
isNew={newLeadIds.has(lead.id)}
|
||||||
onSelect={onSelectLead}
|
onSelect={onSelectLead}
|
||||||
|
onViewDetails={onViewDetails}
|
||||||
|
onQuickAssign={onQuickAssign}
|
||||||
stormMode={stormMode}
|
stormMode={stormMode}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
index={index}
|
index={index}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { motion, AnimatePresence } from 'framer-motion';
|
|||||||
import {
|
import {
|
||||||
Bot, CheckCircle, X, Star, Clock,
|
Bot, CheckCircle, X, Star, Clock,
|
||||||
Navigation, CloudLightning, Zap, Users, ChevronRight,
|
Navigation, CloudLightning, Zap, Users, ChevronRight,
|
||||||
|
AlertTriangle, Sparkles, FileText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PieChart, Pie, Cell } from 'recharts';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
|
import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
|
||||||
import { AnimatedCounter } from '../AnimatedCounter';
|
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 CapacityDonut = ({ used, max }) => {
|
||||||
const pct = used / max;
|
const pct = used / max;
|
||||||
const color = pct >= 0.85 ? '#EF4444' : pct >= 0.6 ? '#F59E0B' : '#10B981';
|
const color = pct >= 0.85 ? '#EF4444' : pct >= 0.6 ? '#F59E0B' : '#10B981';
|
||||||
const data = [{ value: used }, { value: Math.max(0, max - used) }];
|
const r = 8;
|
||||||
|
const circumference = 2 * Math.PI * r;
|
||||||
|
const filled = circumference * Math.min(pct, 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<PieChart width={24} height={24}>
|
<svg width="22" height="22" viewBox="0 0 22 22" style={{ flexShrink: 0 }}>
|
||||||
<Pie
|
{/* Track */}
|
||||||
data={data}
|
<circle
|
||||||
cx={12} cy={12}
|
cx="11" cy="11" r={r}
|
||||||
innerRadius={6} outerRadius={11}
|
fill="none"
|
||||||
startAngle={90} endAngle={-270}
|
stroke="currentColor"
|
||||||
dataKey="value"
|
strokeWidth="3"
|
||||||
strokeWidth={0}
|
className="text-zinc-200 dark:text-zinc-700"
|
||||||
>
|
/>
|
||||||
<Cell fill={color} />
|
{/* Fill */}
|
||||||
<Cell fill="transparent" />
|
<circle
|
||||||
</Pie>
|
cx="11" cy="11" r={r}
|
||||||
</PieChart>
|
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>
|
<span className="text-[10px] text-zinc-400 font-medium">{used}/{max} slots</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -205,14 +214,13 @@ const RepAvatar = ({ initials, status }) => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Lead summary header
|
// Lead summary header
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const LeadSummary = ({ lead, stormMode }) => {
|
const LeadSummary = ({ lead, stormMode, onViewDetails }) => {
|
||||||
const urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard;
|
const urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard;
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-xl border p-3 transition-all duration-500 ${
|
<div
|
||||||
stormMode
|
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]"
|
||||||
? 'bg-amber-50/70 dark:bg-amber-500/8 border-amber-200 dark:border-amber-500/20'
|
style={stormMode ? { backgroundColor: '#F59E0B0E', borderColor: '#F59E0B30' } : undefined}
|
||||||
: 'bg-zinc-50 dark:bg-zinc-800/40 border-zinc-200 dark:border-white/[0.07]'
|
>
|
||||||
}`}>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-bold text-zinc-900 dark:text-white truncate">{lead.customer.name}</p>
|
<p className="text-xs font-bold text-zinc-900 dark:text-white truncate">{lead.customer.name}</p>
|
||||||
@@ -227,17 +235,26 @@ const LeadSummary = ({ lead, stormMode }) => {
|
|||||||
{urgCfg.label}
|
{urgCfg.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5 mt-2 flex-wrap">
|
<div className="flex items-center justify-between mt-2 gap-2">
|
||||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400 font-medium">
|
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||||
{LEAD_TYPE_LABELS[lead.leadType] || lead.leadType}
|
<span className="text-[10px] text-zinc-500 dark:text-zinc-400 font-medium">
|
||||||
</span>
|
{LEAD_TYPE_LABELS[lead.leadType] || lead.leadType}
|
||||||
<span className="text-zinc-300 dark:text-zinc-700">·</span>
|
</span>
|
||||||
<span className="flex items-center gap-1 text-[10px] text-zinc-400">
|
<span className="text-zinc-300 dark:text-zinc-700">·</span>
|
||||||
<Clock size={9} />
|
<span className="flex items-center gap-1 text-[10px] text-zinc-400">
|
||||||
{lead.estimatedDuration} min est.
|
<Clock size={9} />
|
||||||
</span>
|
{lead.estimatedDuration} min est.
|
||||||
<span className="text-zinc-300 dark:text-zinc-700">·</span>
|
</span>
|
||||||
<span className="text-[9px] font-mono text-zinc-300 dark:text-zinc-700">{lead.id}</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>
|
||||||
</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'
|
: 'border-zinc-200 dark:border-white/[0.07] bg-white dark:bg-zinc-800/40'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Top-pick badge */}
|
{/* Top-right: Top Pick badge (rank 0 only) stacked above Score */}
|
||||||
{rank === 0 && (
|
<div className="absolute top-2.5 right-2.5 flex flex-col items-end gap-1.5">
|
||||||
<div className="absolute top-2.5 right-2.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">
|
<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" />
|
<Star size={7} fill="currentColor" />
|
||||||
Top Pick
|
Top Pick
|
||||||
</span>
|
</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>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Rep identity row */}
|
{/* Rep identity row */}
|
||||||
<div className="flex items-start gap-2.5 mb-2.5">
|
<div className="flex items-start gap-2.5 mb-2.5">
|
||||||
<RepAvatar initials={rep.initials} status={rep.status} />
|
<RepAvatar initials={rep.initials} status={rep.status} />
|
||||||
<div className="min-w-0 flex-1 pt-0.5">
|
<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-sm font-bold text-zinc-900 dark:text-white leading-tight pr-20">{rep.name}</p>
|
||||||
<p className="text-[11px] mt-0.5" style={{ color: REP_STATUS_COLORS[rep.status] || '#6B7280' }}>
|
{/* Status + distance */}
|
||||||
{REP_STATUS_LABELS[rep.status]}
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
</p>
|
<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">
|
<div className="mt-1.5">
|
||||||
<CapacityDonut used={rep.todayAppointments} max={rep.maxDaily} />
|
<CapacityDonut used={rep.todayAppointments} max={rep.maxDaily} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* AI Score — top-right */}
|
</div>
|
||||||
<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>
|
{/* Arrival window */}
|
||||||
<div className="text-[1.6rem] font-black font-mono leading-none" style={{ color }}>
|
<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]">
|
||||||
<AnimatedCounter from={0} to={rec.score} duration={0.85} />
|
<Clock size={10} className="text-zinc-400 shrink-0" />
|
||||||
</div>
|
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||||
<p className="text-[9px] text-zinc-400 mt-0.5 text-right">{rec.slotStart}</p>
|
Arrives{' '}
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Score breakdown bars */}
|
{/* Score breakdown bars */}
|
||||||
@@ -311,6 +362,29 @@ const RepCard = ({ rec, rep, rank, stormMode, accent, onAssign, onOverride }) =>
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 */}
|
{/* Reason chips */}
|
||||||
<div className="flex flex-wrap gap-1 mb-3">
|
<div className="flex flex-wrap gap-1 mb-3">
|
||||||
{reasons.map((r, i) => {
|
{reasons.map((r, i) => {
|
||||||
@@ -428,7 +502,7 @@ const DispatchedConfirmation = ({ info, accent }) => (
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Rep picker modal (Override)
|
// 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">
|
<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
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 32 }}
|
initial={{ opacity: 0, y: 32 }}
|
||||||
@@ -458,7 +532,7 @@ const RepPickerModal = ({ onClose, onSelect, accent }) => (
|
|||||||
|
|
||||||
{/* Rep list */}
|
{/* Rep list */}
|
||||||
<div className="p-3 space-y-1.5 max-h-[60vh] overflow-y-auto custom-scrollbar">
|
<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 color = REP_STATUS_COLORS[rep.status] || '#6B7280';
|
||||||
const isFull = rep.todayAppointments >= rep.maxDaily;
|
const isFull = rep.todayAppointments >= rep.maxDaily;
|
||||||
|
|
||||||
@@ -506,10 +580,12 @@ const RepPickerModal = ({ onClose, onSelect, accent }) => (
|
|||||||
const DispatchRecommendationDrawer = ({
|
const DispatchRecommendationDrawer = ({
|
||||||
selectedLead,
|
selectedLead,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
|
reps = DISPATCH_REPS,
|
||||||
stormMode,
|
stormMode,
|
||||||
accent,
|
accent,
|
||||||
onAssign,
|
onAssign,
|
||||||
onDismissAfterAssign,
|
onDismissAfterAssign,
|
||||||
|
onViewDetails,
|
||||||
isDesktop,
|
isDesktop,
|
||||||
}) => {
|
}) => {
|
||||||
const [dispatchedInfo, setDispatchedInfo] = useState(null);
|
const [dispatchedInfo, setDispatchedInfo] = useState(null);
|
||||||
@@ -522,13 +598,13 @@ const DispatchRecommendationDrawer = ({
|
|||||||
setDispatchedInfo(null);
|
setDispatchedInfo(null);
|
||||||
}, [selectedLead?.id]);
|
}, [selectedLead?.id]);
|
||||||
|
|
||||||
const handleAssign = useCallback((rep) => {
|
const handleAssign = useCallback((rep, isManual = false) => {
|
||||||
if (!selectedLead) return;
|
if (!selectedLead) return;
|
||||||
|
|
||||||
const recs = DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? [];
|
const recs = DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? [];
|
||||||
const repRec = recs.find(r => r.repId === rep.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`, {
|
toast.success(`${rep.name.split(' ')[0]} dispatched`, {
|
||||||
description: `${selectedLead.property.address} · ${repRec?.slotStart ?? 'TBD'}`,
|
description: `${selectedLead.property.address} · ${repRec?.slotStart ?? 'TBD'}`,
|
||||||
@@ -551,7 +627,7 @@ const DispatchRecommendationDrawer = ({
|
|||||||
|
|
||||||
const handlePickerSelect = (rep) => {
|
const handlePickerSelect = (rep) => {
|
||||||
setShowRepPicker(false);
|
setShowRepPicker(false);
|
||||||
handleAssign(rep);
|
handleAssign(rep, true); // manual override
|
||||||
};
|
};
|
||||||
|
|
||||||
// Height: desktop = fixed to match lead list; mobile = min-h
|
// Height: desktop = fixed to match lead list; mobile = min-h
|
||||||
@@ -586,7 +662,65 @@ const DispatchRecommendationDrawer = ({
|
|||||||
transition={{ duration: 0.18 }}
|
transition={{ duration: 0.18 }}
|
||||||
className="p-3 space-y-3"
|
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 */}
|
{/* Section divider */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -599,7 +733,7 @@ const DispatchRecommendationDrawer = ({
|
|||||||
|
|
||||||
{/* Rep cards */}
|
{/* Rep cards */}
|
||||||
{recommendations.map((rec, i) => {
|
{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;
|
if (!rep) return null;
|
||||||
return (
|
return (
|
||||||
<RepCard
|
<RepCard
|
||||||
@@ -680,6 +814,7 @@ const DispatchRecommendationDrawer = ({
|
|||||||
onClose={() => setShowRepPicker(false)}
|
onClose={() => setShowRepPicker(false)}
|
||||||
onSelect={handlePickerSelect}
|
onSelect={handlePickerSelect}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
|
reps={reps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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;
|
||||||
+167
-18
@@ -2731,7 +2731,7 @@ export const DISPATCH_REPS = [
|
|||||||
currentLat: 33.0612, currentLng: -96.7891,
|
currentLat: 33.0612, currentLng: -96.7891,
|
||||||
todayAppointments: 3, maxDaily: 6,
|
todayAppointments: 3, maxDaily: 6,
|
||||||
skills: ['insurance_claim', 'retail_estimate'],
|
skills: ['insurance_claim', 'retail_estimate'],
|
||||||
performanceScore: 94,
|
performanceScore: 94, rating: 4.8, closeRate: 82,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'REP-02', name: 'Aisha Kumar', initials: 'AK',
|
id: 'REP-02', name: 'Aisha Kumar', initials: 'AK',
|
||||||
@@ -2739,7 +2739,7 @@ export const DISPATCH_REPS = [
|
|||||||
currentLat: 33.0812, currentLng: -96.7234,
|
currentLat: 33.0812, currentLng: -96.7234,
|
||||||
todayAppointments: 2, maxDaily: 6,
|
todayAppointments: 2, maxDaily: 6,
|
||||||
skills: ['roof_inspection', 'emergency_tarp'],
|
skills: ['roof_inspection', 'emergency_tarp'],
|
||||||
performanceScore: 88,
|
performanceScore: 88, rating: 4.9, closeRate: 76,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'REP-03', name: 'Tom Bradley', initials: 'TB',
|
id: 'REP-03', name: 'Tom Bradley', initials: 'TB',
|
||||||
@@ -2747,7 +2747,7 @@ export const DISPATCH_REPS = [
|
|||||||
currentLat: 33.0389, currentLng: -96.7401,
|
currentLat: 33.0389, currentLng: -96.7401,
|
||||||
todayAppointments: 5, maxDaily: 6,
|
todayAppointments: 5, maxDaily: 6,
|
||||||
skills: ['retail_estimate', 'roof_inspection'],
|
skills: ['retail_estimate', 'roof_inspection'],
|
||||||
performanceScore: 79,
|
performanceScore: 79, rating: 4.5, closeRate: 68,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'REP-04', name: 'Nina Patel', initials: 'NP',
|
id: 'REP-04', name: 'Nina Patel', initials: 'NP',
|
||||||
@@ -2755,7 +2755,7 @@ export const DISPATCH_REPS = [
|
|||||||
currentLat: 33.0516, currentLng: -96.7512,
|
currentLat: 33.0516, currentLng: -96.7512,
|
||||||
todayAppointments: 1, maxDaily: 6,
|
todayAppointments: 1, maxDaily: 6,
|
||||||
skills: ['insurance_claim', 'roof_inspection', 'emergency_tarp'],
|
skills: ['insurance_claim', 'roof_inspection', 'emergency_tarp'],
|
||||||
performanceScore: 91,
|
performanceScore: 91, rating: 4.7, closeRate: 79,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'REP-05', name: 'Derek Walsh', initials: 'DW',
|
id: 'REP-05', name: 'Derek Walsh', initials: 'DW',
|
||||||
@@ -2763,81 +2763,105 @@ export const DISPATCH_REPS = [
|
|||||||
currentLat: 33.0198, currentLng: -96.7234,
|
currentLat: 33.0198, currentLng: -96.7234,
|
||||||
todayAppointments: 4, maxDaily: 6,
|
todayAppointments: 4, maxDaily: 6,
|
||||||
skills: ['retail_estimate', 'roof_inspection'],
|
skills: ['retail_estimate', 'roof_inspection'],
|
||||||
performanceScore: 82,
|
performanceScore: 82, rating: 4.6, closeRate: 74,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DISPATCH_LEADS_INITIAL = [
|
const DISPATCH_LEADS_INITIAL = [
|
||||||
{
|
{
|
||||||
id: 'LD-1001', source: 'call_center', leadType: 'emergency_tarp', urgency: 'emergency',
|
id: 'LD-1001', source: 'call_center', leadType: 'emergency_tarp', urgency: 'emergency',
|
||||||
customer: { name: 'John Smith', phone: '(972) 555-1001' },
|
customer: { name: 'John Smith', phone: '(972) 555-1001', email: 'john.smith@gmail.com' },
|
||||||
property: { address: '2612 Dunwick Dr', city: 'Plano', state: 'TX', zip: '75023', lat: 33.0708, lng: -96.7456 },
|
property: { address: '2612 Dunwick Dr', city: 'Plano', state: 'TX', zip: '75023', lat: 33.0708, lng: -96.7456 },
|
||||||
|
callCenter: { id: 'CC-01', name: 'Plano Command Center' },
|
||||||
|
notes: 'Customer reports major tarp blowoff after last night\'s storm — exposed decking on NE corner. Needs immediate coverage before rain resumes.',
|
||||||
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 8, assignedRepId: null,
|
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 8, assignedRepId: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'LD-1002', source: 'website_form', leadType: 'roof_inspection', urgency: 'high',
|
id: 'LD-1002', source: 'website_form', leadType: 'roof_inspection', urgency: 'high',
|
||||||
customer: { name: 'Maria Gonzalez', phone: '(972) 555-1002' },
|
customer: { name: 'Maria Gonzalez', phone: '(972) 555-1002', email: 'maria.gonzalez@gmail.com' },
|
||||||
property: { address: '4817 Shady Brook Ln', city: 'Plano', state: 'TX', zip: '75093', lat: 33.0652, lng: -96.8021 },
|
property: { address: '4817 Shady Brook Ln', city: 'Plano', state: 'TX', zip: '75093', lat: 33.0652, lng: -96.8021 },
|
||||||
|
webSource: { campaign: 'Google Ads — Storm Season 2026' },
|
||||||
|
notes: 'Noticed curling shingles and granule loss in gutters. No active leak but concerned about upcoming storm season. Wants full inspection.',
|
||||||
estimatedDuration: 45, status: 'unassigned', arrivedMinsAgo: 14, assignedRepId: null,
|
estimatedDuration: 45, status: 'unassigned', arrivedMinsAgo: 14, assignedRepId: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'LD-1003', source: 'canvassing', leadType: 'insurance_claim', urgency: 'high',
|
id: 'LD-1003', source: 'canvassing', leadType: 'insurance_claim', urgency: 'high',
|
||||||
customer: { name: 'Robert Chen', phone: '(972) 555-1003' },
|
customer: { name: 'Robert Chen', phone: '(972) 555-1003', email: 'robert.chen@outlook.com' },
|
||||||
property: { address: '1234 Oak Creek Blvd', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0421, lng: -96.7138 },
|
property: { address: '1234 Oak Creek Blvd', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0421, lng: -96.7138 },
|
||||||
|
canvassedBy: { agentId: 'U-05', name: 'Marcus Webb' },
|
||||||
|
notes: 'Filed insurance claim after hail event 3/18. Adjuster visit scheduled 3/28 — needs estimate in hand beforehand. Cooperative and responsive.',
|
||||||
estimatedDuration: 45, status: 'assigned', arrivedMinsAgo: 22, assignedRepId: 'REP-01',
|
estimatedDuration: 45, status: 'assigned', arrivedMinsAgo: 22, assignedRepId: 'REP-01',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'LD-1004', source: 'referral', leadType: 'retail_estimate', urgency: 'standard',
|
id: 'LD-1004', source: 'referral', leadType: 'retail_estimate', urgency: 'standard',
|
||||||
customer: { name: 'Sarah Mitchell', phone: '(972) 555-1004' },
|
customer: { name: 'Sarah Mitchell', phone: '(972) 555-1004', email: 'sarah.mitchell@yahoo.com' },
|
||||||
property: { address: '890 Custer Rd', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0389, lng: -96.7334 },
|
property: { address: '890 Custer Rd', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0389, lng: -96.7334 },
|
||||||
|
referredBy: { type: 'client', id: 'CL-2201', name: 'Tom Hargrove' },
|
||||||
|
notes: 'Retail estimate for full reroof. No insurance involvement. Wants architectural shingles — GAF Timberline HDZ preferred. Budget-conscious.',
|
||||||
estimatedDuration: 30, status: 'confirmed', arrivedMinsAgo: 35, assignedRepId: 'REP-03',
|
estimatedDuration: 30, status: 'confirmed', arrivedMinsAgo: 35, assignedRepId: 'REP-03',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'LD-1005', source: 'call_center', leadType: 'roof_inspection', urgency: 'standard',
|
id: 'LD-1005', source: 'call_center', leadType: 'roof_inspection', urgency: 'standard',
|
||||||
customer: { name: 'James Wilson', phone: '(972) 555-1005' },
|
customer: { name: 'James Wilson', phone: '(972) 555-1005', email: 'j.wilson85@gmail.com' },
|
||||||
property: { address: '321 Independence Pkwy', city: 'Plano', state: 'TX', zip: '75023', lat: 33.0812, lng: -96.7521 },
|
property: { address: '321 Independence Pkwy', city: 'Plano', state: 'TX', zip: '75023', lat: 33.0812, lng: -96.7521 },
|
||||||
|
callCenter: { id: 'CC-02', name: 'DFW Ops Hub' },
|
||||||
|
notes: 'At-risk — customer called twice to reschedule. Currently assigned but rep running behind. Possible missed appointment window.',
|
||||||
estimatedDuration: 45, status: 'at_risk', arrivedMinsAgo: 47, assignedRepId: 'REP-02',
|
estimatedDuration: 45, status: 'at_risk', arrivedMinsAgo: 47, assignedRepId: 'REP-02',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'LD-1006', source: 'website_form', leadType: 'insurance_claim', urgency: 'high',
|
id: 'LD-1006', source: 'website_form', leadType: 'insurance_claim', urgency: 'high',
|
||||||
customer: { name: 'Lisa Thompson', phone: '(972) 555-1006' },
|
customer: { name: 'Lisa Thompson', phone: '(972) 555-1006', email: 'lisathompson@icloud.com' },
|
||||||
property: { address: '7654 Preston Rd', city: 'Plano', state: 'TX', zip: '75024', lat: 33.0934, lng: -96.8002 },
|
property: { address: '7654 Preston Rd', city: 'Plano', state: 'TX', zip: '75024', lat: 33.0934, lng: -96.8002 },
|
||||||
|
webSource: { campaign: 'Organic Search' },
|
||||||
|
notes: 'Large residential — ridge cap blown off, potential decking exposure. Adjuster already completed visit, needs supplement review before signing.',
|
||||||
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 5, assignedRepId: null,
|
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 5, assignedRepId: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'LD-1007', source: 'canvassing', leadType: 'retail_estimate', urgency: 'standard',
|
id: 'LD-1007', source: 'canvassing', leadType: 'retail_estimate', urgency: 'standard',
|
||||||
customer: { name: 'David Park', phone: '(972) 555-1007' },
|
customer: { name: 'David Park', phone: '(972) 555-1007', email: 'dpark.plano@gmail.com' },
|
||||||
property: { address: '2200 Alma Dr', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0516, lng: -96.7289 },
|
property: { address: '2200 Alma Dr', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0516, lng: -96.7289 },
|
||||||
|
canvassedBy: { agentId: 'U-07', name: 'Priya Nair' },
|
||||||
|
notes: 'Customer confirmed appointment. Wants two options: repair patch vs full replacement. Existing roof is 19 years old.',
|
||||||
estimatedDuration: 30, status: 'confirmed', arrivedMinsAgo: 62, assignedRepId: 'REP-04',
|
estimatedDuration: 30, status: 'confirmed', arrivedMinsAgo: 62, assignedRepId: 'REP-04',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'LD-1008', source: 'referral', leadType: 'roof_inspection', urgency: 'standard',
|
id: 'LD-1008', source: 'referral', leadType: 'roof_inspection', urgency: 'standard',
|
||||||
customer: { name: 'Angela Moore', phone: '(972) 555-1008' },
|
customer: { name: 'Angela Moore', phone: '(972) 555-1008', email: 'angela.moore72@gmail.com' },
|
||||||
property: { address: '555 Legacy Dr', city: 'Plano', state: 'TX', zip: '75023', lat: 33.0744, lng: -96.7612 },
|
property: { address: '555 Legacy Dr', city: 'Plano', state: 'TX', zip: '75023', lat: 33.0744, lng: -96.7612 },
|
||||||
|
referredBy: { type: 'customer', id: 'CU-0892', name: 'Daniel Reyes' },
|
||||||
|
notes: 'Referral from existing customer. Friendly homeowner, flexible scheduling. Roof is 12 years old with no known damage — proactive inspection only.',
|
||||||
estimatedDuration: 45, status: 'unassigned', arrivedMinsAgo: 28, assignedRepId: null,
|
estimatedDuration: 45, status: 'unassigned', arrivedMinsAgo: 28, assignedRepId: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'LD-1009', source: 'call_center', leadType: 'emergency_tarp', urgency: 'emergency',
|
id: 'LD-1009', source: 'call_center', leadType: 'emergency_tarp', urgency: 'emergency',
|
||||||
customer: { name: 'Carlos Rivera', phone: '(972) 555-1009' },
|
customer: { name: 'Carlos Rivera', phone: '(972) 555-1009', email: 'c.rivera@hotmail.com' },
|
||||||
property: { address: '3301 Avenue K', city: 'Plano', state: 'TX', zip: '75074', lat: 33.0198, lng: -96.6989 },
|
property: { address: '3301 Avenue K', city: 'Plano', state: 'TX', zip: '75074', lat: 33.0198, lng: -96.6989 },
|
||||||
|
callCenter: { id: 'CC-01', name: 'Plano Command Center' },
|
||||||
|
notes: 'ACTIVE LEAK — water through master bedroom ceiling. Family with newborn on premises. Must dispatch immediately regardless of weather conditions.',
|
||||||
estimatedDuration: 90, status: 'unassigned', arrivedMinsAgo: 3, assignedRepId: null,
|
estimatedDuration: 90, status: 'unassigned', arrivedMinsAgo: 3, assignedRepId: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'LD-1010', source: 'website_form', leadType: 'retail_estimate', urgency: 'standard',
|
id: 'LD-1010', source: 'website_form', leadType: 'retail_estimate', urgency: 'standard',
|
||||||
customer: { name: 'Patricia Davis', phone: '(972) 555-1010' },
|
customer: { name: 'Patricia Davis', phone: '(972) 555-1010', email: 'pdavis.home@gmail.com' },
|
||||||
property: { address: '6711 W Spring Creek Pkwy', city: 'Plano', state: 'TX', zip: '75024', lat: 33.0888, lng: -96.7756 },
|
property: { address: '6711 W Spring Creek Pkwy', city: 'Plano', state: 'TX', zip: '75024', lat: 33.0888, lng: -96.7756 },
|
||||||
|
webSource: { campaign: 'Facebook — Spring Roofing Promo' },
|
||||||
|
notes: 'At-risk of going cold — no response to last two callbacks. Rep assigned but contact not confirmed. Try alternate number if available.',
|
||||||
estimatedDuration: 30, status: 'at_risk', arrivedMinsAgo: 55, assignedRepId: 'REP-05',
|
estimatedDuration: 30, status: 'at_risk', arrivedMinsAgo: 55, assignedRepId: 'REP-05',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'LD-1011', source: 'canvassing', leadType: 'insurance_claim', urgency: 'high',
|
id: 'LD-1011', source: 'canvassing', leadType: 'insurance_claim', urgency: 'high',
|
||||||
customer: { name: 'Michael Anderson', phone: '(972) 555-1011' },
|
customer: { name: 'Michael Anderson', phone: '(972) 555-1011', email: 'michael.a@outlook.com' },
|
||||||
property: { address: '1800 Jupiter Rd', city: 'Plano', state: 'TX', zip: '75074', lat: 33.0267, lng: -96.7102 },
|
property: { address: '1800 Jupiter Rd', city: 'Plano', state: 'TX', zip: '75074', lat: 33.0267, lng: -96.7102 },
|
||||||
|
canvassedBy: { agentId: 'U-05', name: 'Marcus Webb' },
|
||||||
|
notes: 'Multiple hail impacts on canvass route. Customer receptive, confirmed hail event on property. Adjuster not yet contacted — walk them through the process.',
|
||||||
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 11, assignedRepId: null,
|
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 11, assignedRepId: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'LD-1012', source: 'referral', leadType: 'roof_inspection', urgency: 'standard',
|
id: 'LD-1012', source: 'referral', leadType: 'roof_inspection', urgency: 'standard',
|
||||||
customer: { name: 'Jennifer White', phone: '(972) 555-1012' },
|
customer: { name: 'Jennifer White', phone: '(972) 555-1012', email: 'jwhite.plano@gmail.com' },
|
||||||
property: { address: '4400 W Parker Rd', city: 'Plano', state: 'TX', zip: '75093', lat: 33.0612, lng: -96.8145 },
|
property: { address: '4400 W Parker Rd', city: 'Plano', state: 'TX', zip: '75093', lat: 33.0612, lng: -96.8145 },
|
||||||
|
referredBy: { type: 'client', id: 'CL-1178', name: 'Brenda Kowalski' },
|
||||||
|
notes: 'Confirmed appointment. Customer already has a competing estimate — come in strong on materials quality. GAF or Owens Corning preferred. Price-sensitive but quality-motivated.',
|
||||||
estimatedDuration: 45, status: 'confirmed', arrivedMinsAgo: 72, assignedRepId: 'REP-01',
|
estimatedDuration: 45, status: 'confirmed', arrivedMinsAgo: 72, assignedRepId: 'REP-01',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -2848,211 +2872,331 @@ export const DISPATCH_RECOMMENDATIONS = {
|
|||||||
'LD-1001': [
|
'LD-1001': [
|
||||||
{
|
{
|
||||||
repId: 'REP-04', score: 91, slotStart: '1:30 PM', slotEnd: '2:30 PM',
|
repId: 'REP-04', score: 91, slotStart: '1:30 PM', slotEnd: '2:30 PM',
|
||||||
|
distanceMi: '2.1',
|
||||||
factors: { proximity: 88, driveTime: 90, calendarFit: 95, weather: 90, skillMatch: 100 },
|
factors: { proximity: 88, driveTime: 90, calendarFit: 95, weather: 90, skillMatch: 100 },
|
||||||
reasons: ['Closest eligible rep — 2.1 mi', '12 min drive time estimated', 'Insurance claim certified', 'Buffer preserved before next appointment'],
|
reasons: ['Closest eligible rep — 2.1 mi', '12 min drive time estimated', 'Insurance claim certified', 'Buffer preserved before next appointment'],
|
||||||
stormReasons: ['Lightning risk before 2 PM — slot pushed to 1:30 PM', 'Rep carries emergency tarp kit'],
|
stormReasons: ['Lightning risk before 2 PM — slot pushed to 1:30 PM', 'Rep carries emergency tarp kit'],
|
||||||
|
aiInsight: 'Calendar and proximity align perfectly — earliest safe window before afternoon storm risk',
|
||||||
|
conflictFlag: null,
|
||||||
routePath: [[33.0516, -96.7512], [33.0612, -96.7484], [33.0708, -96.7456]],
|
routePath: [[33.0516, -96.7512], [33.0612, -96.7484], [33.0708, -96.7456]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
repId: 'REP-02', score: 79, slotStart: '3:00 PM', slotEnd: '4:00 PM',
|
repId: 'REP-02', score: 79, slotStart: '3:00 PM', slotEnd: '4:00 PM',
|
||||||
|
distanceMi: '3.8',
|
||||||
factors: { proximity: 72, driveTime: 75, calendarFit: 82, weather: 80, skillMatch: 85 },
|
factors: { proximity: 72, driveTime: 75, calendarFit: 82, weather: 80, skillMatch: 85 },
|
||||||
reasons: ['Available at 3 PM', '18 min drive — moderate traffic', 'Roof inspection certified'],
|
reasons: ['Available at 3 PM', '18 min drive — moderate traffic', 'Roof inspection certified'],
|
||||||
stormReasons: ['Afternoon slot carries weather risk — monitor closely'],
|
stormReasons: ['Afternoon slot carries weather risk — monitor closely'],
|
||||||
|
aiInsight: 'Solid backup with roof certification — afternoon slot viable but weather requires monitoring',
|
||||||
|
conflictFlag: 'Post-3 PM weather risk — confirm conditions before dispatch',
|
||||||
routePath: [[33.0812, -96.7234], [33.0760, -96.7345], [33.0708, -96.7456]],
|
routePath: [[33.0812, -96.7234], [33.0760, -96.7345], [33.0708, -96.7456]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
repId: 'REP-01', score: 71, slotStart: '4:30 PM', slotEnd: '5:30 PM',
|
repId: 'REP-01', score: 71, slotStart: '4:30 PM', slotEnd: '5:30 PM',
|
||||||
|
distanceMi: '5.2',
|
||||||
factors: { proximity: 62, driveTime: 60, calendarFit: 78, weather: 70, skillMatch: 100 },
|
factors: { proximity: 62, driveTime: 60, calendarFit: 78, weather: 70, skillMatch: 100 },
|
||||||
reasons: ['Available later in the day', '24 min drive from current location', 'High performer — score 94'],
|
reasons: ['Available later in the day', '24 min drive from current location', 'High performer — score 94'],
|
||||||
stormReasons: ['Late afternoon — weather conditions improving'],
|
stormReasons: ['Late afternoon — weather conditions improving'],
|
||||||
|
aiInsight: 'High performer but distance and late slot reduce overall optimality for this job type',
|
||||||
|
conflictFlag: '20 min buffer only after completion — schedule at risk if job runs long',
|
||||||
routePath: [[33.0612, -96.7891], [33.0660, -96.7674], [33.0708, -96.7456]],
|
routePath: [[33.0612, -96.7891], [33.0660, -96.7674], [33.0708, -96.7456]],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'LD-1002': [
|
'LD-1002': [
|
||||||
{
|
{
|
||||||
repId: 'REP-01', score: 88, slotStart: '2:00 PM', slotEnd: '2:45 PM',
|
repId: 'REP-01', score: 88, slotStart: '2:00 PM', slotEnd: '2:45 PM',
|
||||||
|
distanceMi: '1.4',
|
||||||
factors: { proximity: 92, driveTime: 88, calendarFit: 85, weather: 88, skillMatch: 100 },
|
factors: { proximity: 92, driveTime: 88, calendarFit: 85, weather: 88, skillMatch: 100 },
|
||||||
reasons: ['Closest rep — 1.4 mi from lead location', '8 min drive time', 'Buffer maintained after current job'],
|
reasons: ['Closest rep — 1.4 mi from lead location', '8 min drive time', 'Buffer maintained after current job'],
|
||||||
stormReasons: ['Weather clear until 3 PM — slot timing is safe', 'Rep familiar with West Plano territory'],
|
stormReasons: ['Weather clear until 3 PM — slot timing is safe', 'Rep familiar with West Plano territory'],
|
||||||
|
aiInsight: 'Nearest eligible rep with strong calendar fit — weather window closes cleanly after completion',
|
||||||
|
conflictFlag: null,
|
||||||
routePath: [[33.0612, -96.7891], [33.0632, -96.7956], [33.0652, -96.8021]],
|
routePath: [[33.0612, -96.7891], [33.0632, -96.7956], [33.0652, -96.8021]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
repId: 'REP-04', score: 74, slotStart: '3:30 PM', slotEnd: '4:15 PM',
|
repId: 'REP-04', score: 74, slotStart: '3:30 PM', slotEnd: '4:15 PM',
|
||||||
|
distanceMi: '4.1',
|
||||||
factors: { proximity: 70, driveTime: 68, calendarFit: 85, weather: 72, skillMatch: 100 },
|
factors: { proximity: 70, driveTime: 68, calendarFit: 85, weather: 72, skillMatch: 100 },
|
||||||
reasons: ['Available at 3:30 PM', '22 min drive from current position', 'Insurance claim certified'],
|
reasons: ['Available at 3:30 PM', '22 min drive from current position', 'Insurance claim certified'],
|
||||||
stormReasons: ['Afternoon slot — weather risk moderate after 3 PM'],
|
stormReasons: ['Afternoon slot — weather risk moderate after 3 PM'],
|
||||||
|
aiInsight: 'Certified fallback with open afternoon — drive time and weather reduce confidence vs. top pick',
|
||||||
|
conflictFlag: 'Afternoon weather window tight — start punctually or reschedule',
|
||||||
routePath: [[33.0516, -96.7512], [33.0584, -96.7767], [33.0652, -96.8021]],
|
routePath: [[33.0516, -96.7512], [33.0584, -96.7767], [33.0652, -96.8021]],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'LD-1003': [
|
'LD-1003': [
|
||||||
{
|
{
|
||||||
repId: 'REP-01', score: 87, slotStart: '11:00 AM', slotEnd: '11:45 AM',
|
repId: 'REP-01', score: 87, slotStart: '11:00 AM', slotEnd: '11:45 AM',
|
||||||
|
distanceMi: '2.8',
|
||||||
factors: { proximity: 85, driveTime: 88, calendarFit: 90, weather: 85, skillMatch: 100 },
|
factors: { proximity: 85, driveTime: 88, calendarFit: 90, weather: 85, skillMatch: 100 },
|
||||||
reasons: ['Currently assigned — optimal for continuity', '11 min drive from current stop', 'Insurance claim specialist'],
|
reasons: ['Currently assigned — optimal for continuity', '11 min drive from current stop', 'Insurance claim specialist'],
|
||||||
stormReasons: ['Morning slot — no weather concerns'],
|
stormReasons: ['Morning slot — no weather concerns'],
|
||||||
|
aiInsight: 'Continuity assignment maximizes close probability — same rep builds on prior customer rapport',
|
||||||
|
conflictFlag: null,
|
||||||
routePath: [[33.0612, -96.7891], [33.0517, -96.7515], [33.0421, -96.7138]],
|
routePath: [[33.0612, -96.7891], [33.0517, -96.7515], [33.0421, -96.7138]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
repId: 'REP-05', score: 67, slotStart: '1:00 PM', slotEnd: '1:45 PM',
|
repId: 'REP-05', score: 67, slotStart: '1:00 PM', slotEnd: '1:45 PM',
|
||||||
|
distanceMi: '4.4',
|
||||||
factors: { proximity: 65, driveTime: 62, calendarFit: 72, weather: 75, skillMatch: 60 },
|
factors: { proximity: 65, driveTime: 62, calendarFit: 72, weather: 75, skillMatch: 60 },
|
||||||
reasons: ['Available afternoon slot', '19 min drive', 'Less specialized — retail estimate primary skill'],
|
reasons: ['Available afternoon slot', '19 min drive', 'Less specialized — retail estimate primary skill'],
|
||||||
stormReasons: ['Afternoon — monitor weather after 1 PM'],
|
stormReasons: ['Afternoon — monitor weather after 1 PM'],
|
||||||
|
aiInsight: 'Capable fallback but skill mismatch lowers confidence for insurance claim type',
|
||||||
|
conflictFlag: 'Skill mismatch — retail primary, insurance secondary skill only',
|
||||||
routePath: [[33.0198, -96.7234], [33.0310, -96.7186], [33.0421, -96.7138]],
|
routePath: [[33.0198, -96.7234], [33.0310, -96.7186], [33.0421, -96.7138]],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'LD-1004': [
|
'LD-1004': [
|
||||||
{
|
{
|
||||||
repId: 'REP-03', score: 95, slotStart: '10:30 AM', slotEnd: '11:00 AM',
|
repId: 'REP-03', score: 95, slotStart: '10:30 AM', slotEnd: '11:00 AM',
|
||||||
|
distanceMi: '0.6',
|
||||||
factors: { proximity: 98, driveTime: 97, calendarFit: 88, weather: 95, skillMatch: 100 },
|
factors: { proximity: 98, driveTime: 97, calendarFit: 88, weather: 95, skillMatch: 100 },
|
||||||
reasons: ['0.6 mi away — optimal proximity', '4 min drive time', 'Retail estimate specialist'],
|
reasons: ['0.6 mi away — optimal proximity', '4 min drive time', 'Retail estimate specialist'],
|
||||||
stormReasons: ['Morning slot — conditions favorable'],
|
stormReasons: ['Morning slot — conditions favorable'],
|
||||||
|
aiInsight: 'Near-perfect assignment — virtually on-site with ideal skill set and favorable morning conditions',
|
||||||
|
conflictFlag: null,
|
||||||
routePath: [[33.0389, -96.7401], [33.0389, -96.7368], [33.0389, -96.7334]],
|
routePath: [[33.0389, -96.7401], [33.0389, -96.7368], [33.0389, -96.7334]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
repId: 'REP-05', score: 73, slotStart: '2:00 PM', slotEnd: '2:30 PM',
|
repId: 'REP-05', score: 73, slotStart: '2:00 PM', slotEnd: '2:30 PM',
|
||||||
|
distanceMi: '3.3',
|
||||||
factors: { proximity: 70, driveTime: 68, calendarFit: 80, weather: 72, skillMatch: 80 },
|
factors: { proximity: 70, driveTime: 68, calendarFit: 80, weather: 72, skillMatch: 80 },
|
||||||
reasons: ['Available afternoon', '16 min drive', 'Retail estimate capable'],
|
reasons: ['Available afternoon', '16 min drive', 'Retail estimate capable'],
|
||||||
stormReasons: ['Afternoon slot — watch post-2 PM weather'],
|
stormReasons: ['Afternoon slot — watch post-2 PM weather'],
|
||||||
|
aiInsight: 'Retail-capable with open afternoon — viable if top pick becomes unavailable',
|
||||||
|
conflictFlag: null,
|
||||||
routePath: [[33.0198, -96.7234], [33.0294, -96.7284], [33.0389, -96.7334]],
|
routePath: [[33.0198, -96.7234], [33.0294, -96.7284], [33.0389, -96.7334]],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'LD-1005': [
|
'LD-1005': [
|
||||||
{
|
{
|
||||||
repId: 'REP-02', score: 94, slotStart: '12:00 PM', slotEnd: '12:45 PM',
|
repId: 'REP-02', score: 94, slotStart: '12:00 PM', slotEnd: '12:45 PM',
|
||||||
|
distanceMi: '0.3',
|
||||||
factors: { proximity: 97, driveTime: 96, calendarFit: 90, weather: 92, skillMatch: 95 },
|
factors: { proximity: 97, driveTime: 96, calendarFit: 90, weather: 92, skillMatch: 95 },
|
||||||
reasons: ['0.3 mi from lead — walking distance', '3 min drive time', 'Roof inspection specialist', 'Low daily load — 2 of 6 slots used'],
|
reasons: ['0.3 mi from lead — effectively on-site', '3 min drive time', 'Roof inspection specialist', 'Low daily load — 2 of 6 slots used'],
|
||||||
stormReasons: ['Midday window — weather acceptable', 'Rep carries safety equipment'],
|
stormReasons: ['Midday window — weather acceptable', 'Rep carries safety equipment'],
|
||||||
|
aiInsight: 'Exceptional proximity with light workload — highest-confidence dispatch of the day',
|
||||||
|
conflictFlag: null,
|
||||||
routePath: [[33.0812, -96.7234], [33.0812, -96.7378], [33.0812, -96.7521]],
|
routePath: [[33.0812, -96.7234], [33.0812, -96.7378], [33.0812, -96.7521]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
repId: 'REP-04', score: 77, slotStart: '2:30 PM', slotEnd: '3:15 PM',
|
repId: 'REP-04', score: 77, slotStart: '2:30 PM', slotEnd: '3:15 PM',
|
||||||
|
distanceMi: '3.6',
|
||||||
factors: { proximity: 72, driveTime: 70, calendarFit: 88, weather: 78, skillMatch: 90 },
|
factors: { proximity: 72, driveTime: 70, calendarFit: 88, weather: 78, skillMatch: 90 },
|
||||||
reasons: ['Available at 2:30 PM', '17 min drive', 'Roof inspection certified'],
|
reasons: ['Available at 2:30 PM', '17 min drive', 'Roof inspection certified'],
|
||||||
stormReasons: ['Afternoon — post-3 PM weather worsening, start punctually'],
|
stormReasons: ['Afternoon — post-3 PM weather worsening, start punctually'],
|
||||||
|
aiInsight: 'Certified backup with clean afternoon window — must start on time before weather closes in',
|
||||||
|
conflictFlag: 'Start before 3 PM — weather deteriorates sharply after',
|
||||||
routePath: [[33.0516, -96.7512], [33.0664, -96.7517], [33.0812, -96.7521]],
|
routePath: [[33.0516, -96.7512], [33.0664, -96.7517], [33.0812, -96.7521]],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'LD-1006': [
|
'LD-1006': [
|
||||||
{
|
{
|
||||||
repId: 'REP-01', score: 83, slotStart: '3:00 PM', slotEnd: '4:00 PM',
|
repId: 'REP-01', score: 83, slotStart: '3:00 PM', slotEnd: '4:00 PM',
|
||||||
|
distanceMi: '3.1',
|
||||||
factors: { proximity: 80, driveTime: 78, calendarFit: 85, weather: 82, skillMatch: 100 },
|
factors: { proximity: 80, driveTime: 78, calendarFit: 85, weather: 82, skillMatch: 100 },
|
||||||
reasons: ['Best available slot — 3 PM', '14 min drive from current route', 'Insurance claim specialist'],
|
reasons: ['Best available slot — 3 PM', '14 min drive from current route', 'Insurance claim specialist'],
|
||||||
stormReasons: ['Wind advisory active — exterior inspection may need delay', 'Insurance claim can proceed indoors'],
|
stormReasons: ['Wind advisory active — exterior inspection may need delay', 'Insurance claim can proceed indoors'],
|
||||||
|
aiInsight: 'Best certified specialist available — indoor claim process can proceed despite active wind advisory',
|
||||||
|
conflictFlag: 'Wind advisory active — verify exterior conditions before starting roof portion',
|
||||||
routePath: [[33.0612, -96.7891], [33.0773, -96.7947], [33.0934, -96.8002]],
|
routePath: [[33.0612, -96.7891], [33.0773, -96.7947], [33.0934, -96.8002]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
repId: 'REP-02', score: 69, slotStart: '4:30 PM', slotEnd: '5:30 PM',
|
repId: 'REP-02', score: 69, slotStart: '4:30 PM', slotEnd: '5:30 PM',
|
||||||
|
distanceMi: '5.8',
|
||||||
factors: { proximity: 65, driveTime: 62, calendarFit: 75, weather: 68, skillMatch: 85 },
|
factors: { proximity: 65, driveTime: 62, calendarFit: 75, weather: 68, skillMatch: 85 },
|
||||||
reasons: ['Late afternoon availability', '26 min drive', 'Roof inspection skilled'],
|
reasons: ['Late afternoon availability', '26 min drive', 'Roof inspection skilled'],
|
||||||
stormReasons: ['Late afternoon — conditions deteriorating, not recommended'],
|
stormReasons: ['Late afternoon — conditions deteriorating, not recommended'],
|
||||||
|
aiInsight: 'Low-confidence option — weather and distance combine unfavorably for late afternoon exterior work',
|
||||||
|
conflictFlag: 'Not recommended — conditions deteriorating by 4:30 PM, daylight limited',
|
||||||
routePath: [[33.0812, -96.7234], [33.0873, -96.7618], [33.0934, -96.8002]],
|
routePath: [[33.0812, -96.7234], [33.0873, -96.7618], [33.0934, -96.8002]],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'LD-1007': [
|
'LD-1007': [
|
||||||
{
|
{
|
||||||
repId: 'REP-04', score: 87, slotStart: '11:30 AM', slotEnd: '12:00 PM',
|
repId: 'REP-04', score: 87, slotStart: '11:30 AM', slotEnd: '12:00 PM',
|
||||||
|
distanceMi: '1.5',
|
||||||
factors: { proximity: 90, driveTime: 88, calendarFit: 92, weather: 90, skillMatch: 80 },
|
factors: { proximity: 90, driveTime: 88, calendarFit: 92, weather: 90, skillMatch: 80 },
|
||||||
reasons: ['1.5 mi away — close proximity', '9 min drive time', 'Low daily load', 'Buffer before afternoon appointments'],
|
reasons: ['1.5 mi away — close proximity', '9 min drive time', 'Low daily load', 'Buffer before afternoon appointments'],
|
||||||
stormReasons: ['Morning window — safe conditions'],
|
stormReasons: ['Morning window — safe conditions'],
|
||||||
|
aiInsight: 'Light daily load with close proximity — midday window maximizes buffer for afternoon pipeline',
|
||||||
|
conflictFlag: null,
|
||||||
routePath: [[33.0516, -96.7512], [33.0516, -96.7401], [33.0516, -96.7289]],
|
routePath: [[33.0516, -96.7512], [33.0516, -96.7401], [33.0516, -96.7289]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
repId: 'REP-03', score: 78, slotStart: '1:30 PM', slotEnd: '2:00 PM',
|
repId: 'REP-03', score: 78, slotStart: '1:30 PM', slotEnd: '2:00 PM',
|
||||||
|
distanceMi: '2.9',
|
||||||
factors: { proximity: 78, driveTime: 75, calendarFit: 70, weather: 82, skillMatch: 100 },
|
factors: { proximity: 78, driveTime: 75, calendarFit: 70, weather: 82, skillMatch: 100 },
|
||||||
reasons: ['Retail estimate specialist', '12 min drive', 'Near current work site'],
|
reasons: ['Retail estimate specialist', '12 min drive', 'Near current work site'],
|
||||||
stormReasons: ['Early afternoon — conditions acceptable'],
|
stormReasons: ['Early afternoon — conditions acceptable'],
|
||||||
|
aiInsight: 'Specialist-level skill match but heavier daily load reduces calendar fit score',
|
||||||
|
conflictFlag: '5 appointments today — buffer is tight if any job runs over',
|
||||||
routePath: [[33.0389, -96.7401], [33.0453, -96.7345], [33.0516, -96.7289]],
|
routePath: [[33.0389, -96.7401], [33.0453, -96.7345], [33.0516, -96.7289]],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'LD-1008': [
|
'LD-1008': [
|
||||||
{
|
{
|
||||||
repId: 'REP-04', score: 86, slotStart: '2:00 PM', slotEnd: '2:45 PM',
|
repId: 'REP-04', score: 86, slotStart: '2:00 PM', slotEnd: '2:45 PM',
|
||||||
|
distanceMi: '2.7',
|
||||||
factors: { proximity: 84, driveTime: 85, calendarFit: 90, weather: 85, skillMatch: 95 },
|
factors: { proximity: 84, driveTime: 85, calendarFit: 90, weather: 85, skillMatch: 95 },
|
||||||
reasons: ['2.7 mi from lead — manageable drive', '15 min estimated travel', 'Roof inspection certified', 'Open afternoon slot'],
|
reasons: ['2.7 mi from lead — manageable drive', '15 min estimated travel', 'Roof inspection certified', 'Open afternoon slot'],
|
||||||
stormReasons: ['Weather acceptable until 3:30 PM', 'Book for 2 PM to maximize safe window'],
|
stormReasons: ['Weather acceptable until 3:30 PM', 'Book for 2 PM to maximize safe window'],
|
||||||
|
aiInsight: 'Certified for roof inspection with open slot — 2 PM booking maximizes safe weather window',
|
||||||
|
conflictFlag: null,
|
||||||
routePath: [[33.0516, -96.7512], [33.0630, -96.7562], [33.0744, -96.7612]],
|
routePath: [[33.0516, -96.7512], [33.0630, -96.7562], [33.0744, -96.7612]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
repId: 'REP-02', score: 75, slotStart: '3:30 PM', slotEnd: '4:15 PM',
|
repId: 'REP-02', score: 75, slotStart: '3:30 PM', slotEnd: '4:15 PM',
|
||||||
|
distanceMi: '4.1',
|
||||||
factors: { proximity: 72, driveTime: 70, calendarFit: 82, weather: 74, skillMatch: 90 },
|
factors: { proximity: 72, driveTime: 70, calendarFit: 82, weather: 74, skillMatch: 90 },
|
||||||
reasons: ['Available at 3:30 PM', '19 min drive', 'Roof inspection specialist'],
|
reasons: ['Available at 3:30 PM', '19 min drive', 'Roof inspection specialist'],
|
||||||
stormReasons: ['Late afternoon — weather risk elevated after 3 PM'],
|
stormReasons: ['Late afternoon — weather risk elevated after 3 PM'],
|
||||||
|
aiInsight: 'Roof specialist with afternoon availability — weather risk increases as slot approaches 4 PM',
|
||||||
|
conflictFlag: '15 min buffer after completion — schedule may be tight if delayed',
|
||||||
routePath: [[33.0812, -96.7234], [33.0778, -96.7423], [33.0744, -96.7612]],
|
routePath: [[33.0812, -96.7234], [33.0778, -96.7423], [33.0744, -96.7612]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
repId: 'REP-01', score: 68, slotStart: '5:00 PM', slotEnd: '5:45 PM',
|
repId: 'REP-01', score: 68, slotStart: '5:00 PM', slotEnd: '5:45 PM',
|
||||||
|
distanceMi: '5.5',
|
||||||
factors: { proximity: 62, driveTime: 58, calendarFit: 70, weather: 68, skillMatch: 80 },
|
factors: { proximity: 62, driveTime: 58, calendarFit: 70, weather: 68, skillMatch: 80 },
|
||||||
reasons: ['End of day slot only', '22 min drive', 'High performer — score 94'],
|
reasons: ['End of day slot only', '22 min drive', 'High performer — score 94'],
|
||||||
stormReasons: ['Evening — conditions improving but daylight limited'],
|
stormReasons: ['Evening — conditions improving but daylight limited'],
|
||||||
|
aiInsight: 'High performer but 5 PM slot limits daylight for roof work — use only as last resort',
|
||||||
|
conflictFlag: 'Daylight limited at 5 PM — roof inspection may be incomplete',
|
||||||
routePath: [[33.0612, -96.7891], [33.0678, -96.7752], [33.0744, -96.7612]],
|
routePath: [[33.0612, -96.7891], [33.0678, -96.7752], [33.0744, -96.7612]],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'LD-1009': [
|
'LD-1009': [
|
||||||
{
|
{
|
||||||
repId: 'REP-05', score: 97, slotStart: '11:00 AM', slotEnd: '12:30 PM',
|
repId: 'REP-05', score: 97, slotStart: '11:00 AM', slotEnd: '12:30 PM',
|
||||||
|
distanceMi: '0.8',
|
||||||
factors: { proximity: 99, driveTime: 98, calendarFit: 95, weather: 95, skillMatch: 90 },
|
factors: { proximity: 99, driveTime: 98, calendarFit: 95, weather: 95, skillMatch: 90 },
|
||||||
reasons: ['0.8 mi away — nearest rep on site', '5 min drive time', 'Emergency certified', 'PRIORITY: emergency tarp needed'],
|
reasons: ['0.8 mi away — nearest rep on site', '5 min drive time', 'Emergency certified', 'PRIORITY: emergency tarp needed'],
|
||||||
stormReasons: ['EMERGENCY — dispatch immediately', 'Rep stocked with emergency tarp materials'],
|
stormReasons: ['EMERGENCY — dispatch immediately', 'Rep stocked with emergency tarp materials'],
|
||||||
|
aiInsight: 'PRIORITY dispatch — nearest certified rep with emergency materials on hand, 5 min ETA',
|
||||||
|
conflictFlag: null,
|
||||||
routePath: [[33.0198, -96.7234], [33.0198, -96.7112], [33.0198, -96.6989]],
|
routePath: [[33.0198, -96.7234], [33.0198, -96.7112], [33.0198, -96.6989]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
repId: 'REP-03', score: 71, slotStart: '1:00 PM', slotEnd: '2:30 PM',
|
repId: 'REP-03', score: 71, slotStart: '1:00 PM', slotEnd: '2:30 PM',
|
||||||
|
distanceMi: '4.6',
|
||||||
factors: { proximity: 65, driveTime: 62, calendarFit: 75, weather: 72, skillMatch: 75 },
|
factors: { proximity: 65, driveTime: 62, calendarFit: 75, weather: 72, skillMatch: 75 },
|
||||||
reasons: ['Backup option — 5 appointments today', '21 min drive', 'Roof inspection capable'],
|
reasons: ['Backup option — 5 appointments today', '21 min drive', 'Roof inspection capable'],
|
||||||
stormReasons: ['Secondary option — use only if REP-05 unavailable'],
|
stormReasons: ['Secondary option — use only if REP-05 unavailable'],
|
||||||
|
aiInsight: 'Emergency backup only — heavier daily load and longer drive make this a secondary option',
|
||||||
|
conflictFlag: '5 appointments today — minimal buffer if any prior job overruns',
|
||||||
routePath: [[33.0389, -96.7401], [33.0294, -96.7195], [33.0198, -96.6989]],
|
routePath: [[33.0389, -96.7401], [33.0294, -96.7195], [33.0198, -96.6989]],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'LD-1010': [
|
'LD-1010': [
|
||||||
{
|
{
|
||||||
repId: 'REP-01', score: 84, slotStart: '2:30 PM', slotEnd: '3:00 PM',
|
repId: 'REP-01', score: 84, slotStart: '2:30 PM', slotEnd: '3:00 PM',
|
||||||
|
distanceMi: '2.9',
|
||||||
factors: { proximity: 82, driveTime: 80, calendarFit: 88, weather: 84, skillMatch: 80 },
|
factors: { proximity: 82, driveTime: 80, calendarFit: 88, weather: 84, skillMatch: 80 },
|
||||||
reasons: ['Best proximity match — 2.9 mi', '16 min drive', 'High performer — score 94', 'Manageable daily load'],
|
reasons: ['Best proximity match — 2.9 mi', '16 min drive', 'High performer — score 94', 'Manageable daily load'],
|
||||||
stormReasons: ['Afternoon slot — monitor weather', 'Retail estimate can proceed partially indoors'],
|
stormReasons: ['Afternoon slot — monitor weather', 'Retail estimate can proceed partially indoors'],
|
||||||
|
aiInsight: 'Top performer with manageable load — retail estimate can proceed indoors if afternoon weather shifts',
|
||||||
|
conflictFlag: null,
|
||||||
routePath: [[33.0612, -96.7891], [33.0750, -96.7824], [33.0888, -96.7756]],
|
routePath: [[33.0612, -96.7891], [33.0750, -96.7824], [33.0888, -96.7756]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
repId: 'REP-02', score: 72, slotStart: '4:00 PM', slotEnd: '4:30 PM',
|
repId: 'REP-02', score: 72, slotStart: '4:00 PM', slotEnd: '4:30 PM',
|
||||||
|
distanceMi: '4.8',
|
||||||
factors: { proximity: 70, driveTime: 68, calendarFit: 78, weather: 70, skillMatch: 80 },
|
factors: { proximity: 70, driveTime: 68, calendarFit: 78, weather: 70, skillMatch: 80 },
|
||||||
reasons: ['Available late afternoon', '22 min drive', 'Roof inspection certified'],
|
reasons: ['Available late afternoon', '22 min drive', 'Roof inspection certified'],
|
||||||
stormReasons: ['4 PM slot — weather conditions deteriorating, not ideal'],
|
stormReasons: ['4 PM slot — weather conditions deteriorating, not ideal'],
|
||||||
|
aiInsight: 'Viable backup but 4 PM timing and weather trend reduce confidence for exterior estimate',
|
||||||
|
conflictFlag: '4 PM slot — low daylight and deteriorating conditions for exterior work',
|
||||||
routePath: [[33.0812, -96.7234], [33.0850, -96.7495], [33.0888, -96.7756]],
|
routePath: [[33.0812, -96.7234], [33.0850, -96.7495], [33.0888, -96.7756]],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'LD-1011': [
|
'LD-1011': [
|
||||||
{
|
{
|
||||||
repId: 'REP-05', score: 89, slotStart: '12:30 PM', slotEnd: '1:30 PM',
|
repId: 'REP-05', score: 89, slotStart: '12:30 PM', slotEnd: '1:30 PM',
|
||||||
|
distanceMi: '1.6',
|
||||||
factors: { proximity: 90, driveTime: 88, calendarFit: 85, weather: 88, skillMatch: 90 },
|
factors: { proximity: 90, driveTime: 88, calendarFit: 85, weather: 88, skillMatch: 90 },
|
||||||
reasons: ['Closest rep — 1.6 mi', '9 min drive time', 'Insurance claim proficient', 'Midday slot timing is optimal'],
|
reasons: ['Closest rep — 1.6 mi', '9 min drive time', 'Insurance claim proficient', 'Midday slot timing is optimal'],
|
||||||
stormReasons: ['Midday window — acceptable conditions', 'Insurance claim can be initiated outdoors then moved inside'],
|
stormReasons: ['Midday window — acceptable conditions', 'Insurance claim can be initiated outdoors then moved inside'],
|
||||||
|
aiInsight: 'Close proximity with optimal midday timing — insurance initiation can flex indoors if needed',
|
||||||
|
conflictFlag: null,
|
||||||
routePath: [[33.0198, -96.7234], [33.0233, -96.7168], [33.0267, -96.7102]],
|
routePath: [[33.0198, -96.7234], [33.0233, -96.7168], [33.0267, -96.7102]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
repId: 'REP-03', score: 76, slotStart: '2:00 PM', slotEnd: '3:00 PM',
|
repId: 'REP-03', score: 76, slotStart: '2:00 PM', slotEnd: '3:00 PM',
|
||||||
|
distanceMi: '3.5',
|
||||||
factors: { proximity: 74, driveTime: 70, calendarFit: 78, weather: 76, skillMatch: 80 },
|
factors: { proximity: 74, driveTime: 70, calendarFit: 78, weather: 76, skillMatch: 80 },
|
||||||
reasons: ['Available at 2 PM', '18 min drive', 'Retail estimate skilled'],
|
reasons: ['Available at 2 PM', '18 min drive', 'Retail estimate skilled'],
|
||||||
stormReasons: ['Afternoon slot — conditions marginal after 2:30 PM'],
|
stormReasons: ['Afternoon slot — conditions marginal after 2:30 PM'],
|
||||||
|
aiInsight: 'Partial skill match with afternoon availability — conditions deteriorate mid-appointment window',
|
||||||
|
conflictFlag: 'Skill alignment moderate — retail primary, insurance secondary only',
|
||||||
routePath: [[33.0389, -96.7401], [33.0328, -96.7252], [33.0267, -96.7102]],
|
routePath: [[33.0389, -96.7401], [33.0328, -96.7252], [33.0267, -96.7102]],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'LD-1012': [
|
'LD-1012': [
|
||||||
{
|
{
|
||||||
repId: 'REP-01', score: 92, slotStart: '10:00 AM', slotEnd: '10:45 AM',
|
repId: 'REP-01', score: 92, slotStart: '10:00 AM', slotEnd: '10:45 AM',
|
||||||
|
distanceMi: '1.7',
|
||||||
factors: { proximity: 94, driveTime: 92, calendarFit: 88, weather: 92, skillMatch: 95 },
|
factors: { proximity: 94, driveTime: 92, calendarFit: 88, weather: 92, skillMatch: 95 },
|
||||||
reasons: ['Currently assigned — optimal', '1.7 mi from current position', 'Morning slot — ideal weather window', 'Insurance + retail estimate certified'],
|
reasons: ['Currently assigned — optimal', '1.7 mi from current position', 'Morning slot — ideal weather window', 'Insurance + retail estimate certified'],
|
||||||
stormReasons: ['Morning slot — safe window before any afternoon weather'],
|
stormReasons: ['Morning slot — safe window before any afternoon weather'],
|
||||||
|
aiInsight: 'Morning assignment with ideal conditions — dual-certified rep maximizes flexibility for this job type',
|
||||||
|
conflictFlag: null,
|
||||||
routePath: [[33.0612, -96.7891], [33.0612, -96.8018], [33.0612, -96.8145]],
|
routePath: [[33.0612, -96.7891], [33.0612, -96.8018], [33.0612, -96.8145]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
repId: 'REP-04', score: 70, slotStart: '1:00 PM', slotEnd: '1:45 PM',
|
repId: 'REP-04', score: 70, slotStart: '1:00 PM', slotEnd: '1:45 PM',
|
||||||
|
distanceMi: '5.3',
|
||||||
factors: { proximity: 65, driveTime: 62, calendarFit: 82, weather: 70, skillMatch: 85 },
|
factors: { proximity: 65, driveTime: 62, calendarFit: 82, weather: 70, skillMatch: 85 },
|
||||||
reasons: ['Afternoon availability', '24 min drive from current location', 'Roof inspection certified'],
|
reasons: ['Afternoon availability', '24 min drive from current location', 'Roof inspection certified'],
|
||||||
stormReasons: ['Afternoon — conditions acceptable but watch post-2 PM'],
|
stormReasons: ['Afternoon — conditions acceptable but watch post-2 PM'],
|
||||||
|
aiInsight: 'Longer drive reduces score significantly — afternoon window is viable if morning rep unavailable',
|
||||||
|
conflictFlag: '24 min drive cuts into slot buffer — delays cascade to next appointment',
|
||||||
routePath: [[33.0516, -96.7512], [33.0564, -96.7829], [33.0612, -96.8145]],
|
routePath: [[33.0516, -96.7512], [33.0564, -96.7829], [33.0612, -96.8145]],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Weekly performance data — Mon Mar 20 → Sun Mar 26 (current week)
|
||||||
|
// repBreakdown keys match DISPATCH_REPS ids
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const DISPATCH_WEEKLY_STATS = [
|
||||||
|
{
|
||||||
|
day: 'Mon', date: 'Mar 20', dispatched: 7, avgWait: 18, emergency: 0,
|
||||||
|
stormDay: false, isToday: false,
|
||||||
|
repBreakdown: { 'REP-01': 2, 'REP-02': 2, 'REP-03': 1, 'REP-04': 1, 'REP-05': 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: 'Tue', date: 'Mar 21', dispatched: 9, avgWait: 14, emergency: 1,
|
||||||
|
stormDay: false, isToday: false,
|
||||||
|
repBreakdown: { 'REP-01': 2, 'REP-02': 2, 'REP-03': 2, 'REP-04': 2, 'REP-05': 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: 'Wed', date: 'Mar 22', dispatched: 6, avgWait: 22, emergency: 0,
|
||||||
|
stormDay: false, isToday: false,
|
||||||
|
repBreakdown: { 'REP-01': 1, 'REP-02': 2, 'REP-03': 1, 'REP-04': 1, 'REP-05': 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: 'Thu', date: 'Mar 23', dispatched: 14, avgWait: 9, emergency: 4,
|
||||||
|
stormDay: true, isToday: false,
|
||||||
|
repBreakdown: { 'REP-01': 3, 'REP-02': 3, 'REP-03': 3, 'REP-04': 3, 'REP-05': 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: 'Fri', date: 'Mar 24', dispatched: 10, avgWait: 13, emergency: 2,
|
||||||
|
stormDay: false, isToday: false,
|
||||||
|
repBreakdown: { 'REP-01': 2, 'REP-02': 2, 'REP-03': 2, 'REP-04': 2, 'REP-05': 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: 'Sat', date: 'Mar 25', dispatched: 5, avgWait: 20, emergency: 0,
|
||||||
|
stormDay: false, isToday: false,
|
||||||
|
repBreakdown: { 'REP-01': 1, 'REP-02': 1, 'REP-03': 1, 'REP-04': 1, 'REP-05': 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
day: 'Sun', date: 'Mar 26', dispatched: 3, avgWait: 24, emergency: 0,
|
||||||
|
stormDay: false, isToday: true,
|
||||||
|
repBreakdown: { 'REP-01': 1, 'REP-02': 1, 'REP-03': 0, 'REP-04': 1, 'REP-05': 0 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// --- CONTEXT SETUP ---
|
// --- CONTEXT SETUP ---
|
||||||
|
|
||||||
const MockStoreContext = createContext();
|
const MockStoreContext = createContext();
|
||||||
@@ -3180,6 +3324,11 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
l.id === leadId ? { ...l, assignedRepId: repId, status: 'confirmed' } : l
|
l.id === leadId ? { ...l, assignedRepId: repId, status: 'confirmed' } : l
|
||||||
));
|
));
|
||||||
},
|
},
|
||||||
|
updateLeadNotes: (leadId, notes) => {
|
||||||
|
setDispatchLeads(prev => prev.map(l =>
|
||||||
|
l.id === leadId ? { ...l, notes } : l
|
||||||
|
));
|
||||||
|
},
|
||||||
|
|
||||||
updatePropertyStatus,
|
updatePropertyStatus,
|
||||||
addMeeting,
|
addMeeting,
|
||||||
|
|||||||
+281
-73
@@ -1,16 +1,24 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Zap, CloudLightning, Radio, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown } from 'lucide-react';
|
import { Zap, CloudLightning, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown, History, BarChart2 } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useMockStore, DISPATCH_REPS } from '../data/mockStore';
|
import { useMockStore, DISPATCH_REPS } from '../data/mockStore';
|
||||||
import DispatchLeadQueue from '../components/dispatch/DispatchLeadQueue';
|
import DispatchLeadQueue from '../components/dispatch/DispatchLeadQueue';
|
||||||
import DispatchRecommendationDrawer from '../components/dispatch/DispatchRecommendationDrawer';
|
import DispatchRecommendationDrawer from '../components/dispatch/DispatchRecommendationDrawer';
|
||||||
|
import DispatchMapPanel from '../components/dispatch/DispatchMapPanel';
|
||||||
|
import LeadQuickViewModal from '../components/dispatch/LeadQuickViewModal';
|
||||||
|
import DispatchLogDrawer from '../components/dispatch/DispatchLogDrawer';
|
||||||
|
import DispatchChartModal from '../components/dispatch/DispatchChartModal';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Panel — themed container
|
// Panel — themed container
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const Panel = ({ children, className = '', style }) => (
|
const Panel = ({ children, className = '', style, stormMode }) => (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col rounded-2xl border overflow-hidden bg-white border-zinc-200 shadow-sm dark:bg-zinc-900/60 dark:border-white/[0.06] ${className}`}
|
className={`flex flex-col rounded-2xl border overflow-hidden bg-white shadow-sm dark:bg-zinc-900/60 transition-colors duration-500 ${
|
||||||
|
stormMode
|
||||||
|
? 'border-amber-200 dark:border-amber-500/20'
|
||||||
|
: 'border-zinc-200 dark:border-white/[0.06]'
|
||||||
|
} ${className}`}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -130,9 +138,22 @@ const CollapsePanel = ({ open, children }) => (
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const LynkDispatchPage = () => {
|
const LynkDispatchPage = () => {
|
||||||
const { dispatchLeads, assignDispatchLead } = useMockStore();
|
const { dispatchLeads, assignDispatchLead } = useMockStore();
|
||||||
|
const [viewDetailLead, setViewDetailLead] = useState(null);
|
||||||
|
const [dispatchLog, setDispatchLog] = useState([]);
|
||||||
|
|
||||||
const [selectedLead, setSelectedLead] = useState(null);
|
const [selectedLead, setSelectedLead] = useState(null);
|
||||||
const [stormMode, setStormMode] = useState(false);
|
const [stormMode, setStormMode] = useState(false);
|
||||||
|
const [logDrawerOpen, setLogDrawerOpen] = useState(false);
|
||||||
|
const [chartModalOpen, setChartModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Rep status live cycling — overrides static DISPATCH_REPS.status
|
||||||
|
const [repStatuses, setRepStatuses] = useState(
|
||||||
|
() => Object.fromEntries(DISPATCH_REPS.map(r => [r.id, r.status]))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dispatch efficiency tracking — seeded with today's prior activity
|
||||||
|
const [aiCount, setAiCount] = useState(8);
|
||||||
|
const [totalCount, setTotalCount] = useState(9);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [leftWidth, setLeftWidth] = useState(300);
|
const [leftWidth, setLeftWidth] = useState(300);
|
||||||
const [rightWidth, setRightWidth] = useState(320);
|
const [rightWidth, setRightWidth] = useState(320);
|
||||||
@@ -173,19 +194,71 @@ const LynkDispatchPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssign = useCallback((leadId, repId) => {
|
const handleAssign = useCallback((leadId, repId, isManual = false) => {
|
||||||
assignDispatchLead(leadId, repId);
|
assignDispatchLead(leadId, repId);
|
||||||
}, [assignDispatchLead]);
|
|
||||||
|
// Cycle rep status: en_route for 60s, then back to available
|
||||||
|
setRepStatuses(prev => ({ ...prev, [repId]: 'en_route' }));
|
||||||
|
setTimeout(() => {
|
||||||
|
setRepStatuses(prev => ({ ...prev, [repId]: 'available' }));
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
// Track AI vs manual efficiency
|
||||||
|
setTotalCount(n => n + 1);
|
||||||
|
if (!isManual) setAiCount(n => n + 1);
|
||||||
|
|
||||||
|
const lead = dispatchLeads.find(l => l.id === leadId);
|
||||||
|
const rep = DISPATCH_REPS.find(r => r.id === repId);
|
||||||
|
if (lead && rep) {
|
||||||
|
setDispatchLog(prev => [{
|
||||||
|
id: Date.now(),
|
||||||
|
repFirstName: rep.name.split(' ')[0],
|
||||||
|
repInitials: rep.initials,
|
||||||
|
repStatus: 'en_route',
|
||||||
|
customerName: lead.customer.name,
|
||||||
|
address: `${lead.property.address}, ${lead.property.city}`,
|
||||||
|
leadType: lead.leadType,
|
||||||
|
urgency: lead.urgency,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}, ...prev].slice(0, 20));
|
||||||
|
}
|
||||||
|
}, [assignDispatchLead, dispatchLeads]);
|
||||||
|
|
||||||
const handleDismissAfterAssign = useCallback(() => {
|
const handleDismissAfterAssign = useCallback(() => {
|
||||||
setSelectedLead(null);
|
setSelectedLead(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const accent = stormMode ? '#F59E0B' : '#3B82F6';
|
const accent = stormMode ? '#F59E0B' : '#3B82F6';
|
||||||
const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
|
|
||||||
|
// Merge live statuses into rep objects — passed to map + AI drawer
|
||||||
|
const effectiveReps = DISPATCH_REPS.map(r => ({
|
||||||
|
...r,
|
||||||
|
status: repStatuses[r.id] ?? r.status,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Efficiency score — what % of dispatches were AI-recommended (not manual override)
|
||||||
|
const efficiencyPct = totalCount > 0 ? Math.round((aiCount / totalCount) * 100) : 0;
|
||||||
|
|
||||||
|
// ── Live KPI derivations ──
|
||||||
|
const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
|
||||||
|
const assignedToday = dispatchLeads.filter(l => ['assigned', 'confirmed'].includes(l.status)).length;
|
||||||
|
const atRiskCount = dispatchLeads.filter(l => l.status === 'at_risk').length;
|
||||||
|
const emergencyCount = dispatchLeads.filter(l => l.urgency === 'emergency').length;
|
||||||
|
const alertCount = stormMode ? emergencyCount + atRiskCount : atRiskCount;
|
||||||
|
|
||||||
|
const avgWait = (() => {
|
||||||
|
const pending = dispatchLeads.filter(l => l.status === 'unassigned');
|
||||||
|
if (!pending.length) return '—';
|
||||||
|
const avg = Math.round(
|
||||||
|
pending.reduce((sum, l) => sum + (l.arrivedMinsAgo || 0), 0) / pending.length
|
||||||
|
);
|
||||||
|
return `${avg} min`;
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col bg-zinc-50 dark:bg-[#09090b] ${stormMode ? 'storm-mode' : ''}`}>
|
<div className={`flex flex-col transition-colors duration-500 ${
|
||||||
|
stormMode ? 'bg-amber-50 dark:bg-[#09090b] storm-mode' : 'bg-zinc-50 dark:bg-[#09090b]'
|
||||||
|
}`}>
|
||||||
|
|
||||||
{/* Storm Banner */}
|
{/* Storm Banner */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
@@ -220,15 +293,118 @@ const LynkDispatchPage = () => {
|
|||||||
<h1 className="text-lg font-black uppercase tracking-tight leading-none text-zinc-900 dark:text-white font-[Barlow_Condensed,sans-serif]">
|
<h1 className="text-lg font-black uppercase tracking-tight leading-none text-zinc-900 dark:text-white font-[Barlow_Condensed,sans-serif]">
|
||||||
LynkDispatch
|
LynkDispatch
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[11px] text-zinc-400 tracking-wide">AI Dispatch Engine</p>
|
<div className="flex items-center gap-2">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{stormMode ? (
|
||||||
|
<motion.p
|
||||||
|
key="storm-sub"
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -4 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="text-[11px] font-bold tracking-wide text-amber-500"
|
||||||
|
>
|
||||||
|
Storm Protocol Active
|
||||||
|
</motion.p>
|
||||||
|
) : (
|
||||||
|
<motion.p
|
||||||
|
key="normal-sub"
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -4 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="text-[11px] text-zinc-400 tracking-wide"
|
||||||
|
>
|
||||||
|
AI Dispatch Engine
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<AnimatePresence>
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<motion.span
|
||||||
|
key="efficiency"
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
title={`${efficiencyPct}% of dispatches used AI recommendation — ${100 - efficiencyPct}% were manual overrides`}
|
||||||
|
className="flex items-center gap-1 text-[10px] font-black cursor-default"
|
||||||
|
style={{ color: stormMode ? '#F59E0B' : '#10B981' }}
|
||||||
|
>
|
||||||
|
<span className="w-1 h-1 rounded-full" style={{ backgroundColor: stormMode ? '#F59E0B' : '#10B981' }} />
|
||||||
|
{efficiencyPct}% AI-Dispatched
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
{/* LIVE badge */}
|
{/* LIVE / STORM ACTIVE badge */}
|
||||||
<div className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20">
|
<AnimatePresence mode="wait">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
{stormMode ? (
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-emerald-600 dark:text-emerald-400">Live</span>
|
<motion.div
|
||||||
|
key="storm-badge"
|
||||||
|
initial={{ opacity: 0, scale: 0.88 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.88 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-amber-50 dark:bg-amber-500/10 border border-amber-300 dark:border-amber-500/30"
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-amber-600 dark:text-amber-400">Storm Active</span>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="live-badge"
|
||||||
|
initial={{ opacity: 0, scale: 0.88 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.88 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20"
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-emerald-600 dark:text-emerald-400">Live</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Log + Chart buttons */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setLogDrawerOpen(true)}
|
||||||
|
title="Dispatch Log"
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs font-bold uppercase tracking-wider border transition-all duration-200 ${
|
||||||
|
stormMode
|
||||||
|
? 'bg-amber-500/10 border-amber-400/40 text-amber-600 dark:text-amber-400'
|
||||||
|
: 'bg-zinc-100 border-zinc-200 text-zinc-500 dark:bg-zinc-800/60 dark:border-white/10 dark:text-zinc-400 hover:border-zinc-300 dark:hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<History size={13} />
|
||||||
|
<span className="hidden sm:inline">Log</span>
|
||||||
|
{dispatchLog.length > 0 && (
|
||||||
|
<span
|
||||||
|
className="w-4 h-4 rounded-full text-[9px] font-black flex items-center justify-center text-white"
|
||||||
|
style={{ backgroundColor: accent }}
|
||||||
|
>
|
||||||
|
{dispatchLog.length > 9 ? '9+' : dispatchLog.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setChartModalOpen(true)}
|
||||||
|
title="Weekly Performance"
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs font-bold uppercase tracking-wider border transition-all duration-200 ${
|
||||||
|
stormMode
|
||||||
|
? 'bg-amber-500/10 border-amber-400/40 text-amber-600 dark:text-amber-400'
|
||||||
|
: 'bg-zinc-100 border-zinc-200 text-zinc-500 dark:bg-zinc-800/60 dark:border-white/10 dark:text-zinc-400 hover:border-zinc-300 dark:hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<BarChart2 size={13} />
|
||||||
|
<span className="hidden sm:inline">Stats</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Storm Mode Toggle */}
|
{/* Storm Mode Toggle */}
|
||||||
@@ -256,27 +432,39 @@ const LynkDispatchPage = () => {
|
|||||||
<Panel
|
<Panel
|
||||||
className="w-full xl:overflow-hidden xl:shrink-0"
|
className="w-full xl:overflow-hidden xl:shrink-0"
|
||||||
style={isDesktop ? { width: leftWidth } : undefined}
|
style={isDesktop ? { width: leftWidth } : undefined}
|
||||||
|
stormMode={stormMode}
|
||||||
>
|
>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
title="Lead Queue"
|
title="Lead Queue"
|
||||||
subtitle="Live · auto-updates every 20s"
|
subtitle={stormMode ? 'Storm Protocol — Emergency leads prioritized' : 'Live · auto-updates every 20s'}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
collapsible={!isDesktop}
|
collapsible={!isDesktop}
|
||||||
isCollapsed={collapsed.queue}
|
isCollapsed={collapsed.queue}
|
||||||
onToggle={() => toggle('queue')}
|
onToggle={() => toggle('queue')}
|
||||||
right={
|
right={
|
||||||
<span
|
stormMode && emergencyCount > 0 ? (
|
||||||
className="text-xs font-bold px-2 py-0.5 rounded-full"
|
<span
|
||||||
style={{ backgroundColor: `${accent}18`, color: accent }}
|
className="flex items-center gap-1 text-xs font-bold px-2 py-0.5 rounded-full animate-pulse"
|
||||||
>
|
style={{ backgroundColor: `${accent}20`, color: accent }}
|
||||||
{dispatchLeads.length}+
|
>
|
||||||
</span>
|
<AlertTriangle size={10} />
|
||||||
|
{emergencyCount} critical
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="text-xs font-bold px-2 py-0.5 rounded-full"
|
||||||
|
style={{ backgroundColor: `${accent}18`, color: accent }}
|
||||||
|
>
|
||||||
|
{dispatchLeads.length}+
|
||||||
|
</span>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{isDesktop ? (
|
{isDesktop ? (
|
||||||
<DispatchLeadQueue
|
<DispatchLeadQueue
|
||||||
selectedLead={selectedLead}
|
selectedLead={selectedLead}
|
||||||
onSelectLead={handleSelectLead}
|
onSelectLead={handleSelectLead}
|
||||||
|
onViewDetails={setViewDetailLead}
|
||||||
stormMode={stormMode}
|
stormMode={stormMode}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
/>
|
/>
|
||||||
@@ -285,6 +473,8 @@ const LynkDispatchPage = () => {
|
|||||||
<DispatchLeadQueue
|
<DispatchLeadQueue
|
||||||
selectedLead={selectedLead}
|
selectedLead={selectedLead}
|
||||||
onSelectLead={handleSelectLead}
|
onSelectLead={handleSelectLead}
|
||||||
|
onViewDetails={setViewDetailLead}
|
||||||
|
onQuickAssign={handleAssign}
|
||||||
stormMode={stormMode}
|
stormMode={stormMode}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
/>
|
/>
|
||||||
@@ -295,34 +485,44 @@ const LynkDispatchPage = () => {
|
|||||||
<ResizableHandle onDrag={dragLeft} />
|
<ResizableHandle onDrag={dragLeft} />
|
||||||
|
|
||||||
{/* CENTER — Territory Map */}
|
{/* CENTER — Territory Map */}
|
||||||
<Panel className="w-full xl:flex-1 xl:overflow-hidden">
|
<Panel className="w-full xl:flex-1 xl:overflow-hidden" stormMode={stormMode}>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
title="Territory Map"
|
title="Territory Map"
|
||||||
subtitle="Plano, TX — Rep routes & lead pins"
|
subtitle={selectedLead ? `Route: ${selectedLead.property.address}` : 'Plano, TX — Rep routes & lead pins'}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
collapsible={!isDesktop}
|
collapsible={!isDesktop}
|
||||||
isCollapsed={collapsed.map}
|
isCollapsed={collapsed.map}
|
||||||
onToggle={() => toggle('map')}
|
onToggle={() => toggle('map')}
|
||||||
right={
|
right={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-2 h-2 rounded-full bg-blue-500" />
|
<span
|
||||||
<span className="text-[10px] text-zinc-400 font-medium">{DISPATCH_REPS.length} reps active</span>
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: accent }}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-zinc-400 font-medium">
|
||||||
|
{DISPATCH_REPS.filter(r => r.status === 'available').length} available
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Placeholder — Phase 4 builds Leaflet map here */}
|
|
||||||
{isDesktop ? (
|
{isDesktop ? (
|
||||||
<div
|
<DispatchMapPanel
|
||||||
className="flex flex-col items-center justify-center gap-3 p-6 bg-zinc-50 dark:bg-zinc-950/40 text-center"
|
selectedLead={selectedLead}
|
||||||
style={{ height: 'calc(72rem + 2.375rem)' }}
|
dispatchLeads={dispatchLeads}
|
||||||
>
|
reps={effectiveReps}
|
||||||
<MapPlaceholderContent />
|
stormMode={stormMode}
|
||||||
</div>
|
accent={accent}
|
||||||
|
isDesktop={isDesktop}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CollapsePanel open={!collapsed.map}>
|
<CollapsePanel open={!collapsed.map}>
|
||||||
<div className="flex flex-col items-center justify-center gap-3 p-6 bg-zinc-50 dark:bg-zinc-950/40 text-center min-h-[280px] sm:min-h-[360px] md:min-h-[420px]">
|
<DispatchMapPanel
|
||||||
<MapPlaceholderContent />
|
selectedLead={selectedLead}
|
||||||
</div>
|
dispatchLeads={dispatchLeads}
|
||||||
|
stormMode={stormMode}
|
||||||
|
accent={accent}
|
||||||
|
isDesktop={isDesktop}
|
||||||
|
/>
|
||||||
</CollapsePanel>
|
</CollapsePanel>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -334,6 +534,7 @@ const LynkDispatchPage = () => {
|
|||||||
<Panel
|
<Panel
|
||||||
className="w-full xl:overflow-hidden xl:shrink-0"
|
className="w-full xl:overflow-hidden xl:shrink-0"
|
||||||
style={isDesktop ? { width: rightWidth } : undefined}
|
style={isDesktop ? { width: rightWidth } : undefined}
|
||||||
|
stormMode={stormMode}
|
||||||
>
|
>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
title="AI Recommendations"
|
title="AI Recommendations"
|
||||||
@@ -361,10 +562,12 @@ const LynkDispatchPage = () => {
|
|||||||
<DispatchRecommendationDrawer
|
<DispatchRecommendationDrawer
|
||||||
selectedLead={selectedLead}
|
selectedLead={selectedLead}
|
||||||
isProcessing={isProcessing}
|
isProcessing={isProcessing}
|
||||||
|
reps={effectiveReps}
|
||||||
stormMode={stormMode}
|
stormMode={stormMode}
|
||||||
accent={accent}
|
accent={accent}
|
||||||
onAssign={handleAssign}
|
onAssign={handleAssign}
|
||||||
onDismissAfterAssign={handleDismissAfterAssign}
|
onDismissAfterAssign={handleDismissAfterAssign}
|
||||||
|
onViewDetails={setViewDetailLead}
|
||||||
isDesktop={isDesktop}
|
isDesktop={isDesktop}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -376,6 +579,7 @@ const LynkDispatchPage = () => {
|
|||||||
accent={accent}
|
accent={accent}
|
||||||
onAssign={handleAssign}
|
onAssign={handleAssign}
|
||||||
onDismissAfterAssign={handleDismissAfterAssign}
|
onDismissAfterAssign={handleDismissAfterAssign}
|
||||||
|
onViewDetails={setViewDetailLead}
|
||||||
isDesktop={isDesktop}
|
isDesktop={isDesktop}
|
||||||
/>
|
/>
|
||||||
</CollapsePanel>
|
</CollapsePanel>
|
||||||
@@ -384,74 +588,78 @@ const LynkDispatchPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Lead Quick View Modal ── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{viewDetailLead && (
|
||||||
|
<LeadQuickViewModal
|
||||||
|
lead={viewDetailLead}
|
||||||
|
onClose={() => setViewDetailLead(null)}
|
||||||
|
accent={accent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* ── KPI Bar ── */}
|
{/* ── KPI Bar ── */}
|
||||||
<div className="shrink-0 grid grid-cols-2 xl:grid-cols-4 gap-2 px-4 py-3">
|
<div className="shrink-0 grid grid-cols-2 xl:grid-cols-4 gap-2 px-4 pt-3 pb-2">
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Leads in Queue"
|
label="Leads in Queue"
|
||||||
value={unassigned}
|
value={unassigned}
|
||||||
icon={Inbox}
|
icon={Inbox}
|
||||||
accent={stormMode ? '#F59E0B' : '#3B82F6'}
|
accent={stormMode ? '#F59E0B' : '#3B82F6'}
|
||||||
stormMode={stormMode}
|
stormMode={stormMode}
|
||||||
pulse={false}
|
pulse={stormMode && unassigned > 0}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Assigned Today"
|
label="Assigned Today"
|
||||||
value={28}
|
value={assignedToday}
|
||||||
icon={CheckCircle}
|
icon={CheckCircle}
|
||||||
accent="#10B981"
|
accent="#10B981"
|
||||||
stormMode={stormMode}
|
stormMode={stormMode}
|
||||||
pulse={false}
|
pulse={false}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Avg Response"
|
label="Avg Wait"
|
||||||
value="4.2 min"
|
value={avgWait}
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
accent="#8B5CF6"
|
accent="#8B5CF6"
|
||||||
stormMode={stormMode}
|
stormMode={stormMode}
|
||||||
pulse={false}
|
pulse={false}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Storm Alerts"
|
label={stormMode ? 'Storm Alerts' : 'At Risk'}
|
||||||
value={stormMode ? 3 : 2}
|
value={alertCount}
|
||||||
icon={AlertTriangle}
|
icon={AlertTriangle}
|
||||||
accent={stormMode ? '#F59E0B' : '#6B7280'}
|
accent={alertCount > 0 ? '#F59E0B' : '#6B7280'}
|
||||||
stormMode={stormMode}
|
stormMode={stormMode}
|
||||||
pulse={stormMode}
|
pulse={stormMode && alertCount > 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Dispatch Log Drawer ── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{logDrawerOpen && (
|
||||||
|
<DispatchLogDrawer
|
||||||
|
log={dispatchLog}
|
||||||
|
accent={accent}
|
||||||
|
stormMode={stormMode}
|
||||||
|
onClose={() => setLogDrawerOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ── Weekly Chart Modal ── */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{chartModalOpen && (
|
||||||
|
<DispatchChartModal
|
||||||
|
accent={accent}
|
||||||
|
stormMode={stormMode}
|
||||||
|
onClose={() => setChartModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Extracted placeholder content (avoids duplication between desktop/mobile)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const MapPlaceholderContent = () => (
|
|
||||||
<>
|
|
||||||
<div className="w-16 h-16 rounded-2xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 flex items-center justify-center">
|
|
||||||
<Radio size={28} className="text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">Interactive Map</p>
|
|
||||||
<p className="text-xs text-zinc-400 mt-1">Leaflet map with rep markers & route polylines — Phase 4</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
|
||||||
{[
|
|
||||||
{ label: 'Available', count: DISPATCH_REPS.filter(r => r.status === 'available').length, color: '#10B981' },
|
|
||||||
{ label: 'En Route', count: DISPATCH_REPS.filter(r => r.status === 'en_route').length, color: '#3B82F6' },
|
|
||||||
{ label: 'Busy', count: DISPATCH_REPS.filter(r => r.status === 'busy').length, color: '#F59E0B' },
|
|
||||||
].map(s => (
|
|
||||||
<span
|
|
||||||
key={s.label}
|
|
||||||
className="text-[11px] font-semibold px-2.5 py-1 rounded-full"
|
|
||||||
style={{ backgroundColor: `${s.color}15`, color: s.color }}
|
|
||||||
>
|
|
||||||
{s.count} {s.label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
export default LynkDispatchPage;
|
export default LynkDispatchPage;
|
||||||
|
|||||||
Reference in New Issue
Block a user