feat(dispatch): Phase 2 — DispatchLeadQueue with tabs, cards, and live drop-in
- DispatchLeadQueue: 4-tab filter (All / Unassigned / At Risk / Confirmed) with live counts; AnimatePresence popLayout for smooth card entrance/exit - LeadCard: urgency badge (EMERGENCY pulse / HIGH / STANDARD), source icon, customer name, lead type, address, status pill, assigned rep initials, time ago — colored left border by urgency, blue ring when selected - Storm mode: emergency leads float to top via sort; PRIORITY badge on emergency_tarp cards; amber Unassigned pill in storm context - 20s interval drops new leads from 3-lead pool; NEW badge fades after 5s - LynkDispatchPage: left panel now renders DispatchLeadQueue; handleSelectLead triggers 1.2s isProcessing state; right panel shows processing/selected/empty states ready for Phase 3
This commit is contained in:
@@ -0,0 +1,317 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Globe, MapPin, Users, Phone, Clock, CloudLightning } from 'lucide-react';
|
||||||
|
import { useMockStore, DISPATCH_REPS } from '../../data/mockStore';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config maps
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const SOURCE_CONFIG = {
|
||||||
|
website_form: { icon: Globe, label: 'Web Form' },
|
||||||
|
canvassing: { icon: MapPin, label: 'Canvassing' },
|
||||||
|
referral: { icon: Users, label: 'Referral' },
|
||||||
|
call_center: { icon: Phone, label: 'Call Center' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEAD_TYPE_LABELS = {
|
||||||
|
emergency_tarp: 'Emergency Tarp',
|
||||||
|
roof_inspection: 'Roof Inspection',
|
||||||
|
insurance_claim: 'Insurance Claim',
|
||||||
|
retail_estimate: 'Retail Estimate',
|
||||||
|
};
|
||||||
|
|
||||||
|
const URGENCY_CONFIG = {
|
||||||
|
emergency: { label: 'EMERGENCY', color: '#EF4444', bg: '#EF444415', pulse: true },
|
||||||
|
high: { label: 'HIGH', color: '#F59E0B', bg: '#F59E0B15', pulse: false },
|
||||||
|
standard: { label: 'STANDARD', color: '#6B7280', bg: '#6B728015', pulse: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
unassigned: { label: 'Unassigned', color: '#EF4444', bg: '#EF444412' },
|
||||||
|
at_risk: { label: 'At Risk', color: '#F59E0B', bg: '#F59E0B12' },
|
||||||
|
assigned: { label: 'Assigned', color: '#3B82F6', bg: '#3B82F612' },
|
||||||
|
confirmed: { label: 'Confirmed', color: '#10B981', bg: '#10B98112' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'all', label: 'All' },
|
||||||
|
{ id: 'unassigned', label: 'Unassigned' },
|
||||||
|
{ id: 'at_risk', label: 'At Risk' },
|
||||||
|
{ id: 'confirmed', label: 'Confirmed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TAB_STATUS_FILTER = {
|
||||||
|
all: null,
|
||||||
|
unassigned: ['unassigned'],
|
||||||
|
at_risk: ['at_risk'],
|
||||||
|
confirmed: ['assigned', 'confirmed'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fake leads that "drop in" every 20s during demo — cycled through pool
|
||||||
|
const DROP_POOL = [
|
||||||
|
{
|
||||||
|
source: 'call_center', leadType: 'roof_inspection', urgency: 'high',
|
||||||
|
customer: { name: 'Marcus Webb' },
|
||||||
|
property: { address: '1620 Mapleshade Ln', city: 'Plano', state: 'TX', zip: '75075', lat: 33.0545, lng: -96.7634 },
|
||||||
|
estimatedDuration: 45, status: 'unassigned', arrivedMinsAgo: 0, assignedRepId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: 'website_form', leadType: 'emergency_tarp', urgency: 'emergency',
|
||||||
|
customer: { name: 'Sandra Kim' },
|
||||||
|
property: { address: '3488 Midway Rd', city: 'Plano', state: 'TX', zip: '75093', lat: 33.0712, lng: -96.8090 },
|
||||||
|
estimatedDuration: 90, status: 'unassigned', arrivedMinsAgo: 0, assignedRepId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: 'canvassing', leadType: 'insurance_claim', urgency: 'high',
|
||||||
|
customer: { name: 'David Okonkwo' },
|
||||||
|
property: { address: '892 McDermott Dr', city: 'Plano', state: 'TX', zip: '75024', lat: 33.0921, lng: -96.7812 },
|
||||||
|
estimatedDuration: 60, status: 'unassigned', arrivedMinsAgo: 0, assignedRepId: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LeadCard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const LeadCard = ({ lead, isSelected, isNew, onSelect, stormMode, accent, index }) => {
|
||||||
|
const urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard;
|
||||||
|
const statusCfg = STATUS_CONFIG[lead.status] || STATUS_CONFIG.unassigned;
|
||||||
|
const srcCfg = SOURCE_CONFIG[lead.source] || SOURCE_CONFIG.call_center;
|
||||||
|
const SrcIcon = srcCfg.icon;
|
||||||
|
const assignedRep = lead.assignedRepId ? DISPATCH_REPS.find(r => r.id === lead.assignedRepId) : null;
|
||||||
|
const timeLabel = lead.arrivedMinsAgo === 0 ? 'Just now' : `${lead.arrivedMinsAgo} min ago`;
|
||||||
|
const borderColor = isSelected ? accent : urgCfg.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: -14 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -16, transition: { duration: 0.18 } }}
|
||||||
|
transition={{ type: 'spring', stiffness: 420, damping: 32, delay: index * 0.03 }}
|
||||||
|
onClick={() => onSelect(lead)}
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
className={`relative cursor-pointer rounded-xl border transition-all duration-200 p-3 select-none
|
||||||
|
${isSelected
|
||||||
|
? 'bg-blue-50/80 dark:bg-blue-500/10 border-blue-300 dark:border-blue-500/40 shadow-md shadow-blue-100/60 dark:shadow-blue-500/5'
|
||||||
|
: 'bg-white dark:bg-zinc-800/50 border-zinc-200 dark:border-white/[0.06] hover:border-zinc-300 dark:hover:border-white/[0.12] hover:shadow-md'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
style={{ borderLeftWidth: 3, borderLeftColor: borderColor }}
|
||||||
|
>
|
||||||
|
{/* Top row: urgency badge + NEW badge + source */}
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0 ${urgCfg.pulse ? 'animate-pulse' : ''}`}
|
||||||
|
style={{ backgroundColor: urgCfg.bg, color: urgCfg.color }}
|
||||||
|
>
|
||||||
|
{urgCfg.label}
|
||||||
|
</span>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isNew && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
className="text-[10px] font-bold px-1.5 py-0.5 rounded bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 shrink-0"
|
||||||
|
>
|
||||||
|
NEW
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
{stormMode && lead.leadType === 'emergency_tarp' && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="flex items-center gap-0.5 text-[10px] font-bold px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-500/15 text-amber-600 dark:text-amber-400 shrink-0"
|
||||||
|
>
|
||||||
|
<CloudLightning size={9} />
|
||||||
|
PRIORITY
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0 text-zinc-400 dark:text-zinc-500">
|
||||||
|
<SrcIcon size={11} />
|
||||||
|
<span className="text-[10px]">{srcCfg.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer name + lead type */}
|
||||||
|
<p className="text-sm font-semibold text-zinc-900 dark:text-white leading-snug">
|
||||||
|
{lead.customer.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||||
|
{LEAD_TYPE_LABELS[lead.leadType] || lead.leadType}
|
||||||
|
<span className="text-zinc-300 dark:text-zinc-600 mx-1">·</span>
|
||||||
|
{lead.estimatedDuration} min est.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 mt-1 truncate">
|
||||||
|
{lead.property.address}, {lead.property.city}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Bottom row: status + time */}
|
||||||
|
<div className="flex items-center justify-between mt-2 gap-2">
|
||||||
|
{assignedRep ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px] font-medium min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold text-white shrink-0"
|
||||||
|
style={{ backgroundColor: statusCfg.color }}
|
||||||
|
>
|
||||||
|
{assignedRep.initials.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="truncate"
|
||||||
|
style={{ color: statusCfg.color }}
|
||||||
|
>
|
||||||
|
{assignedRep.name.split(' ')[0]}
|
||||||
|
<span className="text-zinc-400 dark:text-zinc-600 font-normal"> · {statusCfg.label}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-bold px-2 py-0.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: statusCfg.bg, color: statusCfg.color }}
|
||||||
|
>
|
||||||
|
{statusCfg.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-zinc-400 shrink-0">
|
||||||
|
<Clock size={10} />
|
||||||
|
{timeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lead ID */}
|
||||||
|
<p className="text-[9px] text-zinc-200 dark:text-zinc-800 mt-1.5 font-mono">{lead.id}</p>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DispatchLeadQueue
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const DispatchLeadQueue = ({ selectedLead, onSelectLead, stormMode, accent }) => {
|
||||||
|
const { dispatchLeads } = useMockStore();
|
||||||
|
const [activeTab, setActiveTab] = useState('all');
|
||||||
|
const [droppedLeads, setDropped] = useState([]);
|
||||||
|
const [newLeadIds, setNewLeadIds] = useState(new Set());
|
||||||
|
const dropIndexRef = useRef(0);
|
||||||
|
|
||||||
|
// 20-second interval: drop in a new lead from the pool
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const template = DROP_POOL[dropIndexRef.current % DROP_POOL.length];
|
||||||
|
dropIndexRef.current++;
|
||||||
|
const lead = { ...template, id: `LD-D${Date.now()}` };
|
||||||
|
|
||||||
|
setDropped(prev => [lead, ...prev]);
|
||||||
|
setNewLeadIds(prev => new Set([...prev, lead.id]));
|
||||||
|
|
||||||
|
// Clear "New" badge after 5s
|
||||||
|
setTimeout(() => {
|
||||||
|
setNewLeadIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(lead.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Merge dropped leads (newest first) with store leads
|
||||||
|
const allLeads = [...droppedLeads, ...dispatchLeads];
|
||||||
|
|
||||||
|
// Tab counts
|
||||||
|
const counts = {
|
||||||
|
all: allLeads.length,
|
||||||
|
unassigned: allLeads.filter(l => l.status === 'unassigned').length,
|
||||||
|
at_risk: allLeads.filter(l => l.status === 'at_risk').length,
|
||||||
|
confirmed: allLeads.filter(l => ['assigned', 'confirmed'].includes(l.status)).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter by tab
|
||||||
|
const statusFilter = TAB_STATUS_FILTER[activeTab];
|
||||||
|
let filteredLeads = statusFilter
|
||||||
|
? allLeads.filter(l => statusFilter.includes(l.status))
|
||||||
|
: allLeads;
|
||||||
|
|
||||||
|
// Storm mode: float emergency leads to top
|
||||||
|
if (stormMode) {
|
||||||
|
filteredLeads = [...filteredLeads].sort((a, b) => {
|
||||||
|
const aScore = a.urgency === 'emergency' ? 0 : a.leadType === 'emergency_tarp' ? 1 : 2;
|
||||||
|
const bScore = b.urgency === 'emergency' ? 0 : b.leadType === 'emergency_tarp' ? 1 : 2;
|
||||||
|
return aScore - bScore;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="shrink-0 flex items-center gap-1 p-2 border-b border-zinc-100 dark:border-white/[0.06] bg-zinc-50/50 dark:bg-zinc-900/30">
|
||||||
|
{TABS.map(tab => {
|
||||||
|
const isActive = activeTab === tab.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all duration-200 flex-1 justify-center
|
||||||
|
${isActive
|
||||||
|
? 'bg-white dark:bg-zinc-700 shadow-sm text-zinc-900 dark:text-white'
|
||||||
|
: 'text-zinc-400 dark:text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-black px-1 rounded min-w-[18px] text-center transition-all duration-200
|
||||||
|
${isActive ? 'text-white' : 'text-zinc-400 dark:text-zinc-600'}
|
||||||
|
`}
|
||||||
|
style={isActive ? { backgroundColor: accent } : {}}
|
||||||
|
>
|
||||||
|
{counts[tab.id]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lead list */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-2 space-y-1.5 custom-scrollbar">
|
||||||
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
|
{filteredLeads.length === 0 ? (
|
||||||
|
<motion.div
|
||||||
|
key="empty"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="flex flex-col items-center justify-center py-12 text-center"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-zinc-400 dark:text-zinc-500 font-medium">No leads in this view</p>
|
||||||
|
<p className="text-xs text-zinc-300 dark:text-zinc-700 mt-1">New leads appear automatically</p>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
filteredLeads.map((lead, index) => (
|
||||||
|
<LeadCard
|
||||||
|
key={lead.id}
|
||||||
|
lead={lead}
|
||||||
|
isSelected={selectedLead?.id === lead.id}
|
||||||
|
isNew={newLeadIds.has(lead.id)}
|
||||||
|
onSelect={onSelectLead}
|
||||||
|
stormMode={stormMode}
|
||||||
|
accent={accent}
|
||||||
|
index={index}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DispatchLeadQueue;
|
||||||
@@ -3,6 +3,7 @@ import { Zap, CloudLightning, Radio, Inbox, CheckCircle, Clock, AlertTriangle, B
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
import { useTheme } from '../context/ThemeContext';
|
||||||
import { useMockStore, DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../data/mockStore';
|
import { useMockStore, DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../data/mockStore';
|
||||||
|
import DispatchLeadQueue from '../components/dispatch/DispatchLeadQueue';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Panel — themed container used by all 3 side panels
|
// Panel — themed container used by all 3 side panels
|
||||||
@@ -63,6 +64,13 @@ const LynkDispatchPage = () => {
|
|||||||
const [stormMode, setStormMode] = useState(false);
|
const [stormMode, setStormMode] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
const handleSelectLead = (lead) => {
|
||||||
|
if (selectedLead?.id === lead.id) return;
|
||||||
|
setSelectedLead(lead);
|
||||||
|
setIsProcessing(true);
|
||||||
|
setTimeout(() => setIsProcessing(false), 1200);
|
||||||
|
};
|
||||||
|
|
||||||
const accent = stormMode ? '#F59E0B' : '#3B82F6';
|
const accent = stormMode ? '#F59E0B' : '#3B82F6';
|
||||||
const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
|
const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
|
||||||
|
|
||||||
@@ -140,43 +148,23 @@ const LynkDispatchPage = () => {
|
|||||||
<Panel className="lg:overflow-hidden">
|
<Panel className="lg:overflow-hidden">
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
title="Lead Queue"
|
title="Lead Queue"
|
||||||
subtitle={`${unassigned} unassigned · ${dispatchLeads.length} total`}
|
subtitle="Live · auto-updates every 20s"
|
||||||
accent={accent}
|
accent={accent}
|
||||||
right={
|
right={
|
||||||
<span
|
<span
|
||||||
className="text-xs font-bold px-2 py-0.5 rounded-full"
|
className="text-xs font-bold px-2 py-0.5 rounded-full"
|
||||||
style={{ backgroundColor: `${accent}18`, color: accent }}
|
style={{ backgroundColor: `${accent}18`, color: accent }}
|
||||||
>
|
>
|
||||||
{dispatchLeads.length}
|
{dispatchLeads.length}+
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Placeholder — Phase 2 builds this */}
|
<DispatchLeadQueue
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
selectedLead={selectedLead}
|
||||||
<div className="w-12 h-12 rounded-2xl bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
|
onSelectLead={handleSelectLead}
|
||||||
<Inbox size={22} className="text-zinc-400" />
|
stormMode={stormMode}
|
||||||
</div>
|
accent={accent}
|
||||||
<div>
|
/>
|
||||||
<p className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">{dispatchLeads.length} leads loaded</p>
|
|
||||||
<p className="text-xs text-zinc-400 mt-1">Lead queue renders in Phase 2</p>
|
|
||||||
</div>
|
|
||||||
{/* Quick stat pills */}
|
|
||||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
|
||||||
{[
|
|
||||||
{ label: 'Unassigned', count: dispatchLeads.filter(l => l.status === 'unassigned').length, color: '#EF4444' },
|
|
||||||
{ label: 'At Risk', count: dispatchLeads.filter(l => l.status === 'at_risk').length, color: '#F59E0B' },
|
|
||||||
{ label: 'Confirmed', count: dispatchLeads.filter(l => l.status === 'confirmed').length, color: '#10B981' },
|
|
||||||
].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>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* CENTER — Map */}
|
{/* CENTER — Map */}
|
||||||
@@ -235,8 +223,52 @@ const LynkDispatchPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Empty state — Phase 3 builds this */}
|
{/* Placeholder — Phase 3 replaces this with DispatchRecommendationDrawer */}
|
||||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{isProcessing ? (
|
||||||
|
<motion.div
|
||||||
|
key="processing"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="flex flex-col items-center gap-3"
|
||||||
|
>
|
||||||
|
<div className="w-14 h-14 rounded-2xl border-2 border-dashed flex items-center justify-center animate-pulse"
|
||||||
|
style={{ borderColor: `${accent}60`, backgroundColor: `${accent}10` }}
|
||||||
|
>
|
||||||
|
<Bot size={24} style={{ color: accent }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold" style={{ color: accent }}>Analyzing lead...</p>
|
||||||
|
<p className="text-xs text-zinc-400">AI scoring reps</p>
|
||||||
|
</motion.div>
|
||||||
|
) : selectedLead ? (
|
||||||
|
<motion.div
|
||||||
|
key="selected"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="flex flex-col items-center gap-2"
|
||||||
|
>
|
||||||
|
<div className="w-14 h-14 rounded-2xl flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: `${accent}18`, color: accent }}
|
||||||
|
>
|
||||||
|
<Bot size={24} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">{selectedLead.id}</p>
|
||||||
|
<p className="text-xs text-zinc-400">{selectedLead.customer.name} · {selectedLead.property.address}</p>
|
||||||
|
<p className="text-[10px] text-zinc-300 dark:text-zinc-700 mt-2 font-medium uppercase tracking-widest">
|
||||||
|
Recommendation engine — Phase 3
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="empty"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="flex flex-col items-center gap-3"
|
||||||
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ scale: [1, 1.05, 1] }}
|
animate={{ scale: [1, 1.05, 1] }}
|
||||||
transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut' }}
|
transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
@@ -245,16 +277,12 @@ const LynkDispatchPage = () => {
|
|||||||
<Bot size={24} className="text-zinc-300 dark:text-zinc-600" />
|
<Bot size={24} className="text-zinc-300 dark:text-zinc-600" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-zinc-500 dark:text-zinc-400">
|
<p className="text-sm font-semibold text-zinc-500 dark:text-zinc-400">No lead selected</p>
|
||||||
{selectedLead ? 'Processing...' : 'No lead selected'}
|
<p className="text-xs text-zinc-400 mt-1">Click a lead in the queue to see AI-scored rep recommendations</p>
|
||||||
</p>
|
|
||||||
<p className="text-xs text-zinc-400 mt-1">
|
|
||||||
Click a lead in the queue to see AI-scored rep recommendations
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-zinc-300 dark:text-zinc-700 mt-2 font-medium uppercase tracking-widest">
|
</motion.div>
|
||||||
Recommendation engine — Phase 3
|
)}
|
||||||
</p>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user