diff --git a/src/components/dispatch/DispatchRecommendationDrawer.jsx b/src/components/dispatch/DispatchRecommendationDrawer.jsx
new file mode 100644
index 0000000..adb124b
--- /dev/null
+++ b/src/components/dispatch/DispatchRecommendationDrawer.jsx
@@ -0,0 +1,690 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import {
+ Bot, CheckCircle, X, Star, Clock,
+ Navigation, CloudLightning, Zap, Users, ChevronRight,
+} from 'lucide-react';
+import { PieChart, Pie, Cell } from 'recharts';
+import { toast } from 'sonner';
+import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
+import { AnimatedCounter } from '../AnimatedCounter';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+const scoreColor = (s) => s >= 85 ? '#10B981' : s >= 70 ? '#F59E0B' : '#EF4444';
+
+const URGENCY_CONFIG = {
+ emergency: { label: 'EMERGENCY', color: '#EF4444' },
+ high: { label: 'HIGH', color: '#F59E0B' },
+ standard: { label: 'STANDARD', color: '#6B7280' },
+};
+
+const LEAD_TYPE_LABELS = {
+ emergency_tarp: 'Emergency Tarp',
+ roof_inspection: 'Roof Inspection',
+ insurance_claim: 'Insurance Claim',
+ retail_estimate: 'Retail Estimate',
+};
+
+const FACTOR_LABELS = {
+ proximity: 'Proximity',
+ driveTime: 'Drive Time',
+ calendarFit: 'Calendar',
+ weather: 'Weather',
+ skillMatch: 'Skill Match',
+};
+
+const REP_STATUS_LABELS = { available: 'Available', en_route: 'En Route', busy: 'Busy' };
+const REP_STATUS_COLORS = { available: '#10B981', en_route: '#3B82F6', busy: '#F59E0B' };
+
+// ---------------------------------------------------------------------------
+// Processing skeleton — structured to mirror rep card shape
+// ---------------------------------------------------------------------------
+const ANALYZING_MSGS = [
+ 'Analyzing lead requirements...',
+ 'Scoring field reps...',
+ 'Checking calendar availability...',
+ 'Calculating drive times...',
+ 'Factoring weather conditions...',
+ 'Ranking recommendations...',
+];
+
+const AnalyzingText = ({ accent }) => {
+ const [idx, setIdx] = useState(0);
+
+ useEffect(() => {
+ const t = setInterval(() => setIdx(i => (i + 1) % ANALYZING_MSGS.length), 380);
+ return () => clearInterval(t);
+ }, []);
+
+ return (
+
+
+ {ANALYZING_MSGS[idx]}
+
+
+ );
+};
+
+const RepCardSkeleton = ({ delay = 0 }) => (
+
+ {/* Avatar + name + score */}
+
+ {/* Score bars */}
+
+ {[100, 87, 93, 79, 95].map((w, i) => (
+
+ ))}
+
+ {/* Chips */}
+
+ {[56, 72, 48].map((w, i) => (
+
+ ))}
+
+ {/* Buttons */}
+
+
+);
+
+const ProcessingSkeleton = ({ accent }) => (
+
+ {/* Lead summary shimmer */}
+
+ {/* Analyzing indicator */}
+
+ {/* Rep card shimmers */}
+
+
+
+
+);
+
+// ---------------------------------------------------------------------------
+// Score breakdown bar
+// ---------------------------------------------------------------------------
+const ScoreBar = ({ label, value, index }) => {
+ const color = scoreColor(value);
+ return (
+
+
{label}
+
+
+
+
{value}
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Capacity donut
+// ---------------------------------------------------------------------------
+const CapacityDonut = ({ used, max }) => {
+ const pct = used / max;
+ const color = pct >= 0.85 ? '#EF4444' : pct >= 0.6 ? '#F59E0B' : '#10B981';
+ const data = [{ value: used }, { value: Math.max(0, max - used) }];
+
+ return (
+
+
+
+ |
+ |
+
+
+
{used}/{max} slots
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Rep avatar with status dot
+// ---------------------------------------------------------------------------
+const RepAvatar = ({ initials, status }) => {
+ const color = REP_STATUS_COLORS[status] || '#6B7280';
+ return (
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Lead summary header
+// ---------------------------------------------------------------------------
+const LeadSummary = ({ lead, stormMode }) => {
+ const urgCfg = URGENCY_CONFIG[lead.urgency] || URGENCY_CONFIG.standard;
+ return (
+
+
+
+
{lead.customer.name}
+
+ {lead.property.address}, {lead.property.city}
+
+
+
+ {urgCfg.label}
+
+
+
+
+ {LEAD_TYPE_LABELS[lead.leadType] || lead.leadType}
+
+ ·
+
+
+ {lead.estimatedDuration} min est.
+
+ ·
+ {lead.id}
+
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Rep card
+// ---------------------------------------------------------------------------
+const RepCard = ({ rec, rep, rank, stormMode, accent, onAssign, onOverride }) => {
+ const [assigned, setAssigned] = useState(false);
+ const color = scoreColor(rec.score);
+ const reasons = (stormMode && rec.stormReasons?.length)
+ ? [...rec.reasons, ...rec.stormReasons]
+ : rec.reasons;
+
+ const handleAssign = () => {
+ setAssigned(true);
+ onAssign(rep);
+ setTimeout(() => setAssigned(false), 2400);
+ };
+
+ return (
+
+ {/* Top-pick badge */}
+ {rank === 0 && (
+
+
+
+ Top Pick
+
+
+ )}
+
+ {/* Rep identity row */}
+
+
+
+
{rep.name}
+
+ {REP_STATUS_LABELS[rep.status]}
+
+
+
+
+
+ {/* AI Score — top-right */}
+
+
Score
+
+
{rec.slotStart}
+
+
+
+ {/* Score breakdown bars */}
+
+ {Object.entries(rec.factors).map(([key, val], i) => (
+
+ ))}
+
+
+ {/* Reason chips */}
+
+ {reasons.map((r, i) => {
+ const isWeatherChip = r.toLowerCase().includes('lightning') ||
+ r.toLowerCase().includes('weather') ||
+ r.toLowerCase().includes('storm') ||
+ r.toLowerCase().includes('risk');
+ return (
+
+ {isWeatherChip && }
+ {r}
+
+ );
+ })}
+
+
+ {/* Action buttons */}
+
+ {assigned ? (
+
+
+ Dispatched
+
+ ) : (
+
+
+
+ Assign
+
+
+
+ )}
+
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Dispatched confirmation overlay (full-drawer, 2.2s, then drawer resets)
+// ---------------------------------------------------------------------------
+const DispatchedConfirmation = ({ info, accent }) => (
+
+
+
+
+
+
+ {info.repFirstName} Dispatched
+
+
+ {info.address}
+
+ {info.slot && (
+
+ Slot: {info.slot}
+
+ )}
+
+
+
+);
+
+// ---------------------------------------------------------------------------
+// Rep picker modal (Override)
+// ---------------------------------------------------------------------------
+const RepPickerModal = ({ onClose, onSelect, accent }) => (
+
+
+ {/* Header */}
+
+
+
+ Override Assignment
+
+
Select a rep manually
+
+
+
+
+ {/* Rep list */}
+
+ {DISPATCH_REPS.map((rep, i) => {
+ const color = REP_STATUS_COLORS[rep.status] || '#6B7280';
+ const isFull = rep.todayAppointments >= rep.maxDaily;
+
+ return (
+
!isFull && onSelect(rep)}
+ disabled={isFull}
+ className={`w-full flex items-center gap-3 p-2.5 rounded-xl border text-left transition-all duration-150 ${
+ isFull
+ ? 'opacity-40 cursor-not-allowed border-zinc-100 dark:border-white/[0.04]'
+ : 'border-zinc-200 dark:border-white/[0.07] hover:border-zinc-300 dark:hover:border-white/[0.14] hover:bg-zinc-50 dark:hover:bg-zinc-800/60 cursor-pointer'
+ }`}
+ >
+
+
+
{rep.name}
+
+
{REP_STATUS_LABELS[rep.status]}
+
·
+
{rep.todayAppointments}/{rep.maxDaily} slots
+
+
+
+
+ {rep.performanceScore}
+
+
perf.
+
+ {!isFull && }
+
+ );
+ })}
+
+
+
+);
+
+// ---------------------------------------------------------------------------
+// Main Drawer
+// ---------------------------------------------------------------------------
+const DispatchRecommendationDrawer = ({
+ selectedLead,
+ isProcessing,
+ stormMode,
+ accent,
+ onAssign,
+ onDismissAfterAssign,
+ isDesktop,
+}) => {
+ const [dispatchedInfo, setDispatchedInfo] = useState(null);
+ const [showRepPicker, setShowRepPicker] = useState(false);
+
+ const recommendations = selectedLead ? (DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? []) : [];
+
+ // Clear dispatched overlay when selectedLead changes
+ useEffect(() => {
+ setDispatchedInfo(null);
+ }, [selectedLead?.id]);
+
+ const handleAssign = useCallback((rep) => {
+ if (!selectedLead) return;
+
+ const recs = DISPATCH_RECOMMENDATIONS[selectedLead.id] ?? [];
+ const repRec = recs.find(r => r.repId === rep.id);
+
+ onAssign(selectedLead.id, rep.id);
+
+ toast.success(`${rep.name.split(' ')[0]} dispatched`, {
+ description: `${selectedLead.property.address} · ${repRec?.slotStart ?? 'TBD'}`,
+ duration: 4000,
+ });
+
+ setDispatchedInfo({
+ repFirstName: rep.name.split(' ')[0],
+ address: `${selectedLead.property.address}, ${selectedLead.property.city}`,
+ slot: repRec?.slotStart ?? null,
+ });
+
+ setTimeout(() => {
+ setDispatchedInfo(null);
+ onDismissAfterAssign?.();
+ }, 2400);
+ }, [selectedLead, onAssign, onDismissAfterAssign]);
+
+ const handleOverride = () => setShowRepPicker(true);
+
+ const handlePickerSelect = (rep) => {
+ setShowRepPicker(false);
+ handleAssign(rep);
+ };
+
+ // Height: desktop = fixed to match lead list; mobile = min-h
+ const fixedStyle = isDesktop ? { height: 'calc(72rem + 2.375rem)' } : undefined;
+ const mobileClass = !isDesktop ? 'min-h-[240px] sm:min-h-[320px] md:min-h-[380px]' : '';
+
+ return (
+ <>
+
+
+
+ {/* ── DISPATCHED CONFIRMATION ── */}
+ {dispatchedInfo ? (
+
+
+ /* ── AI PROCESSING ── */
+ ) : isProcessing ? (
+
+
+
+
+ /* ── RESULTS ── */
+ ) : selectedLead && recommendations.length > 0 ? (
+
+
+
+ {/* Section divider */}
+
+
+
+ Top AI Recommendations
+
+
+
+
+ {/* Rep cards */}
+ {recommendations.map((rec, i) => {
+ const rep = DISPATCH_REPS.find(r => r.id === rec.repId);
+ if (!rep) return null;
+ return (
+
+ );
+ })}
+
+
+ /* ── NO RECS FOR THIS LEAD ── */
+ ) : selectedLead ? (
+
+
+
+
+
+
No reps available
+
+ All reps are at capacity or unavailable for this slot
+
+
+
+
+
+ /* ── EMPTY STATE ── */
+ ) : (
+
+
+
+
+
+
No lead selected
+
+ Click any lead in the queue to see AI-scored rep recommendations
+
+
+
+ )}
+
+
+
+ {/* Override modal */}
+
+ {showRepPicker && (
+ setShowRepPicker(false)}
+ onSelect={handlePickerSelect}
+ accent={accent}
+ />
+ )}
+
+ >
+ );
+};
+
+export default DispatchRecommendationDrawer;
diff --git a/src/pages/LynkDispatchPage.jsx b/src/pages/LynkDispatchPage.jsx
index 6b845bb..eeff0a2 100644
--- a/src/pages/LynkDispatchPage.jsx
+++ b/src/pages/LynkDispatchPage.jsx
@@ -1,51 +1,68 @@
-import React, { useState } from 'react';
-import { Zap, CloudLightning, Radio, Inbox, CheckCircle, Clock, AlertTriangle, Bot } from 'lucide-react';
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { Zap, CloudLightning, Radio, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
-import { useTheme } from '../context/ThemeContext';
-import { useMockStore, DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../data/mockStore';
+import { useMockStore, DISPATCH_REPS } from '../data/mockStore';
import DispatchLeadQueue from '../components/dispatch/DispatchLeadQueue';
+import DispatchRecommendationDrawer from '../components/dispatch/DispatchRecommendationDrawer';
// ---------------------------------------------------------------------------
-// Panel — themed container used by all 3 side panels
+// Panel — themed container
// ---------------------------------------------------------------------------
-const Panel = ({ children, className = '' }) => (
-
+const Panel = ({ children, className = '', style }) => (
+
{children}
);
-const PanelHeader = ({ title, subtitle, accent, right }) => (
+// PanelHeader — supports optional collapse toggle on mobile/tablet
+const PanelHeader = ({ title, subtitle, accent, right, collapsible, isCollapsed, onToggle }) => (
-
+
{title}
{subtitle &&
{subtitle}
}
- {right &&
{right}
}
+
+ {right && (
+
collapsible && e.stopPropagation()}>
+ {right}
+
+ )}
+ {collapsible && (
+
+ )}
+
);
// ---------------------------------------------------------------------------
-// KPI Card — bottom bar metric card
+// KPI Card
// ---------------------------------------------------------------------------
const KpiCard = ({ label, value, icon: Icon, accent, stormMode, pulse }) => (
-
-
+
-
{label}
-
{value}
+
{label}
+
{value}
{stormMode && pulse && (
@@ -53,29 +70,122 @@ const KpiCard = ({ label, value, icon: Icon, accent, stormMode, pulse }) => (
);
+// ---------------------------------------------------------------------------
+// ResizableHandle — desktop only (xl+)
+// ---------------------------------------------------------------------------
+const ResizableHandle = ({ onDrag }) => {
+ const dragging = useRef(false);
+ const lastX = useRef(0);
+
+ const onMouseDown = useCallback((e) => {
+ dragging.current = true;
+ lastX.current = e.clientX;
+ e.preventDefault();
+
+ const onMove = (ev) => {
+ if (!dragging.current) return;
+ const delta = ev.clientX - lastX.current;
+ lastX.current = ev.clientX;
+ onDrag(delta);
+ };
+ const onUp = () => {
+ dragging.current = false;
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ };
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ }, [onDrag]);
+
+ return (
+
+ );
+};
+
+// Animated collapse wrapper
+const CollapsePanel = ({ open, children }) => (
+
+ {open && (
+
+ {children}
+
+ )}
+
+);
+
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
const LynkDispatchPage = () => {
- const { theme } = useTheme();
- const { dispatchLeads } = useMockStore();
+ const { dispatchLeads, assignDispatchLead } = useMockStore();
const [selectedLead, setSelectedLead] = useState(null);
const [stormMode, setStormMode] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
+ const [leftWidth, setLeftWidth] = useState(300);
+ const [rightWidth, setRightWidth] = useState(320);
+
+ // xl = 1280px — treats all iPads (inc. Air 820/1180 & Pro 11" 834/1194) as tablet
+ const [isDesktop, setIsDesktop] = useState(() => window.innerWidth >= 1280);
+ const aiPanelRef = useRef(null);
+
+ useEffect(() => {
+ const handler = () => setIsDesktop(window.innerWidth >= 1280);
+ window.addEventListener('resize', handler);
+ return () => window.removeEventListener('resize', handler);
+ }, []);
+
+ // Collapse state — map & AI default-collapsed on small phones only
+ const [collapsed, setCollapsed] = useState(() => ({
+ queue: false,
+ map: window.innerWidth < 640,
+ ai: window.innerWidth < 640,
+ }));
+ const toggle = (key) => setCollapsed(p => ({ ...p, [key]: !p[key] }));
+
+ const dragLeft = useCallback((delta) => setLeftWidth(w => Math.min(440, Math.max(220, w + delta))), []);
+ const dragRight = useCallback((delta) => setRightWidth(w => Math.min(440, Math.max(260, w - delta))), []);
const handleSelectLead = (lead) => {
if (selectedLead?.id === lead.id) return;
setSelectedLead(lead);
setIsProcessing(true);
setTimeout(() => setIsProcessing(false), 1200);
+
+ // Mobile/tablet: expand AI panel and scroll to it
+ if (!isDesktop) {
+ setCollapsed(p => ({ ...p, ai: false }));
+ setTimeout(() => {
+ aiPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ }, 120);
+ }
};
- const accent = stormMode ? '#F59E0B' : '#3B82F6';
- const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
+ const handleAssign = useCallback((leadId, repId) => {
+ assignDispatchLead(leadId, repId);
+ }, [assignDispatchLead]);
+
+ const handleDismissAfterAssign = useCallback(() => {
+ setSelectedLead(null);
+ }, []);
+
+ const accent = stormMode ? '#F59E0B' : '#3B82F6';
+ const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
return (
-
+
{/* Storm Banner */}
@@ -97,9 +207,8 @@ const LynkDispatchPage = () => {
)}
- {/* ── Header ─────────────────────────────────────────────────────── */}
-
- {/* Branding */}
+ {/* ── Header ── */}
+