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:
@@ -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 20–26
|
||||
</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;
|
||||
Reference in New Issue
Block a user