diff --git a/src/pages/LynkDispatchPage.jsx b/src/pages/LynkDispatchPage.jsx index 95fbe14..a38e0dd 100644 --- a/src/pages/LynkDispatchPage.jsx +++ b/src/pages/LynkDispatchPage.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { Zap, CloudLightning, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown, History, BarChart2 } from 'lucide-react'; +import { Zap, CloudLightning, Inbox, CheckCircle, Clock, AlertTriangle, Bot, ChevronDown, History, BarChart2, Navigation, Trophy, Gauge, Sparkles, X, Info } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { toast } from 'sonner'; import { useMockStore, DISPATCH_REPS } from '../data/mockStore'; @@ -137,6 +137,87 @@ const CollapsePanel = ({ open, children }) => ( ); +// --------------------------------------------------------------------------- +// AI Dispatch Explainer — popover anchored to the "% AI-Dispatched" stat. +// Turns the standout number into a trust signal: shows WHY the engine picks a +// rep (drive time, win rate, capacity, …) instead of leaving owners guessing. +// --------------------------------------------------------------------------- +const DISPATCH_FACTORS = [ + { key: 'driveTime', icon: Navigation, label: 'Drive Time', weight: 30, desc: 'Closest rep with the shortest ETA — live route & traffic.' }, + { key: 'winRate', icon: Trophy, label: 'Win Rate', weight: 25, desc: 'Reps who close this lead type more often rank higher.' }, + { key: 'capacity', icon: Gauge, label: 'Capacity', weight: 20, desc: "Balances today's slots so no rep gets overbooked." }, + { key: 'skill', icon: Sparkles, label: 'Skill Match', weight: 15, desc: 'Certifications & job-type expertise for this lead.' }, + { key: 'weather', icon: CloudLightning, label: 'Weather', weight: 10, desc: 'Storm & hail safety windows adjust routing in Storm Mode.' }, +]; +const MAX_FACTOR_WEIGHT = Math.max(...DISPATCH_FACTORS.map(f => f.weight)); + +const AIDispatchExplainer = ({ efficiencyPct, aiCount, totalCount, accent, onClose }) => { + const overrides = totalCount - aiCount; + return ( + e.stopPropagation()} + className="absolute top-full left-0 mt-2 w-[320px] max-w-[88vw] z-[9999] rounded-2xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 shadow-2xl shadow-black/10 dark:shadow-black/50 overflow-hidden" + > + {/* Header */} +
+
+
+ +

Why AI-Dispatched

+
+ +
+

+ {efficiencyPct}% of today's {totalCount} dispatches took the AI's top pick + {' · '}{overrides} manual override{overrides === 1 ? '' : 's'}. +

+
+ + {/* Factor weights */} +
+

How a rep is chosen

+ {DISPATCH_FACTORS.map((f, i) => ( +
+
+ +
+
+
+ {f.label} + {f.weight}% +
+
+ +
+

{f.desc}

+
+
+ ))} +
+ + {/* Footer */} +
+

+ + Every pick is logged & fully overridable — you stay in control. +

+
+
+ ); +}; + // --------------------------------------------------------------------------- // Main Page // --------------------------------------------------------------------------- @@ -151,6 +232,18 @@ const LynkDispatchPage = () => { const [chartModalOpen, setChartModalOpen] = useState(false); const [filterHailOnly, setFilterHailOnly] = useState(false); + // "% AI-Dispatched" explainability popover + const [aiExplainerOpen, setAiExplainerOpen] = useState(false); + const explainerRef = useRef(null); + useEffect(() => { + if (!aiExplainerOpen) return; + const onClick = (e) => { if (explainerRef.current && !explainerRef.current.contains(e.target)) setAiExplainerOpen(false); }; + const onKey = (e) => { if (e.key === 'Escape') setAiExplainerOpen(false); }; + document.addEventListener('mousedown', onClick); + document.addEventListener('keydown', onKey); + return () => { document.removeEventListener('mousedown', onClick); document.removeEventListener('keydown', onKey); }; + }, [aiExplainerOpen]); + // Hail Recon — load summaries for all leads in queue const { summaries: hailSummaries, loading: hailLoading } = useHailSummaries(dispatchLeads); @@ -372,19 +465,36 @@ const LynkDispatchPage = () => { {totalCount > 0 && ( - - - {efficiencyPct}% AI-Dispatched - +
+ setAiExplainerOpen(o => !o)} + aria-expanded={aiExplainerOpen} + title="See why the AI dispatched" + className="flex items-center gap-1 text-[10px] font-black cursor-pointer whitespace-nowrap rounded-full px-1.5 py-0.5 -ml-1.5 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors" + style={{ color: stormMode ? '#F59E0B' : '#10B981' }} + > + + {efficiencyPct}% AI-Dispatched + + + + {aiExplainerOpen && ( + setAiExplainerOpen(false)} + /> + )} + +
)}