From c0706077d3997c7c0d2f6cf045a4b2a7c483e0ae Mon Sep 17 00:00:00 2001 From: Satyam <95536056+Satyam-Rastogi@users.noreply.github.com> Date: Thu, 26 Mar 2026 01:57:49 +0530 Subject: [PATCH] =?UTF-8?q?feat(dispatch):=20Phase=203=20=E2=80=94=20AI=20?= =?UTF-8?q?recommendation=20drawer=20with=20scoring=20and=20dispatch=20flo?= =?UTF-8?q?w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Build DispatchRecommendationDrawer with empty/processing/results/dispatched states - Structured shimmer skeletons during AI processing with cycling analyze messages - Rep cards: animated score counter, 5-factor breakdown bars, reason chips, capacity donut - Top Pick badge, storm-aware weather chips (amber styling) - Assign button fires Sonner toast + updates lead status to confirmed in queue - Override button opens rep picker modal with capacity and performance data - Post-assign dispatched confirmation overlay (2.4s) then drawer resets - Mobile: tap lead auto-expands AI panel and smooth-scrolls to it --- .../dispatch/DispatchRecommendationDrawer.jsx | 690 ++++++++++++++++++ src/pages/LynkDispatchPage.jsx | 386 ++++++---- 2 files changed, 947 insertions(+), 129 deletions(-) create mode 100644 src/components/dispatch/DispatchRecommendationDrawer.jsx 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 ( +
+
+ {initials} +
+ +
+ ); +}; + +// --------------------------------------------------------------------------- +// 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 ── */} +
{
- {/* Right controls */}
{/* LIVE badge */}
@@ -139,17 +247,23 @@ const LynkDispatchPage = () => {
- {/* ── 3-Panel Grid ───────────────────────────────────────────────── */} - {/* Desktop: [320px fixed] [flex-1 map] [340px fixed] */} - {/* Mobile: single column, scrollable */} -
+ {/* ── 3-Panel Layout ──────────────────────────────────────────────── */} + {/* xl+ (1280px): flex row with drag handles (all iPads = tablet) */} + {/* < xl: single column, collapsible panels */} +
{/* LEFT — Lead Queue */} - + toggle('queue')} right={ { } /> - + {isDesktop ? ( + + ) : ( + + + + )} - {/* CENTER — Map */} - + + + {/* CENTER — Territory Map */} + toggle('map')} right={
@@ -181,39 +311,43 @@ const LynkDispatchPage = () => { } /> {/* Placeholder — Phase 4 builds Leaflet map here */} -
-
- + {isDesktop ? ( +
+
-
-

Interactive Map

-

Leaflet map with rep markers & route polylines — Phase 4

-
- {/* Rep status pills */} -
- {[ - { 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 => ( - - {s.count} {s.label} - - ))} -
-
+ ) : ( + +
+ +
+
+ )} - {/* RIGHT — AI Recommendation Drawer */} - + + + {/* RIGHT — AI Recommendations */} +
+ toggle('ai')} right={
{
} /> - {/* Placeholder — Phase 3 replaces this with DispatchRecommendationDrawer */} -
- - {isProcessing ? ( - -
- -
-

Analyzing lead...

-

AI scoring reps

-
- ) : selectedLead ? ( - -
- -
-

{selectedLead.id}

-

{selectedLead.customer.name} · {selectedLead.property.address}

-

- Recommendation engine — Phase 3 -

-
- ) : ( - - - - -
-

No lead selected

-

Click a lead in the queue to see AI-scored rep recommendations

-
-
- )} -
-
+ {isDesktop ? ( + + ) : ( + + + + )}
+
- {/* ── KPI Bar ────────────────────────────────────────────────────── */} -
+ {/* ── KPI Bar ── */} +
{ ); }; +// --------------------------------------------------------------------------- +// Extracted placeholder content (avoids duplication between desktop/mobile) +// --------------------------------------------------------------------------- +const MapPlaceholderContent = () => ( + <> +
+ +
+
+

Interactive Map

+

Leaflet map with rep markers & route polylines — Phase 4

+
+
+ {[ + { 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 => ( + + {s.count} {s.label} + + ))} +
+ +); + + export default LynkDispatchPage;