feat(dispatch): Phase 1 — LynkDispatch AI skeleton, mock data, routes, sidebar
- mockStore: add DISPATCH_LEADS (12), DISPATCH_REPS (5), DISPATCH_RECOMMENDATIONS (pre-baked AI scores + route paths for all 12 leads), dispatchLeads state, assignDispatchLead() action - LynkDispatchPage: 3-panel command center shell with storm banner, live badge, Storm Mode toggle, KPI bar with real unassigned count - App.jsx: routes /admin/dispatch and /owner/dispatch wired - Layout.jsx: LynkDispatch (Zap icon) added to ADMIN and OWNER sidebar nav
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Zap, CloudLightning, Radio, Inbox, CheckCircle, Clock, AlertTriangle, Bot } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { useMockStore, DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../data/mockStore';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel — themed container used by all 3 side panels
|
||||
// ---------------------------------------------------------------------------
|
||||
const Panel = ({ children, className = '' }) => (
|
||||
<div className={`flex flex-col rounded-2xl border overflow-hidden bg-white border-zinc-200 shadow-sm dark:bg-zinc-900/60 dark:border-white/[0.06] ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const PanelHeader = ({ title, subtitle, accent, right }) => (
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-white/[0.06]"
|
||||
style={{ borderTopColor: accent, borderTopWidth: 2 }}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold uppercase tracking-widest text-zinc-800 dark:text-white">{title}</h2>
|
||||
{subtitle && <p className="text-[11px] text-zinc-400 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
{right && <div className="shrink-0">{right}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KPI Card — bottom bar metric card
|
||||
// ---------------------------------------------------------------------------
|
||||
const KpiCard = ({ label, value, icon: Icon, accent, stormMode, pulse }) => (
|
||||
<div className={`flex items-center gap-3 rounded-xl px-4 py-3 border transition-all duration-500
|
||||
${stormMode && pulse
|
||||
? 'bg-amber-50 border-amber-300 dark:bg-amber-500/10 dark:border-amber-500/40 shadow-amber-200/50 dark:shadow-amber-500/10 shadow-md'
|
||||
: 'bg-white border-zinc-200 shadow-sm dark:bg-zinc-900/60 dark:border-white/[0.06]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: `${accent}18`, color: accent }}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400 truncate">{label}</p>
|
||||
<p className="text-xl font-black font-mono leading-tight text-zinc-900 dark:text-white">{value}</p>
|
||||
</div>
|
||||
{stormMode && pulse && (
|
||||
<span className="ml-auto w-2 h-2 rounded-full bg-amber-500 animate-pulse shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page
|
||||
// ---------------------------------------------------------------------------
|
||||
const LynkDispatchPage = () => {
|
||||
const { theme } = useTheme();
|
||||
const { dispatchLeads } = useMockStore();
|
||||
|
||||
const [selectedLead, setSelectedLead] = useState(null);
|
||||
const [stormMode, setStormMode] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const accent = stormMode ? '#F59E0B' : '#3B82F6';
|
||||
const unassigned = dispatchLeads.filter(l => l.status === 'unassigned').length;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full overflow-hidden bg-zinc-50 dark:bg-[#09090b] ${stormMode ? 'storm-mode' : ''}`}>
|
||||
|
||||
{/* Storm Banner */}
|
||||
<AnimatePresence>
|
||||
{stormMode && (
|
||||
<motion.div
|
||||
key="storm-banner"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="overflow-hidden shrink-0"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 bg-amber-500 px-4 py-2 text-xs font-bold uppercase tracking-widest text-white">
|
||||
<CloudLightning size={14} className="animate-pulse" />
|
||||
Storm Mode Active — Exterior Inspections Restricted — Route Planning Adjusted
|
||||
<CloudLightning size={14} className="animate-pulse" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||
<header className="shrink-0 flex items-center justify-between gap-4 px-4 pt-4 pb-3">
|
||||
{/* Branding */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0 transition-colors duration-500"
|
||||
style={{ backgroundColor: `${accent}20`, color: accent }}
|
||||
>
|
||||
<Zap size={20} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-black uppercase tracking-tight leading-none text-zinc-900 dark:text-white font-[Barlow_Condensed,sans-serif]">
|
||||
LynkDispatch
|
||||
</h1>
|
||||
<p className="text-[11px] text-zinc-400 tracking-wide">AI Dispatch Engine</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right controls */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{/* LIVE badge */}
|
||||
<div className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-emerald-600 dark:text-emerald-400">Live</span>
|
||||
</div>
|
||||
|
||||
{/* Storm Mode Toggle */}
|
||||
<button
|
||||
onClick={() => setStormMode(v => !v)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-xl text-xs font-bold uppercase tracking-wider border transition-all duration-300 ${
|
||||
stormMode
|
||||
? 'bg-amber-500/15 border-amber-400/50 text-amber-600 dark:text-amber-400 shadow-amber-200/40 dark:shadow-amber-500/10 shadow-md'
|
||||
: 'bg-zinc-100 border-zinc-200 text-zinc-500 dark:bg-zinc-800/60 dark:border-white/10 dark:text-zinc-400 hover:border-amber-300 dark:hover:border-amber-500/40'
|
||||
}`}
|
||||
>
|
||||
<CloudLightning size={13} className={stormMode ? 'text-amber-500' : ''} />
|
||||
<span className="hidden sm:inline">Storm Mode</span>
|
||||
{stormMode && <span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── 3-Panel Grid ───────────────────────────────────────────────── */}
|
||||
{/* Desktop: [320px fixed] [flex-1 map] [340px fixed] */}
|
||||
{/* Mobile: single column, scrollable */}
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-[320px_1fr_340px] gap-3 px-4 pb-3 overflow-hidden lg:overflow-hidden">
|
||||
|
||||
{/* LEFT — Lead Queue */}
|
||||
<Panel className="lg:overflow-hidden">
|
||||
<PanelHeader
|
||||
title="Lead Queue"
|
||||
subtitle={`${unassigned} unassigned · ${dispatchLeads.length} total`}
|
||||
accent={accent}
|
||||
right={
|
||||
<span
|
||||
className="text-xs font-bold px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: `${accent}18`, color: accent }}
|
||||
>
|
||||
{dispatchLeads.length}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{/* Placeholder — Phase 2 builds this */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<div className="w-12 h-12 rounded-2xl bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
|
||||
<Inbox size={22} className="text-zinc-400" />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* CENTER — Map */}
|
||||
<Panel className="lg:overflow-hidden min-h-[280px]">
|
||||
<PanelHeader
|
||||
title="Territory Map"
|
||||
subtitle="Plano, TX — Rep routes & lead pins"
|
||||
accent={accent}
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-[10px] text-zinc-400 font-medium">{DISPATCH_REPS.length} reps active</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{/* Placeholder — Phase 4 builds Leaflet map here */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 bg-zinc-50 dark:bg-zinc-950/40 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20 flex items-center justify-center">
|
||||
<Radio size={28} className="text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-700 dark:text-zinc-300">Interactive Map</p>
|
||||
<p className="text-xs text-zinc-400 mt-1">Leaflet map with rep markers & route polylines — Phase 4</p>
|
||||
</div>
|
||||
{/* Rep status pills */}
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||
{[
|
||||
{ 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 => (
|
||||
<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>
|
||||
|
||||
{/* RIGHT — AI Recommendation Drawer */}
|
||||
<Panel className="lg:overflow-hidden">
|
||||
<PanelHeader
|
||||
title="AI Recommendations"
|
||||
subtitle={selectedLead ? `Analyzing ${selectedLead.id}` : 'Select a lead to begin'}
|
||||
accent={accent}
|
||||
right={
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center"
|
||||
style={{ backgroundColor: `${accent}18`, color: accent }}
|
||||
>
|
||||
<Bot size={13} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{/* Empty state — Phase 3 builds this */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.05, 1] }}
|
||||
transition={{ duration: 2.5, repeat: Infinity, ease: 'easeInOut' }}
|
||||
className="w-14 h-14 rounded-2xl border-2 border-dashed border-zinc-200 dark:border-zinc-700 flex items-center justify-center"
|
||||
>
|
||||
<Bot size={24} className="text-zinc-300 dark:text-zinc-600" />
|
||||
</motion.div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-500 dark:text-zinc-400">
|
||||
{selectedLead ? 'Processing...' : 'No lead selected'}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-400 mt-1">
|
||||
Click a lead in the queue to see AI-scored rep recommendations
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[10px] text-zinc-300 dark:text-zinc-700 mt-2 font-medium uppercase tracking-widest">
|
||||
Recommendation engine — Phase 3
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* ── KPI Bar ────────────────────────────────────────────────────── */}
|
||||
<div className="shrink-0 grid grid-cols-2 lg:grid-cols-4 gap-3 px-4 pb-4">
|
||||
<KpiCard
|
||||
label="Leads in Queue"
|
||||
value={unassigned}
|
||||
icon={Inbox}
|
||||
accent={stormMode ? '#F59E0B' : '#3B82F6'}
|
||||
stormMode={stormMode}
|
||||
pulse={false}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Assigned Today"
|
||||
value={28}
|
||||
icon={CheckCircle}
|
||||
accent="#10B981"
|
||||
stormMode={stormMode}
|
||||
pulse={false}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Avg Response"
|
||||
value="4.2 min"
|
||||
icon={Clock}
|
||||
accent="#8B5CF6"
|
||||
stormMode={stormMode}
|
||||
pulse={false}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Storm Alerts"
|
||||
value={stormMode ? 3 : 2}
|
||||
icon={AlertTriangle}
|
||||
accent={stormMode ? '#F59E0B' : '#6B7280'}
|
||||
stormMode={stormMode}
|
||||
pulse={stormMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LynkDispatchPage;
|
||||
Reference in New Issue
Block a user