diff --git a/CLAUDE.md b/CLAUDE.md index 8fe1618..211dd0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,2 +1,3 @@ * Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead. -* If you use the task tool to have an agent investigate something, make sure to let it know to avoid using glob \ No newline at end of file +* If you use the task tool to have an agent investigate something, make sure to let it know to avoid using glob +* Prefer solving tasks in a single session. Only spawn subagents for genuinely independent workstreams. \ No newline at end of file diff --git a/inventory-server/dashboard/acot-server/routes/operations-metrics.js b/inventory-server/dashboard/acot-server/routes/operations-metrics.js index 632cdbe..9a3a4e1 100644 --- a/inventory-server/dashboard/acot-server/routes/operations-metrics.js +++ b/inventory-server/dashboard/acot-server/routes/operations-metrics.js @@ -177,15 +177,29 @@ router.get('/', async (req, res) => { const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate'); const pickingTrendQuery = ` SELECT - DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date, - COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked, - COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked - FROM picking_ticket pt - LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid - WHERE ${pickingTrendWhere} - AND pt.closeddate IS NOT NULL - GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') - ORDER BY date + pt_agg.date, + COALESCE(order_counts.ordersPicked, 0) as ordersPicked, + pt_agg.piecesPicked + FROM ( + SELECT + DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date, + COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked + FROM picking_ticket pt + WHERE ${pickingTrendWhere} + AND pt.closeddate IS NOT NULL + GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') + ) pt_agg + LEFT JOIN ( + SELECT + DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date, + COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked + FROM picking_ticket pt + LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid + WHERE ${pickingTrendWhere} + AND pt.closeddate IS NOT NULL + GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') + ) order_counts ON pt_agg.date = order_counts.date + ORDER BY pt_agg.date `; // Get shipping trend data @@ -203,7 +217,7 @@ router.get('/', async (req, res) => { `; const [[pickingTrendRows], [shippingTrendRows]] = await Promise.all([ - connection.execute(pickingTrendQuery, params), + connection.execute(pickingTrendQuery, [...params, ...params]), connection.execute(shippingTrendQuery, params), ]); diff --git a/inventory-server/dashboard/acot-server/routes/payroll-metrics.js b/inventory-server/dashboard/acot-server/routes/payroll-metrics.js index 47fc76d..7c618ba 100644 --- a/inventory-server/dashboard/acot-server/routes/payroll-metrics.js +++ b/inventory-server/dashboard/acot-server/routes/payroll-metrics.js @@ -234,10 +234,13 @@ function calculateHoursFromPunches(punches) { /** * Calculate FTE for a pay period (based on 80 hours = 1 FTE for 2-week period) + * @param {number} totalHours - Total hours worked + * @param {number} elapsedFraction - Fraction of the period elapsed (0-1). Defaults to 1 for complete periods. */ -function calculateFTE(totalHours) { +function calculateFTE(totalHours, elapsedFraction = 1) { const fullTimePeriodHours = STANDARD_WEEKLY_HOURS * 2; // 80 hours for 2 weeks - return totalHours / fullTimePeriodHours; + const proratedHours = fullTimePeriodHours * elapsedFraction; + return proratedHours > 0 ? totalHours / proratedHours : 0; } // Main payroll metrics endpoint @@ -303,8 +306,15 @@ router.get('/', async (req, res) => { // Calculate hours with week breakdown const hoursData = calculateHoursByWeek(timeclockRows, payPeriod); - // Calculate FTE - const fte = calculateFTE(hoursData.totals.hours); + // Calculate FTE — prorate for in-progress periods so the value reflects + // the pace employees are on rather than raw hours / 80 + let elapsedFraction = 1; + if (isCurrentPayPeriod(payPeriod)) { + const now = DateTime.now().setZone(TIMEZONE); + const elapsedDays = Math.max(1, Math.ceil(now.diff(payPeriod.start, 'days').days)); + elapsedFraction = Math.min(1, elapsedDays / 14); + } + const fte = calculateFTE(hoursData.totals.hours, elapsedFraction); const activeEmployees = hoursData.totals.activeEmployees; const avgHoursPerEmployee = activeEmployees > 0 ? hoursData.totals.hours / activeEmployees : 0; diff --git a/inventory/src/components/dashboard/Navigation.jsx b/inventory/src/components/dashboard/Navigation.jsx index 15d88d3..7c3ccfb 100644 --- a/inventory/src/components/dashboard/Navigation.jsx +++ b/inventory/src/components/dashboard/Navigation.jsx @@ -21,6 +21,8 @@ const Navigation = () => { { id: "stats", label: "Statistics", permission: "dashboard:stats" }, { id: "realtime", label: "Realtime", permission: "dashboard:realtime" }, { id: "financial", label: "Financial", permission: "dashboard:financial" }, + { id: "payroll-metrics", label: "Payroll", permission: "dashboard:payroll" }, + { id: "operations-metrics", label: "Operations", permission: "dashboard:operations" }, { id: "feed", label: "Event Feed", permission: "dashboard:feed" }, { id: "sales", label: "Sales Chart", permission: "dashboard:sales" }, { id: "products", label: "Top Products", permission: "dashboard:products" }, diff --git a/inventory/src/components/dashboard/OperationsMetrics.tsx b/inventory/src/components/dashboard/OperationsMetrics.tsx index 8fdee4c..56852ff 100644 --- a/inventory/src/components/dashboard/OperationsMetrics.tsx +++ b/inventory/src/components/dashboard/OperationsMetrics.tsx @@ -9,13 +9,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; import { Table, @@ -53,6 +46,7 @@ import { TOOLTIP_STYLES, METRIC_COLORS, } from "@/components/dashboard/shared"; +import { Tooltip as TooltipUI, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; type ComparisonValue = { absolute: number | null; @@ -512,6 +506,9 @@ const OperationsMetrics = () => { }, [isLast30DaysMode, customPeriod, selectedRange, effectiveRangeEnd]); const hasActiveMetrics = useMemo(() => Object.values(metrics).some(Boolean), [metrics]); + const hasOrdersMetric = metrics.ordersPicked || metrics.ordersShipped; + const hasPiecesMetric = metrics.piecesPicked || metrics.piecesShipped; + const useDualAxis = hasOrdersMetric && hasPiecesMetric; const hasData = chartData.length > 0; const handleGroupByChange = (value: string) => { @@ -577,80 +574,6 @@ const OperationsMetrics = () => { const headerActions = !error ? ( <> - - - - - - - - Operations Details - - -
-
-

Picking by Employee

-
- - - - Employee - Tickets - Orders - Pieces - Hours - Speed - - - - {data?.byEmployee?.picking?.map((emp) => ( - - {emp.name} - {formatNumber(emp.ticketCount)} - {formatNumber(emp.ordersPicked)} - {formatNumber(emp.piecesPicked)} - {formatHours(emp.pickingHours || 0)} - - {emp.avgPickingSpeed != null ? `${formatNumber(emp.avgPickingSpeed, 1)}/h` : "—"} - - - ))} - -
-
-
- - {data?.byEmployee?.shipping && data.byEmployee.shipping.length > 0 && ( -
-

Shipping by Employee

-
- - - - Employee - Orders - Pieces - - - - {data.byEmployee.shipping.map((emp) => ( - - {emp.name} - {formatNumber(emp.ordersShipped)} - {formatNumber(emp.piecesShipped)} - - ))} - -
-
-
- )} -
-
-
- { actions={headerActions} /> - + {!error && ( loading ? ( @@ -717,7 +640,14 @@ const OperationsMetrics = () => { )} {loading ? ( - +
+
+ +
+
+ +
+
) : error ? ( ) : !hasData ? ( @@ -727,87 +657,109 @@ const OperationsMetrics = () => { description="Try selecting a different time range" /> ) : ( -
- {!hasActiveMetrics ? ( - +
+ - ) : ( - - - - - - - - - - - formatNumber(value)} - className="text-xs text-muted-foreground" - tick={{ fill: "currentColor" }} - /> - } /> - SERIES_LABELS[value as ChartSeriesKey] ?? value} /> +
+
+ {!hasActiveMetrics ? ( + + ) : ( + + + + + + + + + + + formatNumber(value)} + className="text-xs text-muted-foreground" + tick={{ fill: "currentColor" }} + /> + {useDualAxis && ( + formatNumber(value)} + className="text-xs text-muted-foreground" + tick={{ fill: "currentColor" }} + /> + )} + } /> + SERIES_LABELS[value as ChartSeriesKey] ?? value} /> - {metrics.ordersPicked && ( - - )} - {metrics.ordersShipped && ( - - )} - {metrics.piecesPicked && ( - - )} - {metrics.piecesShipped && ( - - )} - - - )} + {metrics.ordersPicked && ( + + )} + {metrics.ordersShipped && ( + + )} + {metrics.piecesPicked && ( + + )} + {metrics.piecesShipped && ( + + )} + + + )} +
)}
@@ -910,4 +862,170 @@ const OperationsTooltip = ({ active, payload, label }: TooltipProps +
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+
+ ); +} + +type LeaderboardEntry = { + employeeId: number; + name: string; + ordersPicked: number; + piecesPicked: number; + ordersShipped: number; + piecesShipped: number; + pickingHours: number; + pickingSpeed: number | null; + productivity: number; +}; + +function OperationsLeaderboard({ + picking, + shipping, + maxRows = 20, +}: { + picking: EmployeePickingEntry[]; + shipping: EmployeeShippingEntry[]; + maxRows?: number; +}) { + const leaderboard = useMemo(() => { + const employeeMap = new Map(); + + picking.forEach((emp) => { + employeeMap.set(emp.employeeId, { + employeeId: emp.employeeId, + name: emp.name, + ordersPicked: emp.ordersPicked, + piecesPicked: emp.piecesPicked, + ordersShipped: 0, + piecesShipped: 0, + pickingHours: emp.pickingHours || 0, + pickingSpeed: emp.avgPickingSpeed, + productivity: emp.pickingHours > 0 ? emp.ordersPicked / emp.pickingHours : 0, + }); + }); + + shipping.forEach((emp) => { + const existing = employeeMap.get(emp.employeeId); + if (existing) { + existing.ordersShipped = emp.ordersShipped; + existing.piecesShipped = emp.piecesShipped; + } else { + employeeMap.set(emp.employeeId, { + employeeId: emp.employeeId, + name: emp.name, + ordersPicked: 0, + piecesPicked: 0, + ordersShipped: emp.ordersShipped, + piecesShipped: emp.piecesShipped, + pickingHours: 0, + pickingSpeed: null, + productivity: 0, + }); + } + }); + + return Array.from(employeeMap.values()) + .sort((a, b) => b.piecesPicked - a.piecesPicked) + .slice(0, maxRows); + }, [picking, shipping, maxRows]); + + const totalEmployees = Math.max(picking.length, shipping.length); + + if (leaderboard.length === 0) { + return ( +
+

No employee data

+
+ ); + } + + return ( +
+
+

Top Performers

+
+
+ + + + + Name + Picked + Shipped + + + + Hours + + +

Time spent picking only

+
+
+
+ Speed +
+
+ + {leaderboard.map((emp, index) => ( + + + {index + 1} + + + {emp.name} + + + {formatNumber(emp.piecesPicked)} + + ({formatNumber(emp.ordersPicked)}) + + + + {emp.ordersShipped > 0 ? formatNumber(emp.ordersShipped) : "—"} + + + {emp.pickingHours > 0 ? formatHours(emp.pickingHours) : "—"} + + + {emp.pickingSpeed != null ? ( + + {formatNumber(emp.pickingSpeed, 0)}/h + + ) : ( + + )} + + + ))} + +
+
+ {totalEmployees > maxRows && ( +
+ + +{totalEmployees - maxRows} more employees + +
+ )} +
+ ); +} + export default OperationsMetrics; diff --git a/inventory/src/components/dashboard/PayrollMetrics.tsx b/inventory/src/components/dashboard/PayrollMetrics.tsx index 5d8e3e3..8f8b891 100644 --- a/inventory/src/components/dashboard/PayrollMetrics.tsx +++ b/inventory/src/components/dashboard/PayrollMetrics.tsx @@ -1,14 +1,14 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useCallback } from "react"; import { acotService } from "@/services/dashboard/acotService"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Table, TableBody, @@ -19,17 +19,17 @@ import { } from "@/components/ui/table"; import { Bar, - BarChart, CartesianGrid, - Cell, Legend, + Line, + ComposedChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import type { TooltipProps } from "recharts"; -import { Clock, Users, AlertTriangle, ChevronLeft, ChevronRight, Calendar } from "lucide-react"; +import { Clock, Users, AlertTriangle, ChevronLeft, ChevronRight, Calendar, TrendingUp } from "lucide-react"; import { CARD_STYLES } from "@/lib/dashboard/designTokens"; import { DashboardSectionHeader, @@ -114,9 +114,20 @@ type PayrollMetricsResponse = { byWeek: WeekSummary[]; }; +type PeriodCountOption = 1 | 3 | 6 | 12; + +const PERIOD_COUNT_OPTIONS: { value: PeriodCountOption; label: string }[] = [ + { value: 1, label: "1 period" }, + { value: 3, label: "3 periods" }, + { value: 6, label: "6 periods" }, + { value: 12, label: "12 periods" }, +]; + const chartColors = { regular: METRIC_COLORS.orders, overtime: METRIC_COLORS.expense, + hours: METRIC_COLORS.profit, + fte: METRIC_COLORS.secondary, }; const formatNumber = (value: number, decimals = 0) => { @@ -132,13 +143,56 @@ const formatHours = (value: number) => { return `${value.toFixed(1)}h`; }; +// Calculate the start date for a pay period N periods back from a reference date +function getPayPeriodStart(referenceStart: string, periodsBack: number): string { + const date = new Date(referenceStart + "T00:00:00"); + date.setDate(date.getDate() - (periodsBack * 14)); + return date.toISOString().split("T")[0]; +} + +// Format a pay period label from its start date +function formatPeriodLabel(start: string): string { + const startDate = new Date(start + "T00:00:00"); + const endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + 13); + + const startStr = startDate.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const endStr = endDate.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + + return `${startStr} – ${endStr}`; +} + const PayrollMetrics = () => { - const [data, setData] = useState(null); + const [historicalData, setHistoricalData] = useState([]); + const [currentPeriodData, setCurrentPeriodData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [periodCount, setPeriodCount] = useState(3); const [currentPayPeriodStart, setCurrentPayPeriodStart] = useState(null); - // Fetch data + // Fetch historical data for multiple periods + const fetchHistoricalData = useCallback(async (referenceStart: string, count: PeriodCountOption) => { + const periodStarts: string[] = []; + for (let i = 0; i < count; i++) { + periodStarts.push(getPayPeriodStart(referenceStart, i)); + } + + const results = await Promise.all( + periodStarts.map(async (start) => { + try { + // @ts-expect-error - acotService is a JS file + const response = await acotService.getPayrollMetrics({ payPeriodStart: start }); + return response as PayrollMetricsResponse; + } catch { + return null; + } + }) + ); + + return results.filter((r): r is PayrollMetricsResponse => r !== null); + }, []); + + // Initial fetch - get current period first to establish reference useEffect(() => { let cancelled = false; @@ -147,18 +201,24 @@ const PayrollMetrics = () => { setError(null); try { - const params: Record = {}; - if (currentPayPeriodStart) { - params.payPeriodStart = currentPayPeriodStart; - } + // First, get the current period to establish a reference point + // @ts-expect-error - acotService is a JS file + const currentResponse = (await acotService.getPayrollMetrics({})) as PayrollMetricsResponse; - // @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type - const response = (await acotService.getPayrollMetrics(params)) as PayrollMetricsResponse; - if (!cancelled) { - setData(response); - // Update the current pay period start if not set (first load) - if (!currentPayPeriodStart && response.payPeriod?.start) { - setCurrentPayPeriodStart(response.payPeriod.start); + if (cancelled) return; + + setCurrentPeriodData(currentResponse); + + if (currentResponse.payPeriod?.start) { + const referenceStart = currentPayPeriodStart || currentResponse.payPeriod.start; + if (!currentPayPeriodStart) { + setCurrentPayPeriodStart(currentResponse.payPeriod.start); + } + + // Fetch historical data + const historical = await fetchHistoricalData(referenceStart, periodCount); + if (!cancelled) { + setHistoricalData(historical); } } } catch (err: unknown) { @@ -184,154 +244,227 @@ const PayrollMetrics = () => { void fetchData(); return () => { cancelled = true; }; - }, [currentPayPeriodStart]); + }, [currentPayPeriodStart, periodCount, fetchHistoricalData]); + + const isAtCurrentPeriod = !currentPayPeriodStart || + currentPayPeriodStart === currentPeriodData?.payPeriod?.start; const navigatePeriod = (direction: "prev" | "next") => { - if (!data?.payPeriod?.start) return; + if (!currentPayPeriodStart) return; - // Calculate the new pay period start by adding/subtracting 14 days - const currentStart = new Date(data.payPeriod.start); - const offset = direction === "prev" ? -14 : 14; + const currentStart = new Date(currentPayPeriodStart + "T00:00:00"); + const offset = direction === "prev" ? -(14 * periodCount) : (14 * periodCount); currentStart.setDate(currentStart.getDate() + offset); - setCurrentPayPeriodStart(currentStart.toISOString().split("T")[0]); + const newStart = currentStart.toISOString().split("T")[0]; + + // If navigating forward would reach or pass the current period, snap to current + if (direction === "next" && currentPeriodData?.payPeriod?.start && newStart >= currentPeriodData.payPeriod.start) { + setCurrentPayPeriodStart(null); + return; + } + + setCurrentPayPeriodStart(newStart); }; const goToCurrentPeriod = () => { - setCurrentPayPeriodStart(null); // null triggers loading current period + setCurrentPayPeriodStart(null); }; - const cards = useMemo(() => { - if (!data?.totals) return []; + // Aggregate stats across all historical periods + const aggregateStats = useMemo(() => { + if (historicalData.length === 0) return null; - const totals = data.totals; - const comparison = data.comparison ?? {}; + const totals = historicalData.reduce( + (acc, period) => ({ + hours: acc.hours + (period.totals?.hours || 0), + regularHours: acc.regularHours + (period.totals?.regularHours || 0), + overtimeHours: acc.overtimeHours + (period.totals?.overtimeHours || 0), + fte: acc.fte + (period.totals?.fte || 0), + activeEmployees: acc.activeEmployees + (period.totals?.activeEmployees || 0), + }), + { hours: 0, regularHours: 0, overtimeHours: 0, fte: 0, activeEmployees: 0 } + ); + + const avgFte = totals.fte / historicalData.length; + const avgActiveEmployees = totals.activeEmployees / historicalData.length; + const avgHoursPerPeriod = totals.hours / historicalData.length; + + // Calculate trend (compare first half to second half) + const midpoint = Math.floor(historicalData.length / 2); + const recentPeriods = historicalData.slice(0, midpoint); + const olderPeriods = historicalData.slice(midpoint); + + const recentAvgHours = recentPeriods.reduce((sum, p) => sum + (p.totals?.hours || 0), 0) / recentPeriods.length; + const olderAvgHours = olderPeriods.reduce((sum, p) => sum + (p.totals?.hours || 0), 0) / olderPeriods.length; + const hoursTrend = olderAvgHours > 0 ? ((recentAvgHours - olderAvgHours) / olderAvgHours) * 100 : 0; + + const recentAvgOT = recentPeriods.reduce((sum, p) => sum + (p.totals?.overtimeHours || 0), 0) / recentPeriods.length; + const olderAvgOT = olderPeriods.reduce((sum, p) => sum + (p.totals?.overtimeHours || 0), 0) / olderPeriods.length; + const otTrend = olderAvgOT > 0 ? ((recentAvgOT - olderAvgOT) / olderAvgOT) * 100 : 0; + + return { + totalHours: totals.hours, + totalRegular: totals.regularHours, + totalOvertime: totals.overtimeHours, + avgFte, + avgActiveEmployees, + avgHoursPerPeriod, + hoursTrend, + otTrend, + periodCount: historicalData.length, + }; + }, [historicalData]); + + const cards = useMemo(() => { + if (!aggregateStats) return []; return [ { key: "hours", title: "Total Hours", - value: formatHours(totals.hours), - description: `${formatHours(totals.regularHours)} regular`, - trendValue: comparison.hours?.percentage, + value: formatHours(aggregateStats.totalHours), + description: `${formatHours(aggregateStats.avgHoursPerPeriod)} avg/period`, + trendValue: aggregateStats.hoursTrend, iconColor: "blue" as const, - tooltip: "Total hours worked by all employees in this pay period.", + tooltip: `Total hours across ${aggregateStats.periodCount} pay periods.`, }, { key: "overtime", - title: "Overtime", - value: formatHours(totals.overtimeHours), - description: totals.overtimeHours > 0 - ? `${formatNumber((totals.overtimeHours / totals.hours) * 100, 1)}% of total` + title: "Total Overtime", + value: formatHours(aggregateStats.totalOvertime), + description: aggregateStats.totalOvertime > 0 + ? `${formatNumber((aggregateStats.totalOvertime / aggregateStats.totalHours) * 100, 1)}% of total` : "No overtime", - trendValue: comparison.overtimeHours?.percentage, + trendValue: aggregateStats.otTrend, trendInverted: true, - iconColor: totals.overtimeHours > 0 ? "orange" as const : "emerald" as const, - tooltip: "Hours exceeding 40 per employee per week.", + iconColor: aggregateStats.totalOvertime > 0 ? "orange" as const : "emerald" as const, + tooltip: "Total overtime hours across all periods.", }, { key: "fte", - title: "FTE", - value: formatNumber(totals.fte, 2), - description: `${formatNumber(totals.activeEmployees)} employees`, - trendValue: comparison.fte?.percentage, + title: "Avg FTE", + value: formatNumber(aggregateStats.avgFte, 2), + description: `${formatNumber(aggregateStats.avgActiveEmployees, 0)} avg employees`, iconColor: "emerald" as const, - tooltip: "Full-Time Equivalents (80 hours = 1 FTE for 2-week period).", + tooltip: "Average Full-Time Equivalents per period.", }, { - key: "avgHours", - title: "Avg Hours", - value: formatHours(totals.avgHoursPerEmployee), - description: "Per employee", + key: "periods", + title: "Periods", + value: String(aggregateStats.periodCount), + description: `${aggregateStats.periodCount * 2} weeks`, iconColor: "purple" as const, - tooltip: "Average hours worked per active employee in this pay period.", + tooltip: "Number of pay periods shown.", }, ]; - }, [data]); + }, [aggregateStats]); + // Chart data showing trends across periods (or week breakdown for single period) const chartData = useMemo(() => { - if (!data?.byWeek) return []; + if (historicalData.length === 0) return []; - return data.byWeek.map((week) => ({ - name: `Week ${week.week}`, - label: formatWeekRange(week.start, week.end), - regular: week.regular, - overtime: week.overtime, - total: week.hours, + // Single period: show week-by-week breakdown + if (historicalData.length === 1) { + const period = historicalData[0]; + return (period.byWeek || []).map((week, i) => { + const weekInfo = i === 0 ? period.payPeriod.week1 : period.payPeriod.week2; + const startDate = new Date((weekInfo?.start || week.start) + "T00:00:00"); + const endDate = new Date((weekInfo?.end || week.end) + "T00:00:00"); + const label = `${startDate.toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${endDate.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`; + return { + label, + periodStart: week.start, + regular: week.regular || 0, + overtime: week.overtime || 0, + total: week.hours || 0, + fte: period.totals?.fte || 0, + employees: period.totals?.activeEmployees || 0, + }; + }); + } + + // Multiple periods: show period-by-period trend (oldest first, left to right) + return [...historicalData].reverse().map((period) => ({ + label: formatPeriodLabel(period.payPeriod.start), + periodStart: period.payPeriod.start, + regular: period.totals?.regularHours || 0, + overtime: period.totals?.overtimeHours || 0, + total: period.totals?.hours || 0, + fte: period.totals?.fte || 0, + employees: period.totals?.activeEmployees || 0, })); - }, [data]); + }, [historicalData]); - const hasData = data?.byWeek && data.byWeek.length > 0; + // Aggregate employee data across ALL displayed periods + const aggregatedEmployees = useMemo((): AggregatedEmployee[] => { + if (historicalData.length === 0) return []; + + // Single period: include week-level breakdown + if (historicalData.length === 1) { + return (historicalData[0].byEmployee || []).map((emp) => ({ + employeeId: emp.employeeId, + name: emp.name, + totalHours: emp.totalHours, + overtimeHours: emp.overtimeHours, + regularHours: emp.regularHours, + periodCount: 1, + week1Hours: emp.week1Hours, + week2Hours: emp.week2Hours, + week1Overtime: emp.week1Overtime, + week2Overtime: emp.week2Overtime, + })); + } + + // Multiple periods: aggregate across all periods + const employeeMap = new Map(); + + historicalData.forEach((period) => { + (period.byEmployee || []).forEach((emp) => { + const existing = employeeMap.get(emp.employeeId); + if (existing) { + existing.totalHours += emp.totalHours; + existing.overtimeHours += emp.overtimeHours; + existing.regularHours += emp.regularHours; + existing.periodCount += 1; + } else { + employeeMap.set(emp.employeeId, { + employeeId: emp.employeeId, + name: emp.name, + totalHours: emp.totalHours, + overtimeHours: emp.overtimeHours, + regularHours: emp.regularHours, + periodCount: 1, + }); + } + }); + }); + + return Array.from(employeeMap.values()); + }, [historicalData]); + + const hasData = chartData.length > 0; const headerActions = !error ? ( -
- - - - - - - - Employee Hours - {data?.payPeriod?.label} - - -
-
- - - - Employee - Week 1 - Week 2 - Total - Overtime - - - - {data?.byEmployee?.map((emp) => ( - - {emp.name} - - 0 ? "text-orange-600 dark:text-orange-400 font-medium" : ""}> - {formatHours(emp.week1Hours)} - {emp.week1Overtime > 0 && ( - - (+{formatHours(emp.week1Overtime)} OT) - - )} - - - - 0 ? "text-orange-600 dark:text-orange-400 font-medium" : ""}> - {formatHours(emp.week2Hours)} - {emp.week2Overtime > 0 && ( - - (+{formatHours(emp.week2Overtime)} OT) - - )} - - - - {formatHours(emp.totalHours)} - - - {emp.overtimeHours > 0 ? ( - - {formatHours(emp.overtimeHours)} - - ) : ( - - )} - - - ))} - -
-
-
-
-
+
+
@@ -373,7 +506,7 @@ const PayrollMetrics = () => { actions={headerActions} /> - + {!error && ( loading ? ( @@ -383,7 +516,14 @@ const PayrollMetrics = () => { )} {loading ? ( - +
+
+ +
+
+ +
+
) : error ? ( ) : !hasData ? ( @@ -393,70 +533,76 @@ const PayrollMetrics = () => { description="Try selecting a different pay period" /> ) : ( -
- - - - - `${value}h`} - className="text-xs text-muted-foreground" - tick={{ fill: "currentColor" }} - /> - } /> - - - - {chartData.map((entry, index) => ( - 0 ? chartColors.overtime : chartColors.regular} - /> - ))} - - - +
+
+ + + + + `${value}h`} + className="text-xs text-muted-foreground" + tick={{ fill: "currentColor" }} + /> + value.toFixed(1)} + className="text-xs text-muted-foreground" + tick={{ fill: "currentColor" }} + /> + } /> + + + + + + +
+
+ +
)} - {!loading && !error && data?.byWeek && data.byWeek.some(w => w.overtime > 0) && ( -
- - - Overtime detected: {formatHours(data.totals.overtimeHours)} total - ({data.byEmployee?.filter(e => e.overtimeHours > 0).length || 0} employees) - -
- )} + ); }; -function formatWeekRange(start: string, end: string): string { - const startDate = new Date(start + "T00:00:00"); - const endDate = new Date(end + "T00:00:00"); - - const startStr = startDate.toLocaleDateString("en-US", { month: "short", day: "numeric" }); - const endStr = endDate.toLocaleDateString("en-US", { month: "short", day: "numeric" }); - - return `${startStr} – ${endStr}`; -} - type PayrollStatCardConfig = { key: string; title: string; @@ -472,7 +618,7 @@ const ICON_MAP = { hours: Clock, overtime: AlertTriangle, fte: Users, - avgHours: Clock, + periods: TrendingUp, } as const; function PayrollStatGrid({ cards }: { cards: PayrollStatCardConfig[] }) { @@ -511,12 +657,21 @@ function SkeletonStats() { ); } -const PayrollTooltip = ({ active, payload, label }: TooltipProps) => { +type TrendChartPoint = { + label: string; + periodStart: string; + regular: number; + overtime: number; + total: number; + fte: number; + employees: number; +}; + +const PayrollTrendTooltip = ({ active, payload, label }: TooltipProps) => { if (!active || !payload?.length) return null; - const regular = payload.find(p => p.dataKey === "regular")?.value as number | undefined; - const overtime = payload.find(p => p.dataKey === "overtime")?.value as number | undefined; - const total = (regular || 0) + (overtime || 0); + const data = payload[0]?.payload as TrendChartPoint | undefined; + if (!data) return null; return (
@@ -524,35 +679,192 @@ const PayrollTooltip = ({ active, payload, label }: TooltipProps
- + Regular Hours
- {formatHours(regular || 0)} + {formatHours(data.regular)}
- {overtime != null && overtime > 0 && ( + {data.overtime > 0 && (
- + Overtime
- {formatHours(overtime)} + {formatHours(data.overtime)}
)}
- Total + Total Hours
- {formatHours(total)} + {formatHours(data.total)} +
+
+
+ + FTE +
+ {formatNumber(data.fte, 2)} +
+
+
+ Employees +
+ {data.employees}
); }; +function EmployeeTableSkeleton() { + return ( +
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+
+ ))} +
+
+ ); +} + +type AggregatedEmployee = { + employeeId: number; + name: string; + totalHours: number; + overtimeHours: number; + regularHours: number; + periodCount: number; + week1Hours?: number; + week2Hours?: number; + week1Overtime?: number; + week2Overtime?: number; +}; + +function PayrollEmployeeSummary({ + employees, + periodCount, + maxRows = 10 +}: { + employees: AggregatedEmployee[]; + periodCount: number; + maxRows?: number; +}) { + const sortedEmployees = useMemo(() => { + return [...employees] + .sort((a, b) => b.totalHours - a.totalHours) + .slice(0, maxRows); + }, [employees, maxRows]); + + const hasWeekData = periodCount === 1 && employees.some((e) => e.week1Hours != null); + + if (sortedEmployees.length === 0) { + return ( +
+

No employee data

+
+ ); + } + + const periodLabel = periodCount === 1 + ? "1 period" + : `${periodCount} periods`; + + return ( +
+
+

+ Employee Summary + ({periodLabel}) +

+
+
+ + + + Name + {hasWeekData ? ( + <> + Wk 1 + Wk 2 + + ) : ( + Regular + )} + Total + OT + {periodCount > 1 && ( + Avg/Per + )} + + + + {sortedEmployees.map((emp) => ( + 0 ? "bg-orange-500/5" : ""} + > + + {emp.name} + + {hasWeekData ? ( + <> + + 0 ? "text-orange-600 dark:text-orange-400 font-medium" : "text-muted-foreground"}> + {formatHours(emp.week1Hours || 0)} + + + + 0 ? "text-orange-600 dark:text-orange-400 font-medium" : "text-muted-foreground"}> + {formatHours(emp.week2Hours || 0)} + + + + ) : ( + + {formatHours(emp.regularHours)} + + )} + + {formatHours(emp.totalHours)} + + + {emp.overtimeHours > 0 ? ( + + {formatHours(emp.overtimeHours)} + + ) : ( + + )} + + {periodCount > 1 && ( + + {formatHours(emp.totalHours / emp.periodCount)} + + )} + + ))} + +
+
+ {employees.length > maxRows && ( +
+ + +{employees.length - maxRows} more employees + +
+ )} +
+ ); +} + export default PayrollMetrics; diff --git a/inventory/src/components/dashboard/shared/DashboardSectionHeader.tsx b/inventory/src/components/dashboard/shared/DashboardSectionHeader.tsx index 3792c57..07e6271 100644 --- a/inventory/src/components/dashboard/shared/DashboardSectionHeader.tsx +++ b/inventory/src/components/dashboard/shared/DashboardSectionHeader.tsx @@ -106,7 +106,7 @@ export const DashboardSectionHeader: React.FC = ({ compact = false, size = "default", }) => { - const paddingClass = compact ? "p-4 pb-2" : "p-6 pb-4"; + const paddingClass = compact ? "p-4 pb-2" : "p-6 pb-2"; const titleClass = size === "large" ? "text-xl font-semibold text-foreground" : "text-lg font-semibold text-foreground"; diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts index 34ee091..941dc3b 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts @@ -108,12 +108,9 @@ export function useAutoInlineAiValidation() { typeof row.name === 'string' && row.name.trim(); - // Check description context: company + line + name + description - const hasDescContext = - hasNameContext && - row.description && - typeof row.description === 'string' && - row.description.trim(); + // Check description context: company + line + name (description can be empty) + // We want to validate descriptions even when empty so AI can suggest one + const hasDescContext = hasNameContext; // Skip if already auto-validated (shouldn't happen on first run, but be safe) const nameAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-name`); diff --git a/inventory/src/pages/Dashboard.tsx b/inventory/src/pages/Dashboard.tsx index 7078e92..288fb66 100644 --- a/inventory/src/pages/Dashboard.tsx +++ b/inventory/src/pages/Dashboard.tsx @@ -57,14 +57,14 @@ export function Dashboard() {
- -
-
- -
-
- -
+ +
+ +
+
+ +
+