feat(dispatch): Phase 6 — weekly performance chart modal and activity log drawer

- DispatchWeeklyChart: Recharts ComposedChart (Area + Line), custom dots for storm/today/emergency days, rep leaderboard with animated progress bars
- DispatchChartModal: centered modal (max-w-2xl, 88vh), portaled to document.body, scale+fade animation, ESC and backdrop close
- DispatchLogDrawer: right-side sliding drawer (400px sm+, full-width mobile), portaled, spring animation from right, full-height scrollable log, ESC and backdrop close
- Storm mode styling on both — amber tint on headers and borders
- DISPATCH_WEEKLY_STATS: 7-day mock data (Mon Mar 20 – Sun Mar 26) with per-rep breakdown, storm day Thursday, partial Sunday
This commit is contained in:
Satyam
2026-03-26 14:14:13 +05:30
parent a636e23568
commit e4c75b7cfd
4 changed files with 1245 additions and 0 deletions
@@ -0,0 +1,213 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { History, ChevronDown, Zap } from 'lucide-react';
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const REP_STATUS_COLORS = { available: '#10B981', en_route: '#3B82F6', busy: '#F59E0B' };
const URGENCY_COLORS = {
emergency: '#EF4444',
high: '#F59E0B',
standard: '#6B7280',
};
const LEAD_TYPE_SHORT = {
emergency_tarp: 'Emerg. Tarp',
roof_inspection: 'Inspection',
insurance_claim: 'Insurance',
retail_estimate: 'Estimate',
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function timeAgo(ts) {
const secs = Math.floor((Date.now() - ts) / 1000);
if (secs < 10) return 'Just now';
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins} min ago`;
return `${Math.floor(mins / 60)}h ago`;
}
// ---------------------------------------------------------------------------
// Single log entry row
// ---------------------------------------------------------------------------
const LogEntry = ({ entry, stormMode }) => {
const repColor = REP_STATUS_COLORS[entry.repStatus] || '#6B7280';
const urgColor = URGENCY_COLORS[entry.urgency] || '#6B7280';
const isEmerg = entry.urgency === 'emergency';
return (
<motion.div
initial={{ opacity: 0, x: -16 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 16, transition: { duration: 0.15 } }}
transition={{ type: 'spring', stiffness: 420, damping: 32 }}
className="flex items-center gap-2.5 px-3 py-2.5 border-b border-zinc-50 dark:border-white/[0.03] last:border-0"
style={isEmerg && stormMode
? { borderLeftWidth: 2, borderLeftColor: '#F59E0B', backgroundColor: '#F59E0B06' }
: undefined
}
>
{/* Rep avatar */}
<div
className="w-6 h-6 rounded-lg flex items-center justify-center text-[9px] font-black text-white shrink-0"
style={{ backgroundColor: repColor }}
>
{entry.repInitials}
</div>
{/* Dispatch info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 min-w-0">
<span className="text-[11px] font-semibold text-zinc-800 dark:text-zinc-100 shrink-0">
{entry.repFirstName}
</span>
<span className="text-[10px] text-zinc-300 dark:text-zinc-600 shrink-0"></span>
<span className="text-[11px] text-zinc-600 dark:text-zinc-300 truncate">
{entry.customerName}
</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[9px] text-zinc-400 truncate">{entry.address}</span>
<span
className="text-[9px] font-bold px-1 py-px rounded shrink-0"
style={{ backgroundColor: `${urgColor}15`, color: urgColor }}
>
{LEAD_TYPE_SHORT[entry.leadType] || entry.leadType}
</span>
</div>
</div>
{/* Timestamp */}
<span className="text-[9px] text-zinc-300 dark:text-zinc-600 shrink-0 tabular-nums">
{timeAgo(entry.timestamp)}
</span>
</motion.div>
);
};
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
const DispatchActivityLog = ({ log, accent, stormMode }) => {
const [open, setOpen] = useState(false);
const [tick, setTick] = useState(0);
// Refresh relative timestamps every 30s
useEffect(() => {
const t = setInterval(() => setTick(n => n + 1), 30000);
return () => clearInterval(t);
}, []);
// Auto-expand when first entry arrives
useEffect(() => {
if (log.length === 1) setOpen(true);
}, [log.length]);
return (
<div className="shrink-0 px-4 pb-3">
{/* ── Section header (always visible) ── */}
<button
onClick={() => setOpen(v => !v)}
className="w-full flex items-center justify-between py-2 group"
>
<div className="flex items-center gap-2">
<History size={12} className="text-zinc-400" />
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500">
Dispatch Log
</span>
<AnimatePresence mode="wait">
{log.length > 0 ? (
<motion.span
key="count"
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.6, opacity: 0 }}
transition={{ type: 'spring', stiffness: 500, damping: 28 }}
className="text-[10px] font-black px-1.5 py-0.5 rounded-full"
style={{ backgroundColor: `${accent}18`, color: accent }}
>
{log.length}
</motion.span>
) : (
<motion.span
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="text-[10px] text-zinc-300 dark:text-zinc-700"
>
· No activity yet
</motion.span>
)}
</AnimatePresence>
</div>
<ChevronDown
size={13}
className={`text-zinc-400 transition-transform duration-200 ${open ? '' : '-rotate-90'}`}
/>
</button>
{/* ── Expandable log body ── */}
<AnimatePresence initial={false}>
{open && (
<motion.div
key="log-body"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.22, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
<div className={`rounded-xl border overflow-hidden transition-colors duration-500 ${
stormMode
? 'border-amber-200 dark:border-amber-500/20'
: 'border-zinc-200 dark:border-white/[0.06]'
} bg-white dark:bg-zinc-900/60`}>
{log.length === 0 ? (
/* Empty state */
<div className="flex flex-col items-center justify-center gap-2 py-5 px-4">
<div
className="w-8 h-8 rounded-xl flex items-center justify-center"
style={{ backgroundColor: `${accent}15`, color: accent }}
>
<Zap size={15} />
</div>
<p className="text-[11px] text-zinc-400 text-center">
Assign a lead to start logging
</p>
</div>
) : (
/* Log entries — newest at top, max-height scroll */
<div
className="overflow-y-auto custom-scrollbar"
style={{ maxHeight: 200 }}
// tick is read to trigger re-render for timeAgo updates
data-tick={tick}
>
<AnimatePresence initial={false}>
{log.map(entry => (
<LogEntry
key={entry.id}
entry={entry}
stormMode={stormMode}
/>
))}
</AnimatePresence>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default DispatchActivityLog;
@@ -0,0 +1,405 @@
import React, { useEffect } from 'react';
import ReactDOM from 'react-dom';
import { motion } from 'framer-motion';
import {
ComposedChart, Area, Line, XAxis, YAxis,
CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
} from 'recharts';
import { X, BarChart2, Users } from 'lucide-react';
import { useTheme } from '../../context/ThemeContext';
import { DISPATCH_REPS, DISPATCH_WEEKLY_STATS } from '../../data/mockStore';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const REP_STATUS_COLORS = { available: '#10B981', en_route: '#3B82F6', busy: '#F59E0B' };
// ---------------------------------------------------------------------------
// Custom Recharts dot — highlights storm day, today, emergency days
// ---------------------------------------------------------------------------
const makeAreaDot = (areaColor) => (props) => {
const { cx, cy, payload } = props;
if (cx === undefined || cy === undefined) return null;
if (payload.isToday) {
return (
<g key={`dot-today-${cx}`}>
<circle cx={cx} cy={cy} r={8} fill={areaColor} opacity={0.15} />
<circle cx={cx} cy={cy} r={4.5} fill={areaColor} stroke="white" strokeWidth={2} />
</g>
);
}
if (payload.stormDay) {
return (
<g key={`dot-storm-${cx}`}>
<circle cx={cx} cy={cy} r={6.5} fill="#F59E0B" opacity={0.18} />
<circle cx={cx} cy={cy} r={4} fill="#F59E0B" stroke="white" strokeWidth={2} />
</g>
);
}
if (payload.emergency > 0) {
return <circle key={`dot-emerg-${cx}`} cx={cx} cy={cy} r={3.5} fill="#EF4444" stroke="white" strokeWidth={1.5} />;
}
return <circle key={`dot-${cx}`} cx={cx} cy={cy} r={3} fill={areaColor} strokeWidth={0} />;
};
// ---------------------------------------------------------------------------
// Custom Tooltip
// ---------------------------------------------------------------------------
const ChartTooltip = ({ active, payload, isDark }) => {
if (!active || !payload?.length) return null;
const d = payload[0]?.payload;
if (!d) return null;
const bg = isDark ? '#1c1c1e' : '#ffffff';
const borderColor = d.stormDay ? '#F59E0B' : d.isToday ? '#3B82F6' : (isDark ? 'rgba(255,255,255,0.1)' : '#e4e4e7');
const textPrimary = isDark ? '#f4f4f5' : '#18181b';
const textMuted = isDark ? '#71717a' : '#a1a1aa';
return (
<div style={{
background: bg, border: `1.5px solid ${borderColor}`,
borderRadius: 12, padding: '10px 14px',
boxShadow: '0 8px 24px rgba(0,0,0,0.16)',
minWidth: 155, pointerEvents: 'none',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 9 }}>
<span style={{ fontSize: 12, fontWeight: 800, color: textPrimary }}>{d.date}</span>
{d.stormDay && (
<span style={{ fontSize: 9, fontWeight: 800, color: '#F59E0B', background: '#F59E0B18', padding: '2px 6px', borderRadius: 8 }}>
Storm Day
</span>
)}
{d.isToday && (
<span style={{ fontSize: 9, fontWeight: 800, color: '#3B82F6', background: '#3B82F618', padding: '2px 6px', borderRadius: 8 }}>
Today
</span>
)}
</div>
<TooltipRow label="Dispatched" value={d.dispatched} color="#3B82F6" textMuted={textMuted} />
<TooltipRow label="Avg Wait" value={`${d.avgWait} min`} color="#8B5CF6" textMuted={textMuted} />
{d.emergency > 0 && (
<TooltipRow label="Emergency" value={d.emergency} color="#EF4444" textMuted={textMuted} />
)}
</div>
);
};
const TooltipRow = ({ label, value, color, textMuted }) => (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 20, marginTop: 4 }}>
<span style={{ fontSize: 11, color: textMuted }}>{label}</span>
<span style={{ fontSize: 11, fontWeight: 800, color }}>{value}</span>
</div>
);
// ---------------------------------------------------------------------------
// Summary stat chip
// ---------------------------------------------------------------------------
const StatChip = ({ label, value, color }) => (
<div className="flex-1 flex flex-col items-center justify-center py-3 px-2 min-w-0">
<span className="text-base font-black leading-tight tabular-nums" style={{ color }}>{value}</span>
<span className="text-[9px] text-zinc-400 mt-0.5 text-center leading-tight">{label}</span>
</div>
);
// ---------------------------------------------------------------------------
// Chart legend item
// ---------------------------------------------------------------------------
const LegendDash = ({ color, dashed }) => (
<div className="flex items-center gap-0.5">
<div className="w-3 h-0.5 rounded-full" style={{ backgroundColor: color }} />
{dashed && <div className="w-1.5 h-0.5" />}
{dashed && <div className="w-3 h-0.5 rounded-full" style={{ backgroundColor: color }} />}
</div>
);
// ---------------------------------------------------------------------------
// Rep performance leaderboard
// ---------------------------------------------------------------------------
const RepLeaderboard = () => {
const repTotals = DISPATCH_REPS.map(rep => ({
...rep,
weekDispatched: DISPATCH_WEEKLY_STATS.reduce((s, d) => s + (d.repBreakdown[rep.id] || 0), 0),
})).sort((a, b) => b.weekDispatched - a.weekDispatched);
const max = repTotals[0]?.weekDispatched || 1;
return (
<div className="px-5 pt-4 pb-5">
<div className="flex items-center gap-2 mb-3">
<Users size={11} className="text-zinc-400" />
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500">
Rep Performance This Week
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2.5">
{repTotals.map((rep, i) => {
const color = REP_STATUS_COLORS[rep.status] || '#6B7280';
const pct = (rep.weekDispatched / max) * 100;
return (
<div key={rep.id} className="flex items-center gap-2.5">
{/* Rank */}
<span className="text-[9px] font-black text-zinc-300 dark:text-zinc-600 w-3 tabular-nums shrink-0">
#{i + 1}
</span>
{/* Avatar */}
<div
className="w-6 h-6 rounded-lg flex items-center justify-center text-[9px] font-black text-white shrink-0"
style={{ backgroundColor: color }}
>
{rep.initials}
</div>
{/* Name */}
<span className="text-[11px] font-semibold text-zinc-700 dark:text-zinc-300 w-14 truncate shrink-0">
{rep.name.split(' ')[0]}
</span>
{/* Bar */}
<div className="flex-1 h-1.5 rounded-full bg-zinc-100 dark:bg-white/[0.06] overflow-hidden">
<motion.div
className="h-full rounded-full"
style={{ backgroundColor: color }}
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ delay: 0.06 * i + 0.3, duration: 0.55, ease: 'easeOut' }}
/>
</div>
{/* Count */}
<span className="text-[12px] font-black tabular-nums w-5 text-right shrink-0" style={{ color }}>
{rep.weekDispatched}
</span>
{/* Rating */}
<span className="text-[10px] text-zinc-400 tabular-nums shrink-0 w-8">
{rep.rating}
</span>
</div>
);
})}
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
const DispatchChartModal = ({ accent, stormMode, onClose }) => {
const { isDark } = useTheme();
// ESC key
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
// Derived summary stats
const totalDispatched = DISPATCH_WEEKLY_STATS.reduce((s, d) => s + d.dispatched, 0);
const avgWaitOverall = Math.round(
DISPATCH_WEEKLY_STATS.reduce((s, d) => s + d.avgWait, 0) / DISPATCH_WEEKLY_STATS.length
);
const totalEmergency = DISPATCH_WEEKLY_STATS.reduce((s, d) => s + d.emergency, 0);
const areaColor = stormMode ? '#F59E0B' : '#3B82F6';
const gridColor = isDark ? 'rgba(255,255,255,0.05)' : '#f0f0f0';
const tickColor = isDark ? '#52525b' : '#a1a1aa';
const renderAreaDot = makeAreaDot(areaColor);
return ReactDOM.createPortal(
<>
{/* Backdrop */}
<motion.div
key="chart-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.22 }}
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-[3px]"
onClick={onClose}
/>
{/* Modal panel */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<motion.div
key="chart-modal"
initial={{ opacity: 0, scale: 0.94, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.94, y: 12 }}
transition={{ type: 'spring', stiffness: 400, damping: 36 }}
className="w-full max-w-2xl max-h-[88vh] flex flex-col rounded-2xl overflow-hidden shadow-2xl pointer-events-auto bg-white dark:bg-zinc-900"
style={{
border: `1.5px solid ${stormMode ? '#F59E0B40' : (isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.07)')}`,
}}
>
{/* ── Modal header ── */}
<div className={`flex items-center justify-between px-5 py-4 border-b shrink-0 transition-colors duration-500 ${
stormMode
? 'border-amber-200 dark:border-amber-500/20 bg-amber-50/60 dark:bg-amber-500/[0.04]'
: 'border-zinc-100 dark:border-white/[0.06]'
}`}>
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-xl flex items-center justify-center shrink-0"
style={{ backgroundColor: `${areaColor}15`, color: areaColor }}
>
<BarChart2 size={16} />
</div>
<div>
<h2 className="text-sm font-black uppercase tracking-wider text-zinc-800 dark:text-white leading-none">
Weekly Performance
</h2>
<p className="text-[10px] text-zinc-400 mt-0.5">
Mar 20 Mar 26, 2026
</p>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-xl flex items-center justify-center text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/[0.08] transition-colors"
>
<X size={16} />
</button>
</div>
{/* ── Scrollable body ── */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{/* Summary stat chips */}
<div className="flex items-stretch border-b border-zinc-100 dark:border-white/[0.05]">
<StatChip label="Dispatched" value={totalDispatched} color={areaColor} />
<div className="w-px bg-zinc-100 dark:bg-white/[0.05] shrink-0" />
<StatChip label="Avg Response" value={`${avgWaitOverall}m`} color="#8B5CF6" />
<div className="w-px bg-zinc-100 dark:bg-white/[0.05] shrink-0" />
<StatChip label="Emergencies" value={totalEmergency} color="#EF4444" />
<div className="w-px bg-zinc-100 dark:bg-white/[0.05] shrink-0" />
<StatChip label="Storm Day" value="Thu" color="#F59E0B" />
</div>
{/* Chart */}
<div className="px-3 pt-5 pb-2">
<ResponsiveContainer width="100%" height={210}>
<ComposedChart
data={DISPATCH_WEEKLY_STATS}
margin={{ top: 6, right: 42, left: -14, bottom: 0 }}
>
<defs>
<linearGradient id="modalAreaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={areaColor} stopOpacity={0.3} />
<stop offset="95%" stopColor={areaColor} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" stroke={gridColor} />
<XAxis
dataKey="day"
tick={{ fontSize: 11, fill: tickColor, fontWeight: 700 }}
axisLine={false}
tickLine={false}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 10, fill: tickColor }}
axisLine={false}
tickLine={false}
domain={[0, 16]}
ticks={[0, 4, 8, 12, 16]}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 10, fill: '#8B5CF6' }}
axisLine={false}
tickLine={false}
domain={[0, 30]}
ticks={[0, 10, 20, 30]}
tickFormatter={v => `${v}m`}
/>
<Tooltip
content={<ChartTooltip isDark={isDark} />}
cursor={{
stroke: isDark ? 'rgba(255,255,255,0.08)' : '#e4e4e7',
strokeWidth: 1,
strokeDasharray: '4 3',
}}
/>
<ReferenceLine
x="Thu"
yAxisId="left"
stroke="#F59E0B"
strokeWidth={1.5}
strokeDasharray="4 3"
label={{
value: '⚡',
position: 'insideTopLeft',
fill: '#F59E0B',
fontSize: 12,
dy: -2,
}}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="dispatched"
fill="url(#modalAreaGradient)"
stroke={areaColor}
strokeWidth={2.5}
dot={renderAreaDot}
activeDot={{ r: 6, fill: areaColor, stroke: 'white', strokeWidth: 2.5 }}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="avgWait"
stroke="#8B5CF6"
strokeWidth={1.5}
strokeDasharray="5 3"
dot={{ r: 2.5, fill: '#8B5CF6', strokeWidth: 0 }}
activeDot={{ r: 5, fill: '#8B5CF6', stroke: 'white', strokeWidth: 2 }}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="flex items-center gap-5 px-5 pb-4">
<div className="flex items-center gap-1.5">
<LegendDash color={areaColor} dashed={false} />
<span className="text-[10px] text-zinc-400">Dispatched</span>
</div>
<div className="flex items-center gap-1.5">
<LegendDash color="#8B5CF6" dashed />
<span className="text-[10px] text-zinc-400">Avg Wait</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: '#F59E0B', opacity: 0.85 }} />
<span className="text-[10px] text-zinc-400">Storm Day</span>
</div>
<div className="flex items-center gap-1.5">
<div
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: areaColor, boxShadow: `0 0 0 2px white, 0 0 0 3.5px ${areaColor}` }}
/>
<span className="text-[10px] text-zinc-400">Today</span>
</div>
</div>
<div className="border-t border-zinc-100 dark:border-white/[0.05]" />
{/* Rep leaderboard */}
<RepLeaderboard />
</div>
</motion.div>
</div>
</>,
document.body
);
};
export default DispatchChartModal;
@@ -0,0 +1,211 @@
import React, { useEffect } from 'react';
import ReactDOM from 'react-dom';
import { motion } from 'framer-motion';
import { X, History, Zap } from 'lucide-react';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const REP_STATUS_COLORS = { available: '#10B981', en_route: '#3B82F6', busy: '#F59E0B' };
const URGENCY_COLORS = {
emergency: '#EF4444',
high: '#F59E0B',
standard: '#6B7280',
};
const LEAD_TYPE_SHORT = {
emergency_tarp: 'Emerg. Tarp',
roof_inspection: 'Inspection',
insurance_claim: 'Insurance',
retail_estimate: 'Estimate',
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function timeAgo(ts) {
const secs = Math.floor((Date.now() - ts) / 1000);
if (secs < 10) return 'Just now';
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins} min ago`;
return `${Math.floor(mins / 60)}h ago`;
}
// ---------------------------------------------------------------------------
// Single log entry row
// ---------------------------------------------------------------------------
const LogEntry = ({ entry, stormMode }) => {
const repColor = REP_STATUS_COLORS[entry.repStatus] || '#6B7280';
const urgColor = URGENCY_COLORS[entry.urgency] || '#6B7280';
const isEmerg = entry.urgency === 'emergency';
return (
<div
className="flex items-center gap-3 px-4 py-3 border-b border-zinc-50 dark:border-white/[0.04] last:border-0 transition-colors hover:bg-zinc-50 dark:hover:bg-white/[0.02]"
style={isEmerg && stormMode
? { borderLeftWidth: 3, borderLeftColor: '#F59E0B', backgroundColor: '#F59E0B06' }
: undefined
}
>
{/* Rep avatar */}
<div
className="w-8 h-8 rounded-xl flex items-center justify-center text-[10px] font-black text-white shrink-0 shadow-sm"
style={{ backgroundColor: repColor }}
>
{entry.repInitials}
</div>
{/* Dispatch info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[12px] font-bold text-zinc-800 dark:text-zinc-100 shrink-0">
{entry.repFirstName}
</span>
<span className="text-[11px] text-zinc-300 dark:text-zinc-600 shrink-0"></span>
<span className="text-[12px] text-zinc-600 dark:text-zinc-300 truncate">
{entry.customerName}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-zinc-400 truncate">{entry.address}</span>
<span
className="text-[9px] font-bold px-1.5 py-px rounded shrink-0"
style={{ backgroundColor: `${urgColor}15`, color: urgColor }}
>
{LEAD_TYPE_SHORT[entry.leadType] || entry.leadType}
</span>
</div>
</div>
{/* Timestamp */}
<span className="text-[10px] text-zinc-300 dark:text-zinc-600 shrink-0 tabular-nums">
{timeAgo(entry.timestamp)}
</span>
</div>
);
};
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
const EmptyState = ({ accent }) => (
<div className="flex flex-col items-center justify-center gap-3 h-full py-20 px-6">
<div
className="w-12 h-12 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: `${accent}15`, color: accent }}
>
<Zap size={20} />
</div>
<div className="text-center">
<p className="text-sm font-semibold text-zinc-500 dark:text-zinc-400">No activity yet</p>
<p className="text-xs text-zinc-400 dark:text-zinc-600 mt-1">
Assign a lead to start logging dispatches
</p>
</div>
</div>
);
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
const DispatchLogDrawer = ({ log, accent, stormMode, onClose }) => {
// ESC key
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
return ReactDOM.createPortal(
<>
{/* Backdrop */}
<motion.div
key="log-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-[2px]"
onClick={onClose}
/>
{/* Drawer */}
<motion.div
key="log-drawer"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', stiffness: 380, damping: 38 }}
className="fixed right-0 top-0 bottom-0 z-50 w-full sm:w-[400px] flex flex-col bg-white dark:bg-zinc-900 shadow-2xl"
style={{
borderLeft: `1.5px solid ${stormMode ? '#F59E0B40' : 'rgba(0,0,0,0.07)'}`,
}}
>
{/* ── Header ── */}
<div className={`flex items-center justify-between px-4 py-3.5 border-b shrink-0 transition-colors duration-500 ${
stormMode
? 'border-amber-200 dark:border-amber-500/20 bg-amber-50/60 dark:bg-amber-500/[0.04]'
: 'border-zinc-100 dark:border-white/[0.06] bg-white dark:bg-zinc-900'
}`}>
<div className="flex items-center gap-2.5">
<div
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0"
style={{ backgroundColor: `${accent}15`, color: accent }}
>
<History size={14} />
</div>
<div>
<span className="text-sm font-black uppercase tracking-wider text-zinc-800 dark:text-white">
Dispatch Log
</span>
{log.length > 0 && (
<span
className="ml-2 text-[10px] font-black px-1.5 py-0.5 rounded-full"
style={{ backgroundColor: `${accent}18`, color: accent }}
>
{log.length}
</span>
)}
</div>
</div>
<button
onClick={onClose}
className="w-7 h-7 rounded-lg flex items-center justify-center text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/[0.08] transition-colors"
>
<X size={15} />
</button>
</div>
{/* ── Log entries — full height, scrollable ── */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{log.length === 0 ? (
<EmptyState accent={accent} />
) : (
log.map(entry => (
<LogEntry
key={entry.id}
entry={entry}
stormMode={stormMode}
/>
))
)}
</div>
{/* ── Footer ── */}
{log.length > 0 && (
<div className="shrink-0 px-4 py-2.5 border-t border-zinc-100 dark:border-white/[0.06]">
<p className="text-[9px] text-zinc-300 dark:text-zinc-600 text-center uppercase tracking-widest font-bold">
Showing {log.length} of {log.length} entries · Newest first
</p>
</div>
)}
</motion.div>
</>,
document.body
);
};
export default DispatchLogDrawer;
@@ -0,0 +1,416 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
ComposedChart, Area, Line, XAxis, YAxis,
CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
} from 'recharts';
import { BarChart2, ChevronDown, Users } from 'lucide-react';
import { useTheme } from '../../context/ThemeContext';
import { DISPATCH_REPS, DISPATCH_WEEKLY_STATS } from '../../data/mockStore';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const REP_STATUS_COLORS = { available: '#10B981', en_route: '#3B82F6', busy: '#F59E0B' };
// ---------------------------------------------------------------------------
// Custom Recharts dot — highlights storm day, today, and emergency days
// ---------------------------------------------------------------------------
const makeAreaDot = (areaColor) => (props) => {
const { cx, cy, payload } = props;
if (cx === undefined || cy === undefined) return null;
if (payload.isToday) {
return (
<g key={`dot-today-${cx}`}>
<circle cx={cx} cy={cy} r={7} fill={areaColor} opacity={0.18} />
<circle cx={cx} cy={cy} r={4.5} fill={areaColor} stroke="white" strokeWidth={2} />
</g>
);
}
if (payload.stormDay) {
return (
<g key={`dot-storm-${cx}`}>
<circle cx={cx} cy={cy} r={6} fill="#F59E0B" opacity={0.2} />
<circle cx={cx} cy={cy} r={4} fill="#F59E0B" stroke="white" strokeWidth={2} />
</g>
);
}
if (payload.emergency > 0) {
return <circle key={`dot-emerg-${cx}`} cx={cx} cy={cy} r={3.5} fill="#EF4444" stroke="white" strokeWidth={1.5} />;
}
return <circle key={`dot-${cx}`} cx={cx} cy={cy} r={3} fill={areaColor} strokeWidth={0} />;
};
// ---------------------------------------------------------------------------
// Custom Tooltip
// ---------------------------------------------------------------------------
const ChartTooltip = ({ active, payload, isDark }) => {
if (!active || !payload?.length) return null;
const d = payload[0]?.payload;
if (!d) return null;
const bg = isDark ? '#1c1c1e' : '#ffffff';
const borderColor = d.stormDay ? '#F59E0B' : d.isToday ? '#3B82F6' : (isDark ? 'rgba(255,255,255,0.1)' : '#e4e4e7');
const textPrimary = isDark ? '#f4f4f5' : '#18181b';
const textMuted = isDark ? '#71717a' : '#a1a1aa';
return (
<div style={{
background: bg, border: `1.5px solid ${borderColor}`,
borderRadius: 12, padding: '10px 14px',
boxShadow: '0 8px 24px rgba(0,0,0,0.16)',
minWidth: 155, pointerEvents: 'none',
}}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 9 }}>
<span style={{ fontSize: 12, fontWeight: 800, color: textPrimary }}>{d.date}</span>
{d.stormDay && (
<span style={{ fontSize: 9, fontWeight: 800, color: '#F59E0B', background: '#F59E0B18', padding: '2px 6px', borderRadius: 8 }}>
Storm Day
</span>
)}
{d.isToday && (
<span style={{ fontSize: 9, fontWeight: 800, color: '#3B82F6', background: '#3B82F618', padding: '2px 6px', borderRadius: 8 }}>
Today
</span>
)}
</div>
{/* Rows */}
<TooltipRow label="Dispatched" value={d.dispatched} color="#3B82F6" textMuted={textMuted} />
<TooltipRow label="Avg Wait" value={`${d.avgWait} min`} color="#8B5CF6" textMuted={textMuted} />
{d.emergency > 0 && (
<TooltipRow label="Emergency" value={d.emergency} color="#EF4444" textMuted={textMuted} />
)}
</div>
);
};
const TooltipRow = ({ label, value, color, textMuted }) => (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 20, marginTop: 4 }}>
<span style={{ fontSize: 11, color: textMuted }}>{label}</span>
<span style={{ fontSize: 11, fontWeight: 800, color }}>{value}</span>
</div>
);
// ---------------------------------------------------------------------------
// Summary stat chip (top of expanded panel)
// ---------------------------------------------------------------------------
const StatChip = ({ label, value, color }) => (
<div className="flex-1 flex flex-col items-center justify-center py-2.5 px-2 min-w-0">
<span className="text-sm font-black leading-tight tabular-nums" style={{ color }}>{value}</span>
<span className="text-[9px] text-zinc-400 mt-0.5 text-center leading-tight">{label}</span>
</div>
);
// ---------------------------------------------------------------------------
// Chart legend row
// ---------------------------------------------------------------------------
const LegendDash = ({ color, dashed }) => (
<div className="flex items-center gap-0.5">
<div className="w-3 h-0.5 rounded-full" style={{ backgroundColor: color }} />
{dashed && <div className="w-1.5 h-0.5" />}
{dashed && <div className="w-3 h-0.5 rounded-full" style={{ backgroundColor: color }} />}
</div>
);
// ---------------------------------------------------------------------------
// Rep performance leaderboard
// ---------------------------------------------------------------------------
const RepLeaderboard = ({ open }) => {
const repTotals = DISPATCH_REPS.map(rep => ({
...rep,
weekDispatched: DISPATCH_WEEKLY_STATS.reduce((s, d) => s + (d.repBreakdown[rep.id] || 0), 0),
})).sort((a, b) => b.weekDispatched - a.weekDispatched);
const max = repTotals[0]?.weekDispatched || 1;
return (
<div className="px-4 pt-3 pb-4">
<div className="flex items-center gap-1.5 mb-3">
<Users size={10} className="text-zinc-400" />
<span className="text-[9px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500">
Rep Performance This Week
</span>
</div>
<div className="flex flex-col gap-2.5">
{repTotals.map((rep, i) => {
const color = REP_STATUS_COLORS[rep.status] || '#6B7280';
const pct = (rep.weekDispatched / max) * 100;
return (
<div key={rep.id} className="flex items-center gap-2.5">
{/* Rank */}
<span className="text-[9px] font-black text-zinc-300 dark:text-zinc-600 w-2.5 tabular-nums shrink-0">
{i + 1}
</span>
{/* Avatar */}
<div
className="w-5 h-5 rounded-md flex items-center justify-center text-[8px] font-black text-white shrink-0"
style={{ backgroundColor: color }}
>
{rep.initials}
</div>
{/* Name */}
<span className="text-[10px] font-semibold text-zinc-700 dark:text-zinc-300 w-12 truncate shrink-0">
{rep.name.split(' ')[0]}
</span>
{/* Bar */}
<div className="flex-1 h-1.5 rounded-full bg-zinc-100 dark:bg-white/[0.06] overflow-hidden">
<motion.div
className="h-full rounded-full"
style={{ backgroundColor: color }}
initial={{ width: 0 }}
animate={open ? { width: `${pct}%` } : { width: 0 }}
transition={{ delay: 0.06 * i + 0.2, duration: 0.5, ease: 'easeOut' }}
/>
</div>
{/* Count */}
<span className="text-[11px] font-black tabular-nums w-4 text-right shrink-0" style={{ color }}>
{rep.weekDispatched}
</span>
{/* Rating */}
<span className="text-[9px] text-zinc-400 tabular-nums shrink-0 w-7">
{rep.rating}
</span>
</div>
);
})}
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
const DispatchWeeklyChart = ({ accent, stormMode }) => {
const { isDark } = useTheme();
const [open, setOpen] = useState(false);
// Summary stats derived from static data
const totalDispatched = DISPATCH_WEEKLY_STATS.reduce((s, d) => s + d.dispatched, 0);
const avgWaitOverall = Math.round(
DISPATCH_WEEKLY_STATS.reduce((s, d) => s + d.avgWait, 0) / DISPATCH_WEEKLY_STATS.length
);
const totalEmergency = DISPATCH_WEEKLY_STATS.reduce((s, d) => s + d.emergency, 0);
const areaColor = stormMode ? '#F59E0B' : '#3B82F6';
const gridColor = isDark ? 'rgba(255,255,255,0.05)' : '#f0f0f0';
const tickColor = isDark ? '#52525b' : '#a1a1aa';
const renderAreaDot = makeAreaDot(areaColor);
return (
<div className="shrink-0 px-4 pb-4">
{/* ── Section header (always visible) ── */}
<button
onClick={() => setOpen(v => !v)}
className="w-full flex items-center justify-between py-2 group"
>
<div className="flex items-center gap-2 min-w-0">
<BarChart2 size={12} className="text-zinc-400 shrink-0" />
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500">
Weekly Performance
</span>
<span className="text-[10px] text-zinc-300 dark:text-zinc-600 hidden sm:inline shrink-0">
Mar 2026
</span>
{/* Collapsed-state preview badges */}
<AnimatePresence>
{!open && (
<motion.div
key="preview"
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -6 }}
transition={{ duration: 0.16 }}
className="flex items-center gap-1.5 ml-0.5"
>
<span
className="text-[10px] font-black px-1.5 py-0.5 rounded-full"
style={{ backgroundColor: `${areaColor}18`, color: areaColor }}
>
{totalDispatched}
</span>
{totalEmergency > 0 && (
<span
className="text-[10px] font-black px-1.5 py-0.5 rounded-full"
style={{ backgroundColor: '#EF444418', color: '#EF4444' }}
>
{totalEmergency} emerg.
</span>
)}
</motion.div>
)}
</AnimatePresence>
</div>
<ChevronDown
size={13}
className={`text-zinc-400 transition-transform duration-200 shrink-0 ${open ? '' : '-rotate-90'}`}
/>
</button>
{/* ── Expandable body ── */}
<AnimatePresence initial={false}>
{open && (
<motion.div
key="chart-body"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.24, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
<div className={`rounded-xl border overflow-hidden transition-colors duration-500 ${
stormMode
? 'border-amber-200 dark:border-amber-500/20'
: 'border-zinc-200 dark:border-white/[0.06]'
} bg-white dark:bg-zinc-900/60`}>
{/* ── Summary stats strip ── */}
<div className="flex items-stretch border-b border-zinc-100 dark:border-white/[0.05]">
<StatChip label="Dispatched" value={totalDispatched} color={areaColor} />
<div className="w-px bg-zinc-100 dark:bg-white/[0.05] shrink-0" />
<StatChip label="Avg Response" value={`${avgWaitOverall}m`} color="#8B5CF6" />
<div className="w-px bg-zinc-100 dark:bg-white/[0.05] shrink-0" />
<StatChip label="Emergencies" value={totalEmergency} color="#EF4444" />
<div className="w-px bg-zinc-100 dark:bg-white/[0.05] shrink-0" />
<StatChip label="Storm Day" value="Thu" color="#F59E0B" />
</div>
{/* ── Chart ── */}
<div className="px-2 pt-4 pb-1">
<ResponsiveContainer width="100%" height={185}>
<ComposedChart
data={DISPATCH_WEEKLY_STATS}
margin={{ top: 6, right: 38, left: -18, bottom: 0 }}
>
<defs>
<linearGradient id="dispatchGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={areaColor} stopOpacity={0.3} />
<stop offset="95%" stopColor={areaColor} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid
vertical={false}
strokeDasharray="3 3"
stroke={gridColor}
/>
<XAxis
dataKey="day"
tick={{ fontSize: 10, fill: tickColor, fontWeight: 700 }}
axisLine={false}
tickLine={false}
/>
{/* Left Y — dispatched count */}
<YAxis
yAxisId="left"
tick={{ fontSize: 9, fill: tickColor }}
axisLine={false}
tickLine={false}
domain={[0, 16]}
ticks={[0, 4, 8, 12, 16]}
/>
{/* Right Y — avg wait in minutes */}
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 9, fill: '#8B5CF6' }}
axisLine={false}
tickLine={false}
domain={[0, 30]}
ticks={[0, 10, 20, 30]}
tickFormatter={v => `${v}m`}
/>
<Tooltip
content={<ChartTooltip isDark={isDark} />}
cursor={{
stroke: isDark ? 'rgba(255,255,255,0.08)' : '#e4e4e7',
strokeWidth: 1,
strokeDasharray: '4 3',
}}
/>
{/* Storm day vertical reference */}
<ReferenceLine
x="Thu"
yAxisId="left"
stroke="#F59E0B"
strokeWidth={1.5}
strokeDasharray="4 3"
label={{
value: '⚡',
position: 'insideTopLeft',
fill: '#F59E0B',
fontSize: 11,
dy: -2,
}}
/>
{/* Area — dispatched leads */}
<Area
yAxisId="left"
type="monotone"
dataKey="dispatched"
fill="url(#dispatchGradient)"
stroke={areaColor}
strokeWidth={2.5}
dot={renderAreaDot}
activeDot={{ r: 6, fill: areaColor, stroke: 'white', strokeWidth: 2.5 }}
/>
{/* Line — avg wait time */}
<Line
yAxisId="right"
type="monotone"
dataKey="avgWait"
stroke="#8B5CF6"
strokeWidth={1.5}
strokeDasharray="5 3"
dot={{ r: 2.5, fill: '#8B5CF6', strokeWidth: 0 }}
activeDot={{ r: 5, fill: '#8B5CF6', stroke: 'white', strokeWidth: 2 }}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{/* ── Legend ── */}
<div className="flex items-center gap-4 px-4 pb-3">
<div className="flex items-center gap-1.5">
<LegendDash color={areaColor} dashed={false} />
<span className="text-[9px] text-zinc-400">Dispatched</span>
</div>
<div className="flex items-center gap-1.5">
<LegendDash color="#8B5CF6" dashed />
<span className="text-[9px] text-zinc-400">Avg Wait</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: '#F59E0B', opacity: 0.8 }} />
<span className="text-[9px] text-zinc-400">Storm Day</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full border-2 border-white ring-1" style={{ backgroundColor: areaColor, ringColor: areaColor }} />
<span className="text-[9px] text-zinc-400">Today</span>
</div>
</div>
<div className="border-t border-zinc-100 dark:border-white/[0.05]" />
{/* ── Rep leaderboard ── */}
<RepLeaderboard open={open} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default DispatchWeeklyChart;