From e4c75b7cfdc50c70e391bfb1b126cb30bd16b921 Mon Sep 17 00:00:00 2001 From: Satyam <95536056+Satyam-Rastogi@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:14:13 +0530 Subject: [PATCH] =?UTF-8?q?feat(dispatch):=20Phase=206=20=E2=80=94=20weekl?= =?UTF-8?q?y=20performance=20chart=20modal=20and=20activity=20log=20drawer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../dispatch/DispatchActivityLog.jsx | 213 +++++++++ .../dispatch/DispatchChartModal.jsx | 405 +++++++++++++++++ src/components/dispatch/DispatchLogDrawer.jsx | 211 +++++++++ .../dispatch/DispatchWeeklyChart.jsx | 416 ++++++++++++++++++ 4 files changed, 1245 insertions(+) create mode 100644 src/components/dispatch/DispatchActivityLog.jsx create mode 100644 src/components/dispatch/DispatchChartModal.jsx create mode 100644 src/components/dispatch/DispatchLogDrawer.jsx create mode 100644 src/components/dispatch/DispatchWeeklyChart.jsx diff --git a/src/components/dispatch/DispatchActivityLog.jsx b/src/components/dispatch/DispatchActivityLog.jsx new file mode 100644 index 0000000..d8d28ef --- /dev/null +++ b/src/components/dispatch/DispatchActivityLog.jsx @@ -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 ( + + {/* Rep avatar */} +
+ {entry.repInitials} +
+ + {/* Dispatch info */} +
+
+ + {entry.repFirstName} + + + + {entry.customerName} + +
+
+ {entry.address} + + {LEAD_TYPE_SHORT[entry.leadType] || entry.leadType} + +
+
+ + {/* Timestamp */} + + {timeAgo(entry.timestamp)} + +
+ ); +}; + +// --------------------------------------------------------------------------- +// 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 ( +
+ + {/* ── Section header (always visible) ── */} + + + {/* ── Expandable log body ── */} + + {open && ( + +
+ + {log.length === 0 ? ( + /* Empty state */ +
+
+ +
+

+ Assign a lead to start logging +

+
+ ) : ( + /* Log entries — newest at top, max-height scroll */ +
+ + {log.map(entry => ( + + ))} + +
+ )} +
+
+ )} +
+
+ ); +}; + +export default DispatchActivityLog; diff --git a/src/components/dispatch/DispatchChartModal.jsx b/src/components/dispatch/DispatchChartModal.jsx new file mode 100644 index 0000000..bfad796 --- /dev/null +++ b/src/components/dispatch/DispatchChartModal.jsx @@ -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 ( + + + + + ); + } + if (payload.stormDay) { + return ( + + + + + ); + } + if (payload.emergency > 0) { + return ; + } + return ; +}; + +// --------------------------------------------------------------------------- +// 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 ( +
+
+ {d.date} + {d.stormDay && ( + + Storm Day + + )} + {d.isToday && ( + + Today + + )} +
+ + + {d.emergency > 0 && ( + + )} +
+ ); +}; + +const TooltipRow = ({ label, value, color, textMuted }) => ( +
+ {label} + {value} +
+); + +// --------------------------------------------------------------------------- +// Summary stat chip +// --------------------------------------------------------------------------- +const StatChip = ({ label, value, color }) => ( +
+ {value} + {label} +
+); + +// --------------------------------------------------------------------------- +// Chart legend item +// --------------------------------------------------------------------------- +const LegendDash = ({ color, dashed }) => ( +
+
+ {dashed &&
} + {dashed &&
} +
+); + +// --------------------------------------------------------------------------- +// 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 ( +
+
+ + + Rep Performance This Week + +
+
+ {repTotals.map((rep, i) => { + const color = REP_STATUS_COLORS[rep.status] || '#6B7280'; + const pct = (rep.weekDispatched / max) * 100; + return ( +
+ {/* Rank */} + + #{i + 1} + + {/* Avatar */} +
+ {rep.initials} +
+ {/* Name */} + + {rep.name.split(' ')[0]} + + {/* Bar */} +
+ +
+ {/* Count */} + + {rep.weekDispatched} + + {/* Rating */} + + ★{rep.rating} + +
+ ); + })} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// 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 */} + + + {/* Modal panel */} +
+ + {/* ── Modal header ── */} +
+
+
+ +
+
+

+ Weekly Performance +

+

+ Mar 20 – Mar 26, 2026 +

+
+
+ + +
+ + {/* ── Scrollable body ── */} +
+ + {/* Summary stat chips */} +
+ +
+ +
+ +
+ +
+ + {/* Chart */} +
+ + + + + + + + + + + + + + + + `${v}m`} + /> + + } + cursor={{ + stroke: isDark ? 'rgba(255,255,255,0.08)' : '#e4e4e7', + strokeWidth: 1, + strokeDasharray: '4 3', + }} + /> + + + + + + + + +
+ + {/* Legend */} +
+
+ + Dispatched +
+
+ + Avg Wait +
+
+
+ Storm Day +
+
+
+ Today +
+
+ +
+ + {/* Rep leaderboard */} + +
+ +
+ , + document.body + ); +}; + +export default DispatchChartModal; diff --git a/src/components/dispatch/DispatchLogDrawer.jsx b/src/components/dispatch/DispatchLogDrawer.jsx new file mode 100644 index 0000000..0079a8b --- /dev/null +++ b/src/components/dispatch/DispatchLogDrawer.jsx @@ -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 ( +
+ {/* Rep avatar */} +
+ {entry.repInitials} +
+ + {/* Dispatch info */} +
+
+ + {entry.repFirstName} + + + + {entry.customerName} + +
+
+ {entry.address} + + {LEAD_TYPE_SHORT[entry.leadType] || entry.leadType} + +
+
+ + {/* Timestamp */} + + {timeAgo(entry.timestamp)} + +
+ ); +}; + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- +const EmptyState = ({ accent }) => ( +
+
+ +
+
+

No activity yet

+

+ Assign a lead to start logging dispatches +

+
+
+); + +// --------------------------------------------------------------------------- +// 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 */} + + + {/* Drawer */} + + {/* ── Header ── */} +
+
+
+ +
+
+ + Dispatch Log + + {log.length > 0 && ( + + {log.length} + + )} +
+
+ + +
+ + {/* ── Log entries — full height, scrollable ── */} +
+ {log.length === 0 ? ( + + ) : ( + log.map(entry => ( + + )) + )} +
+ + {/* ── Footer ── */} + {log.length > 0 && ( +
+

+ Showing {log.length} of {log.length} entries · Newest first +

+
+ )} +
+ , + document.body + ); +}; + +export default DispatchLogDrawer; diff --git a/src/components/dispatch/DispatchWeeklyChart.jsx b/src/components/dispatch/DispatchWeeklyChart.jsx new file mode 100644 index 0000000..e45e20a --- /dev/null +++ b/src/components/dispatch/DispatchWeeklyChart.jsx @@ -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 ( + + + + + ); + } + if (payload.stormDay) { + return ( + + + + + ); + } + if (payload.emergency > 0) { + return ; + } + return ; +}; + +// --------------------------------------------------------------------------- +// 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 ( +
+ {/* Header */} +
+ {d.date} + {d.stormDay && ( + + Storm Day + + )} + {d.isToday && ( + + Today + + )} +
+ {/* Rows */} + + + {d.emergency > 0 && ( + + )} +
+ ); +}; + +const TooltipRow = ({ label, value, color, textMuted }) => ( +
+ {label} + {value} +
+); + +// --------------------------------------------------------------------------- +// Summary stat chip (top of expanded panel) +// --------------------------------------------------------------------------- +const StatChip = ({ label, value, color }) => ( +
+ {value} + {label} +
+); + +// --------------------------------------------------------------------------- +// Chart legend row +// --------------------------------------------------------------------------- +const LegendDash = ({ color, dashed }) => ( +
+
+ {dashed &&
} + {dashed &&
} +
+); + +// --------------------------------------------------------------------------- +// 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 ( +
+
+ + + Rep Performance This Week + +
+
+ {repTotals.map((rep, i) => { + const color = REP_STATUS_COLORS[rep.status] || '#6B7280'; + const pct = (rep.weekDispatched / max) * 100; + return ( +
+ {/* Rank */} + + {i + 1} + + {/* Avatar */} +
+ {rep.initials} +
+ {/* Name */} + + {rep.name.split(' ')[0]} + + {/* Bar */} +
+ +
+ {/* Count */} + + {rep.weekDispatched} + + {/* Rating */} + + ★{rep.rating} + +
+ ); + })} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// 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 ( +
+ + {/* ── Section header (always visible) ── */} + + + {/* ── Expandable body ── */} + + {open && ( + +
+ + {/* ── Summary stats strip ── */} +
+ +
+ +
+ +
+ +
+ + {/* ── Chart ── */} +
+ + + + + + + + + + + + + + {/* Left Y — dispatched count */} + + + {/* Right Y — avg wait in minutes */} + `${v}m`} + /> + + } + cursor={{ + stroke: isDark ? 'rgba(255,255,255,0.08)' : '#e4e4e7', + strokeWidth: 1, + strokeDasharray: '4 3', + }} + /> + + {/* Storm day vertical reference */} + + + {/* Area — dispatched leads */} + + + {/* Line — avg wait time */} + + + +
+ + {/* ── Legend ── */} +
+
+ + Dispatched +
+
+ + Avg Wait +
+
+
+ Storm Day +
+
+
+ Today +
+
+ +
+ + {/* ── Rep leaderboard ── */} + +
+ + )} + +
+ ); +}; + +export default DispatchWeeklyChart;