added info badge to show why ai distpatch shows 89% on lynkeddispatch page
This commit is contained in:
+124
-14
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
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 { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useMockStore, DISPATCH_REPS } from '../data/mockStore';
|
import { useMockStore, DISPATCH_REPS } from '../data/mockStore';
|
||||||
@@ -137,6 +137,87 @@ const CollapsePanel = ({ open, children }) => (
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -6, scale: 0.97 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -6, scale: 0.97 }}
|
||||||
|
transition={{ duration: 0.16 }}
|
||||||
|
onClick={(e) => 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 */}
|
||||||
|
<div className="px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06]" style={{ borderTopWidth: 2, borderTopColor: accent }}>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bot size={14} style={{ color: accent }} />
|
||||||
|
<h3 className="text-xs font-black uppercase tracking-widest text-zinc-900 dark:text-white">Why AI-Dispatched</h3>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} aria-label="Close" className="p-1 rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors">
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mt-1.5 leading-relaxed">
|
||||||
|
<span className="font-bold" style={{ color: accent }}>{efficiencyPct}%</span> of today's {totalCount} dispatches took the AI's top pick
|
||||||
|
{' · '}{overrides} manual override{overrides === 1 ? '' : 's'}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Factor weights */}
|
||||||
|
<div className="p-3 space-y-2.5">
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500">How a rep is chosen</p>
|
||||||
|
{DISPATCH_FACTORS.map((f, i) => (
|
||||||
|
<div key={f.key} className="flex items-start gap-2.5">
|
||||||
|
<div className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5" style={{ backgroundColor: `${accent}14`, color: accent }}>
|
||||||
|
<f.icon size={13} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[11px] font-bold text-zinc-800 dark:text-zinc-100">{f.label}</span>
|
||||||
|
<span className="text-[10px] font-mono font-bold text-zinc-400">{f.weight}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 h-1 rounded-full bg-zinc-100 dark:bg-zinc-700/50 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{ backgroundColor: accent }}
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${(f.weight / MAX_FACTOR_WEIGHT) * 100}%` }}
|
||||||
|
transition={{ duration: 0.5, ease: 'easeOut', delay: 0.06 + i * 0.05 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-zinc-500 dark:text-zinc-400 mt-1 leading-snug">{f.desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-4 py-2.5 border-t border-zinc-100 dark:border-white/[0.06] bg-zinc-50 dark:bg-white/[0.02]">
|
||||||
|
<p className="text-[10px] text-zinc-400 leading-snug flex items-center gap-1.5">
|
||||||
|
<CheckCircle size={11} className="text-emerald-500 shrink-0" />
|
||||||
|
Every pick is logged & fully overridable — you stay in control.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main Page
|
// Main Page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -151,6 +232,18 @@ const LynkDispatchPage = () => {
|
|||||||
const [chartModalOpen, setChartModalOpen] = useState(false);
|
const [chartModalOpen, setChartModalOpen] = useState(false);
|
||||||
const [filterHailOnly, setFilterHailOnly] = 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
|
// Hail Recon — load summaries for all leads in queue
|
||||||
const { summaries: hailSummaries, loading: hailLoading } = useHailSummaries(dispatchLeads);
|
const { summaries: hailSummaries, loading: hailLoading } = useHailSummaries(dispatchLeads);
|
||||||
|
|
||||||
@@ -372,19 +465,36 @@ const LynkDispatchPage = () => {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{totalCount > 0 && (
|
{totalCount > 0 && (
|
||||||
<motion.span
|
<div key="efficiency-wrap" className="relative" ref={explainerRef}>
|
||||||
key="efficiency"
|
<motion.button
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
key="efficiency"
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
type="button"
|
||||||
exit={{ opacity: 0 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
transition={{ duration: 0.2 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
title={`${efficiencyPct}% of dispatches used AI recommendation — ${100 - efficiencyPct}% were manual overrides`}
|
exit={{ opacity: 0 }}
|
||||||
className="flex items-center gap-1 text-[10px] font-black cursor-default whitespace-nowrap"
|
transition={{ duration: 0.2 }}
|
||||||
style={{ color: stormMode ? '#F59E0B' : '#10B981' }}
|
onClick={() => setAiExplainerOpen(o => !o)}
|
||||||
>
|
aria-expanded={aiExplainerOpen}
|
||||||
<span className="w-1 h-1 rounded-full" style={{ backgroundColor: stormMode ? '#F59E0B' : '#10B981' }} />
|
title="See why the AI dispatched"
|
||||||
{efficiencyPct}% 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"
|
||||||
</motion.span>
|
style={{ color: stormMode ? '#F59E0B' : '#10B981' }}
|
||||||
|
>
|
||||||
|
<span className="w-1 h-1 rounded-full" style={{ backgroundColor: stormMode ? '#F59E0B' : '#10B981' }} />
|
||||||
|
{efficiencyPct}% AI-Dispatched
|
||||||
|
<Info size={9} className="opacity-70" />
|
||||||
|
</motion.button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{aiExplainerOpen && (
|
||||||
|
<AIDispatchExplainer
|
||||||
|
efficiencyPct={efficiencyPct}
|
||||||
|
aiCount={aiCount}
|
||||||
|
totalCount={totalCount}
|
||||||
|
accent={stormMode ? '#F59E0B' : '#10B981'}
|
||||||
|
onClose={() => setAiExplainerOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user