Add payroll and operations dashboard components

This commit is contained in:
2026-02-06 10:45:34 -05:00
parent fd14af0f9e
commit b5469440bf
9 changed files with 860 additions and 406 deletions

View File

@@ -1,2 +1,3 @@
* Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead. * 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 * 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.

View File

@@ -177,15 +177,29 @@ router.get('/', async (req, res) => {
const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate'); const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
const pickingTrendQuery = ` const pickingTrendQuery = `
SELECT SELECT
DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date, pt_agg.date,
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked, COALESCE(order_counts.ordersPicked, 0) as ordersPicked,
COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked pt_agg.piecesPicked
FROM picking_ticket pt FROM (
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid SELECT
WHERE ${pickingTrendWhere} DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
AND pt.closeddate IS NOT NULL COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked
GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') FROM picking_ticket pt
ORDER BY date 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 // Get shipping trend data
@@ -203,7 +217,7 @@ router.get('/', async (req, res) => {
`; `;
const [[pickingTrendRows], [shippingTrendRows]] = await Promise.all([ const [[pickingTrendRows], [shippingTrendRows]] = await Promise.all([
connection.execute(pickingTrendQuery, params), connection.execute(pickingTrendQuery, [...params, ...params]),
connection.execute(shippingTrendQuery, params), connection.execute(shippingTrendQuery, params),
]); ]);

View File

@@ -234,10 +234,13 @@ function calculateHoursFromPunches(punches) {
/** /**
* Calculate FTE for a pay period (based on 80 hours = 1 FTE for 2-week period) * 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 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 // Main payroll metrics endpoint
@@ -303,8 +306,15 @@ router.get('/', async (req, res) => {
// Calculate hours with week breakdown // Calculate hours with week breakdown
const hoursData = calculateHoursByWeek(timeclockRows, payPeriod); const hoursData = calculateHoursByWeek(timeclockRows, payPeriod);
// Calculate FTE // Calculate FTE — prorate for in-progress periods so the value reflects
const fte = calculateFTE(hoursData.totals.hours); // 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 activeEmployees = hoursData.totals.activeEmployees;
const avgHoursPerEmployee = activeEmployees > 0 ? hoursData.totals.hours / activeEmployees : 0; const avgHoursPerEmployee = activeEmployees > 0 ? hoursData.totals.hours / activeEmployees : 0;

View File

@@ -21,6 +21,8 @@ const Navigation = () => {
{ id: "stats", label: "Statistics", permission: "dashboard:stats" }, { id: "stats", label: "Statistics", permission: "dashboard:stats" },
{ id: "realtime", label: "Realtime", permission: "dashboard:realtime" }, { id: "realtime", label: "Realtime", permission: "dashboard:realtime" },
{ id: "financial", label: "Financial", permission: "dashboard:financial" }, { 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: "feed", label: "Event Feed", permission: "dashboard:feed" },
{ id: "sales", label: "Sales Chart", permission: "dashboard:sales" }, { id: "sales", label: "Sales Chart", permission: "dashboard:sales" },
{ id: "products", label: "Top Products", permission: "dashboard:products" }, { id: "products", label: "Top Products", permission: "dashboard:products" },

View File

@@ -9,13 +9,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { import {
Table, Table,
@@ -53,6 +46,7 @@ import {
TOOLTIP_STYLES, TOOLTIP_STYLES,
METRIC_COLORS, METRIC_COLORS,
} from "@/components/dashboard/shared"; } from "@/components/dashboard/shared";
import { Tooltip as TooltipUI, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
type ComparisonValue = { type ComparisonValue = {
absolute: number | null; absolute: number | null;
@@ -512,6 +506,9 @@ const OperationsMetrics = () => {
}, [isLast30DaysMode, customPeriod, selectedRange, effectiveRangeEnd]); }, [isLast30DaysMode, customPeriod, selectedRange, effectiveRangeEnd]);
const hasActiveMetrics = useMemo(() => Object.values(metrics).some(Boolean), [metrics]); 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 hasData = chartData.length > 0;
const handleGroupByChange = (value: string) => { const handleGroupByChange = (value: string) => {
@@ -577,80 +574,6 @@ const OperationsMetrics = () => {
const headerActions = !error ? ( const headerActions = !error ? (
<> <>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="h-9" disabled={loading || !data?.byEmployee}>
Details
</Button>
</DialogTrigger>
<DialogContent className={`p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col ${CARD_STYLES.base}`}>
<DialogHeader className="flex-none">
<DialogTitle className="text-foreground">
Operations Details
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto mt-6 space-y-6">
<div>
<h3 className="text-sm font-medium mb-2">Picking by Employee</h3>
<div className={`rounded-lg border ${CARD_STYLES.base}`}>
<Table>
<TableHeader>
<TableRow>
<TableHead className="whitespace-nowrap px-4">Employee</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Tickets</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Orders</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Pieces</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Hours</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Speed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.byEmployee?.picking?.map((emp) => (
<TableRow key={emp.employeeId}>
<TableCell className="px-4">{emp.name}</TableCell>
<TableCell className="text-right px-4">{formatNumber(emp.ticketCount)}</TableCell>
<TableCell className="text-right px-4">{formatNumber(emp.ordersPicked)}</TableCell>
<TableCell className="text-right px-4">{formatNumber(emp.piecesPicked)}</TableCell>
<TableCell className="text-right px-4">{formatHours(emp.pickingHours || 0)}</TableCell>
<TableCell className="text-right px-4">
{emp.avgPickingSpeed != null ? `${formatNumber(emp.avgPickingSpeed, 1)}/h` : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{data?.byEmployee?.shipping && data.byEmployee.shipping.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-2">Shipping by Employee</h3>
<div className={`rounded-lg border ${CARD_STYLES.base}`}>
<Table>
<TableHeader>
<TableRow>
<TableHead className="whitespace-nowrap px-4">Employee</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Orders</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Pieces</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.byEmployee.shipping.map((emp) => (
<TableRow key={emp.employeeId}>
<TableCell className="px-4">{emp.name}</TableCell>
<TableCell className="text-right px-4">{formatNumber(emp.ordersShipped)}</TableCell>
<TableCell className="text-right px-4">{formatNumber(emp.piecesShipped)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
<PeriodSelectionPopover <PeriodSelectionPopover
open={isPeriodPopoverOpen} open={isPeriodPopoverOpen}
onOpenChange={setIsPeriodPopoverOpen} onOpenChange={setIsPeriodPopoverOpen}
@@ -671,7 +594,7 @@ const OperationsMetrics = () => {
actions={headerActions} actions={headerActions}
/> />
<CardContent className="p-6 pt-0 space-y-4"> <CardContent className="p-6 pt-0">
{!error && ( {!error && (
loading ? ( loading ? (
<SkeletonStats /> <SkeletonStats />
@@ -717,7 +640,14 @@ const OperationsMetrics = () => {
)} )}
{loading ? ( {loading ? (
<ChartSkeleton type="area" height="default" withCard={false} /> <div className="flex flex-col lg:flex-row gap-6 mt-4">
<div className="w-full lg:w-[45%]">
<LeaderboardTableSkeleton />
</div>
<div className="w-full lg:w-[55%]">
<ChartSkeleton type="area" height="md" withCard={false} />
</div>
</div>
) : error ? ( ) : error ? (
<DashboardErrorState error={`Failed to load operations data: ${error}`} className="mx-0 my-0" /> <DashboardErrorState error={`Failed to load operations data: ${error}`} className="mx-0 my-0" />
) : !hasData ? ( ) : !hasData ? (
@@ -727,87 +657,109 @@ const OperationsMetrics = () => {
description="Try selecting a different time range" description="Try selecting a different time range"
/> />
) : ( ) : (
<div className={`h-[280px] ${CARD_STYLES.base} rounded-lg p-0 relative`}> <div className="flex flex-col lg:flex-row gap-6 mt-4">
{!hasActiveMetrics ? ( <div className="w-full lg:w-[45%]">
<DashboardEmptyState <OperationsLeaderboard
icon={TrendingUp} picking={data?.byEmployee?.picking ?? []}
title="No metrics selected" shipping={data?.byEmployee?.shipping ?? []}
description="Select at least one metric to visualize."
/> />
) : ( </div>
<ResponsiveContainer width="100%" height="100%"> <div className={`h-[300px] w-full lg:w-[55%] ${CARD_STYLES.base} rounded-lg p-0 relative`}>
<ComposedChart data={chartData} margin={{ top: 5, right: 15, left: 15, bottom: 5 }}> {!hasActiveMetrics ? (
<defs> <DashboardEmptyState
<linearGradient id="operationsOrdersPicked" x1="0" y1="0" x2="0" y2="1"> icon={TrendingUp}
<stop offset="5%" stopColor={chartColors.ordersPicked} stopOpacity={0.8} /> title="No metrics selected"
<stop offset="95%" stopColor={chartColors.ordersPicked} stopOpacity={0.3} /> description="Select at least one metric to visualize."
</linearGradient> />
</defs> ) : (
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <ResponsiveContainer width="100%" height="100%">
<XAxis <ComposedChart data={chartData} margin={{ top: 5, right: 15, left: 15, bottom: 5 }}>
dataKey="label" <defs>
className="text-xs text-muted-foreground" <linearGradient id="operationsOrdersPicked" x1="0" y1="0" x2="0" y2="1">
tick={{ fill: "currentColor" }} <stop offset="5%" stopColor={chartColors.ordersPicked} stopOpacity={0.8} />
/> <stop offset="95%" stopColor={chartColors.ordersPicked} stopOpacity={0.3} />
<YAxis </linearGradient>
tickFormatter={(value: number) => formatNumber(value)} </defs>
className="text-xs text-muted-foreground" <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
tick={{ fill: "currentColor" }} <XAxis
/> dataKey="label"
<Tooltip content={<OperationsTooltip />} /> className="text-xs text-muted-foreground"
<Legend formatter={(value: string) => SERIES_LABELS[value as ChartSeriesKey] ?? value} /> tick={{ fill: "currentColor" }}
/>
<YAxis
yAxisId="left"
tickFormatter={(value: number) => formatNumber(value)}
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
/>
{useDualAxis && (
<YAxis
yAxisId="right"
orientation="right"
tickFormatter={(value: number) => formatNumber(value)}
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
/>
)}
<Tooltip content={<OperationsTooltip />} />
<Legend formatter={(value: string) => SERIES_LABELS[value as ChartSeriesKey] ?? value} />
{metrics.ordersPicked && ( {metrics.ordersPicked && (
<Area <Area
type="monotone" yAxisId="left"
dataKey="ordersPicked" type="monotone"
name={SERIES_LABELS.ordersPicked} dataKey="ordersPicked"
stroke={chartColors.ordersPicked} name={SERIES_LABELS.ordersPicked}
fill="url(#operationsOrdersPicked)" stroke={chartColors.ordersPicked}
strokeWidth={2} fill="url(#operationsOrdersPicked)"
/> strokeWidth={2}
)} />
{metrics.ordersShipped && ( )}
<Line {metrics.ordersShipped && (
type="monotone" <Line
dataKey="ordersShipped" yAxisId="left"
name={SERIES_LABELS.ordersShipped} type="monotone"
stroke={chartColors.ordersShipped} dataKey="ordersShipped"
strokeWidth={2} name={SERIES_LABELS.ordersShipped}
dot={false} stroke={chartColors.ordersShipped}
activeDot={{ r: 4 }} strokeWidth={2}
connectNulls dot={false}
/> activeDot={{ r: 4 }}
)} connectNulls
{metrics.piecesPicked && ( />
<Line )}
type="monotone" {metrics.piecesPicked && (
dataKey="piecesPicked" <Line
name={SERIES_LABELS.piecesPicked} yAxisId={useDualAxis ? "right" : "left"}
stroke={chartColors.piecesPicked} type="monotone"
strokeWidth={2} dataKey="piecesPicked"
strokeDasharray="5 3" name={SERIES_LABELS.piecesPicked}
dot={false} stroke={chartColors.piecesPicked}
activeDot={{ r: 4 }} strokeWidth={2}
connectNulls strokeDasharray="5 3"
/> dot={false}
)} activeDot={{ r: 4 }}
{metrics.piecesShipped && ( connectNulls
<Line />
type="monotone" )}
dataKey="piecesShipped" {metrics.piecesShipped && (
name={SERIES_LABELS.piecesShipped} <Line
stroke={chartColors.piecesShipped} yAxisId={useDualAxis ? "right" : "left"}
strokeWidth={2} type="monotone"
strokeDasharray="3 3" dataKey="piecesShipped"
dot={false} name={SERIES_LABELS.piecesShipped}
activeDot={{ r: 4 }} stroke={chartColors.piecesShipped}
connectNulls strokeWidth={2}
/> strokeDasharray="3 3"
)} dot={false}
</ComposedChart> activeDot={{ r: 4 }}
</ResponsiveContainer> connectNulls
)} />
)}
</ComposedChart>
</ResponsiveContainer>
)}
</div>
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -910,4 +862,170 @@ const OperationsTooltip = ({ active, payload, label }: TooltipProps<number, stri
); );
}; };
function LeaderboardTableSkeleton() {
return (
<div className={`h-[280px] rounded-lg border ${CARD_STYLES.base} overflow-hidden`}>
<div className="p-3 border-b bg-muted/30">
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
</div>
<div className="p-2 space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-2 py-1.5">
<div className="h-5 w-5 bg-muted rounded-full animate-pulse" />
<div className="h-3 w-20 bg-muted rounded animate-pulse" />
<div className="flex-1" />
<div className="h-3 w-10 bg-muted rounded animate-pulse" />
<div className="h-3 w-10 bg-muted rounded animate-pulse" />
</div>
))}
</div>
</div>
);
}
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<LeaderboardEntry[]>(() => {
const employeeMap = new Map<number, LeaderboardEntry>();
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 (
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} flex items-center justify-center`}>
<p className="text-sm text-muted-foreground">No employee data</p>
</div>
);
}
return (
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} overflow-hidden flex flex-col`}>
<div className="p-3 border-b bg-muted/30 flex-none">
<h4 className="text-sm font-medium">Top Performers</h4>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 text-xs px-2 w-8" />
<TableHead className="h-8 text-xs px-2">Name</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">Picked</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">Shipped</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">
<TooltipUI>
<TooltipTrigger asChild>
<span className="cursor-help">Hours</span>
</TooltipTrigger>
<TooltipContent>
<p>Time spent picking only</p>
</TooltipContent>
</TooltipUI>
</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">Speed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{leaderboard.map((emp, index) => (
<TableRow key={emp.employeeId}>
<TableCell className="py-1.5 px-2 w-8 text-xs text-muted-foreground text-center">
{index + 1}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs font-medium truncate max-w-[100px]">
{emp.name}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right">
<span className="font-medium">{formatNumber(emp.piecesPicked)}</span>
<span className="text-muted-foreground ml-1 text-[10px]">
({formatNumber(emp.ordersPicked)})
</span>
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right text-muted-foreground">
{emp.ordersShipped > 0 ? formatNumber(emp.ordersShipped) : "—"}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right text-muted-foreground">
{emp.pickingHours > 0 ? formatHours(emp.pickingHours) : "—"}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right">
{emp.pickingSpeed != null ? (
<span className="text-emerald-600 dark:text-emerald-400 font-medium">
{formatNumber(emp.pickingSpeed, 0)}/h
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{totalEmployees > maxRows && (
<div className="p-2 border-t text-center flex-none">
<span className="text-xs text-muted-foreground">
+{totalEmployees - maxRows} more employees
</span>
</div>
)}
</div>
);
}
export default OperationsMetrics; export default OperationsMetrics;

View File

@@ -1,14 +1,14 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState, useCallback } from "react";
import { acotService } from "@/services/dashboard/acotService"; import { acotService } from "@/services/dashboard/acotService";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Select,
DialogContent, SelectContent,
DialogHeader, SelectItem,
DialogTitle, SelectTrigger,
DialogTrigger, SelectValue,
} from "@/components/ui/dialog"; } from "@/components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
@@ -19,17 +19,17 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { import {
Bar, Bar,
BarChart,
CartesianGrid, CartesianGrid,
Cell,
Legend, Legend,
Line,
ComposedChart,
ResponsiveContainer, ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import type { TooltipProps } 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 { CARD_STYLES } from "@/lib/dashboard/designTokens";
import { import {
DashboardSectionHeader, DashboardSectionHeader,
@@ -114,9 +114,20 @@ type PayrollMetricsResponse = {
byWeek: WeekSummary[]; 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 = { const chartColors = {
regular: METRIC_COLORS.orders, regular: METRIC_COLORS.orders,
overtime: METRIC_COLORS.expense, overtime: METRIC_COLORS.expense,
hours: METRIC_COLORS.profit,
fte: METRIC_COLORS.secondary,
}; };
const formatNumber = (value: number, decimals = 0) => { const formatNumber = (value: number, decimals = 0) => {
@@ -132,13 +143,56 @@ const formatHours = (value: number) => {
return `${value.toFixed(1)}h`; 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 PayrollMetrics = () => {
const [data, setData] = useState<PayrollMetricsResponse | null>(null); const [historicalData, setHistoricalData] = useState<PayrollMetricsResponse[]>([]);
const [currentPeriodData, setCurrentPeriodData] = useState<PayrollMetricsResponse | null>(null);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [periodCount, setPeriodCount] = useState<PeriodCountOption>(3);
const [currentPayPeriodStart, setCurrentPayPeriodStart] = useState<string | null>(null); const [currentPayPeriodStart, setCurrentPayPeriodStart] = useState<string | null>(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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -147,18 +201,24 @@ const PayrollMetrics = () => {
setError(null); setError(null);
try { try {
const params: Record<string, string> = {}; // First, get the current period to establish a reference point
if (currentPayPeriodStart) { // @ts-expect-error - acotService is a JS file
params.payPeriodStart = currentPayPeriodStart; const currentResponse = (await acotService.getPayrollMetrics({})) as PayrollMetricsResponse;
}
// @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type if (cancelled) return;
const response = (await acotService.getPayrollMetrics(params)) as PayrollMetricsResponse;
if (!cancelled) { setCurrentPeriodData(currentResponse);
setData(response);
// Update the current pay period start if not set (first load) if (currentResponse.payPeriod?.start) {
if (!currentPayPeriodStart && response.payPeriod?.start) { const referenceStart = currentPayPeriodStart || currentResponse.payPeriod.start;
setCurrentPayPeriodStart(response.payPeriod.start); if (!currentPayPeriodStart) {
setCurrentPayPeriodStart(currentResponse.payPeriod.start);
}
// Fetch historical data
const historical = await fetchHistoricalData(referenceStart, periodCount);
if (!cancelled) {
setHistoricalData(historical);
} }
} }
} catch (err: unknown) { } catch (err: unknown) {
@@ -184,154 +244,227 @@ const PayrollMetrics = () => {
void fetchData(); void fetchData();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [currentPayPeriodStart]); }, [currentPayPeriodStart, periodCount, fetchHistoricalData]);
const isAtCurrentPeriod = !currentPayPeriodStart ||
currentPayPeriodStart === currentPeriodData?.payPeriod?.start;
const navigatePeriod = (direction: "prev" | "next") => { 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(currentPayPeriodStart + "T00:00:00");
const currentStart = new Date(data.payPeriod.start); const offset = direction === "prev" ? -(14 * periodCount) : (14 * periodCount);
const offset = direction === "prev" ? -14 : 14;
currentStart.setDate(currentStart.getDate() + offset); 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 = () => { const goToCurrentPeriod = () => {
setCurrentPayPeriodStart(null); // null triggers loading current period setCurrentPayPeriodStart(null);
}; };
const cards = useMemo(() => { // Aggregate stats across all historical periods
if (!data?.totals) return []; const aggregateStats = useMemo(() => {
if (historicalData.length === 0) return null;
const totals = data.totals; const totals = historicalData.reduce(
const comparison = data.comparison ?? {}; (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 [ return [
{ {
key: "hours", key: "hours",
title: "Total Hours", title: "Total Hours",
value: formatHours(totals.hours), value: formatHours(aggregateStats.totalHours),
description: `${formatHours(totals.regularHours)} regular`, description: `${formatHours(aggregateStats.avgHoursPerPeriod)} avg/period`,
trendValue: comparison.hours?.percentage, trendValue: aggregateStats.hoursTrend,
iconColor: "blue" as const, 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", key: "overtime",
title: "Overtime", title: "Total Overtime",
value: formatHours(totals.overtimeHours), value: formatHours(aggregateStats.totalOvertime),
description: totals.overtimeHours > 0 description: aggregateStats.totalOvertime > 0
? `${formatNumber((totals.overtimeHours / totals.hours) * 100, 1)}% of total` ? `${formatNumber((aggregateStats.totalOvertime / aggregateStats.totalHours) * 100, 1)}% of total`
: "No overtime", : "No overtime",
trendValue: comparison.overtimeHours?.percentage, trendValue: aggregateStats.otTrend,
trendInverted: true, trendInverted: true,
iconColor: totals.overtimeHours > 0 ? "orange" as const : "emerald" as const, iconColor: aggregateStats.totalOvertime > 0 ? "orange" as const : "emerald" as const,
tooltip: "Hours exceeding 40 per employee per week.", tooltip: "Total overtime hours across all periods.",
}, },
{ {
key: "fte", key: "fte",
title: "FTE", title: "Avg FTE",
value: formatNumber(totals.fte, 2), value: formatNumber(aggregateStats.avgFte, 2),
description: `${formatNumber(totals.activeEmployees)} employees`, description: `${formatNumber(aggregateStats.avgActiveEmployees, 0)} avg employees`,
trendValue: comparison.fte?.percentage,
iconColor: "emerald" as const, 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", key: "periods",
title: "Avg Hours", title: "Periods",
value: formatHours(totals.avgHoursPerEmployee), value: String(aggregateStats.periodCount),
description: "Per employee", description: `${aggregateStats.periodCount * 2} weeks`,
iconColor: "purple" as const, 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(() => { const chartData = useMemo(() => {
if (!data?.byWeek) return []; if (historicalData.length === 0) return [];
return data.byWeek.map((week) => ({ // Single period: show week-by-week breakdown
name: `Week ${week.week}`, if (historicalData.length === 1) {
label: formatWeekRange(week.start, week.end), const period = historicalData[0];
regular: week.regular, return (period.byWeek || []).map((week, i) => {
overtime: week.overtime, const weekInfo = i === 0 ? period.payPeriod.week1 : period.payPeriod.week2;
total: week.hours, 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<number, AggregatedEmployee>();
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 ? ( const headerActions = !error ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<Dialog> <Select
<DialogTrigger asChild> value={String(periodCount)}
<Button variant="outline" className="h-9" disabled={loading || !data?.byEmployee}> onValueChange={(value) => {
Details setPeriodCount(Number(value) as PeriodCountOption);
</Button> setCurrentPayPeriodStart(null);
</DialogTrigger> }}
<DialogContent className={`p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col ${CARD_STYLES.base}`}> disabled={loading}
<DialogHeader className="flex-none"> >
<DialogTitle className="text-foreground"> <SelectTrigger className="h-9 w-[110px]">
Employee Hours - {data?.payPeriod?.label} <SelectValue />
</DialogTitle> </SelectTrigger>
</DialogHeader> <SelectContent>
<div className="flex-1 overflow-auto mt-6"> {PERIOD_COUNT_OPTIONS.map((option) => (
<div className={`rounded-lg border ${CARD_STYLES.base}`}> <SelectItem key={option.value} value={String(option.value)}>
<Table> {option.label}
<TableHeader> </SelectItem>
<TableRow> ))}
<TableHead className="whitespace-nowrap px-4">Employee</TableHead> </SelectContent>
<TableHead className="text-right whitespace-nowrap px-4">Week 1</TableHead> </Select>
<TableHead className="text-right whitespace-nowrap px-4">Week 2</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Total</TableHead>
<TableHead className="text-right whitespace-nowrap px-4">Overtime</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.byEmployee?.map((emp) => (
<TableRow key={emp.employeeId}>
<TableCell className="px-4">{emp.name}</TableCell>
<TableCell className="text-right px-4">
<span className={emp.week1Overtime > 0 ? "text-orange-600 dark:text-orange-400 font-medium" : ""}>
{formatHours(emp.week1Hours)}
{emp.week1Overtime > 0 && (
<span className="ml-1 text-xs">
(+{formatHours(emp.week1Overtime)} OT)
</span>
)}
</span>
</TableCell>
<TableCell className="text-right px-4">
<span className={emp.week2Overtime > 0 ? "text-orange-600 dark:text-orange-400 font-medium" : ""}>
{formatHours(emp.week2Hours)}
{emp.week2Overtime > 0 && (
<span className="ml-1 text-xs">
(+{formatHours(emp.week2Overtime)} OT)
</span>
)}
</span>
</TableCell>
<TableCell className="text-right px-4 font-medium">
{formatHours(emp.totalHours)}
</TableCell>
<TableCell className="text-right px-4">
{emp.overtimeHours > 0 ? (
<span className="text-orange-600 dark:text-orange-400 font-medium">
{formatHours(emp.overtimeHours)}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</DialogContent>
</Dialog>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
@@ -345,19 +478,19 @@ const PayrollMetrics = () => {
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="h-9 px-3 min-w-[180px]" className="h-9 px-3 min-w-[120px]"
onClick={goToCurrentPeriod} onClick={goToCurrentPeriod}
disabled={loading || data?.payPeriod?.isCurrent} disabled={loading || isAtCurrentPeriod}
> >
<Calendar className="h-4 w-4 mr-2" /> <Calendar className="h-4 w-4 mr-2" />
{loading ? "Loading..." : data?.payPeriod?.label || "Loading..."} {loading ? "Loading..." : "Current"}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
className="h-9 w-9" className="h-9 w-9"
onClick={() => navigatePeriod("next")} onClick={() => navigatePeriod("next")}
disabled={loading || data?.payPeriod?.isCurrent} disabled={loading || isAtCurrentPeriod}
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
@@ -373,7 +506,7 @@ const PayrollMetrics = () => {
actions={headerActions} actions={headerActions}
/> />
<CardContent className="p-6 pt-0 space-y-4"> <CardContent className="p-6 pt-0">
{!error && ( {!error && (
loading ? ( loading ? (
<SkeletonStats /> <SkeletonStats />
@@ -383,7 +516,14 @@ const PayrollMetrics = () => {
)} )}
{loading ? ( {loading ? (
<ChartSkeleton type="bar" height="default" withCard={false} /> <div className="flex flex-col lg:flex-row gap-6">
<div className="w-full lg:w-[65%]">
<ChartSkeleton type="bar" height="md" withCard={false} />
</div>
<div className="w-full lg:w-[35%]">
<EmployeeTableSkeleton />
</div>
</div>
) : error ? ( ) : error ? (
<DashboardErrorState error={`Failed to load payroll data: ${error}`} className="mx-0 my-0" /> <DashboardErrorState error={`Failed to load payroll data: ${error}`} className="mx-0 my-0" />
) : !hasData ? ( ) : !hasData ? (
@@ -393,70 +533,76 @@ const PayrollMetrics = () => {
description="Try selecting a different pay period" description="Try selecting a different pay period"
/> />
) : ( ) : (
<div className={`h-[280px] ${CARD_STYLES.base} rounded-lg p-0 relative`}> <div className="flex flex-col lg:flex-row gap-6">
<ResponsiveContainer width="100%" height="100%"> <div className={`h-[300px] w-full lg:w-[65%] ${CARD_STYLES.base} rounded-lg p-0 relative`}>
<BarChart data={chartData} margin={{ top: 20, right: 20, left: 20, bottom: 5 }}> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <ComposedChart data={chartData} margin={{ top: 20, right: 20, left: 20, bottom: 5 }}>
<XAxis <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
dataKey="label" <XAxis
className="text-xs text-muted-foreground" dataKey="label"
tick={{ fill: "currentColor" }} className="text-xs text-muted-foreground"
/> tick={{ fill: "currentColor", fontSize: 10 }}
<YAxis angle={-45}
tickFormatter={(value: number) => `${value}h`} textAnchor="end"
className="text-xs text-muted-foreground" height={60}
tick={{ fill: "currentColor" }} interval={0}
/> />
<Tooltip content={<PayrollTooltip />} /> <YAxis
<Legend /> yAxisId="hours"
<Bar tickFormatter={(value: number) => `${value}h`}
dataKey="regular" className="text-xs text-muted-foreground"
name="Regular Hours" tick={{ fill: "currentColor" }}
stackId="hours" />
fill={chartColors.regular} <YAxis
/> yAxisId="fte"
<Bar orientation="right"
dataKey="overtime" tickFormatter={(value: number) => value.toFixed(1)}
name="Overtime" className="text-xs text-muted-foreground"
stackId="hours" tick={{ fill: "currentColor" }}
fill={chartColors.overtime} />
> <Tooltip content={<PayrollTrendTooltip />} />
{chartData.map((entry, index) => ( <Legend />
<Cell <Bar
key={`cell-${index}`} yAxisId="hours"
fill={entry.overtime > 0 ? chartColors.overtime : chartColors.regular} dataKey="regular"
/> name="Regular Hours"
))} stackId="hours"
</Bar> fill={chartColors.regular}
</BarChart> />
</ResponsiveContainer> <Bar
yAxisId="hours"
dataKey="overtime"
name="Overtime"
stackId="hours"
fill={chartColors.overtime}
/>
<Line
yAxisId="fte"
type="monotone"
dataKey="fte"
name="FTE"
stroke={chartColors.fte}
strokeWidth={2}
dot={{ fill: chartColors.fte, r: 3 }}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="w-full lg:w-[35%]">
<PayrollEmployeeSummary
employees={aggregatedEmployees}
periodCount={historicalData.length}
/>
</div>
</div> </div>
)} )}
{!loading && !error && data?.byWeek && data.byWeek.some(w => w.overtime > 0) && (
<div className="flex items-center gap-2 text-sm text-orange-600 dark:text-orange-400">
<AlertTriangle className="h-4 w-4" />
<span>
Overtime detected: {formatHours(data.totals.overtimeHours)} total
({data.byEmployee?.filter(e => e.overtimeHours > 0).length || 0} employees)
</span>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
); );
}; };
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 = { type PayrollStatCardConfig = {
key: string; key: string;
title: string; title: string;
@@ -472,7 +618,7 @@ const ICON_MAP = {
hours: Clock, hours: Clock,
overtime: AlertTriangle, overtime: AlertTriangle,
fte: Users, fte: Users,
avgHours: Clock, periods: TrendingUp,
} as const; } as const;
function PayrollStatGrid({ cards }: { cards: PayrollStatCardConfig[] }) { function PayrollStatGrid({ cards }: { cards: PayrollStatCardConfig[] }) {
@@ -511,12 +657,21 @@ function SkeletonStats() {
); );
} }
const PayrollTooltip = ({ active, payload, label }: TooltipProps<number, string>) => { type TrendChartPoint = {
label: string;
periodStart: string;
regular: number;
overtime: number;
total: number;
fte: number;
employees: number;
};
const PayrollTrendTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
if (!active || !payload?.length) return null; if (!active || !payload?.length) return null;
const regular = payload.find(p => p.dataKey === "regular")?.value as number | undefined; const data = payload[0]?.payload as TrendChartPoint | undefined;
const overtime = payload.find(p => p.dataKey === "overtime")?.value as number | undefined; if (!data) return null;
const total = (regular || 0) + (overtime || 0);
return ( return (
<div className={TOOLTIP_STYLES.container}> <div className={TOOLTIP_STYLES.container}>
@@ -524,35 +679,192 @@ const PayrollTooltip = ({ active, payload, label }: TooltipProps<number, string>
<div className={TOOLTIP_STYLES.content}> <div className={TOOLTIP_STYLES.content}>
<div className={TOOLTIP_STYLES.row}> <div className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}> <div className={TOOLTIP_STYLES.rowLabel}>
<span <span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: chartColors.regular }} />
className={TOOLTIP_STYLES.dot}
style={{ backgroundColor: chartColors.regular }}
/>
<span className={TOOLTIP_STYLES.name}>Regular Hours</span> <span className={TOOLTIP_STYLES.name}>Regular Hours</span>
</div> </div>
<span className={TOOLTIP_STYLES.value}>{formatHours(regular || 0)}</span> <span className={TOOLTIP_STYLES.value}>{formatHours(data.regular)}</span>
</div> </div>
{overtime != null && overtime > 0 && ( {data.overtime > 0 && (
<div className={TOOLTIP_STYLES.row}> <div className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}> <div className={TOOLTIP_STYLES.rowLabel}>
<span <span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: chartColors.overtime }} />
className={TOOLTIP_STYLES.dot}
style={{ backgroundColor: chartColors.overtime }}
/>
<span className={TOOLTIP_STYLES.name}>Overtime</span> <span className={TOOLTIP_STYLES.name}>Overtime</span>
</div> </div>
<span className={TOOLTIP_STYLES.value}>{formatHours(overtime)}</span> <span className={TOOLTIP_STYLES.value}>{formatHours(data.overtime)}</span>
</div> </div>
)} )}
<div className={`${TOOLTIP_STYLES.row} border-t border-border/50 pt-1 mt-1`}> <div className={`${TOOLTIP_STYLES.row} border-t border-border/50 pt-1 mt-1`}>
<div className={TOOLTIP_STYLES.rowLabel}> <div className={TOOLTIP_STYLES.rowLabel}>
<span className={TOOLTIP_STYLES.name}>Total</span> <span className={TOOLTIP_STYLES.name}>Total Hours</span>
</div> </div>
<span className={`${TOOLTIP_STYLES.value} font-semibold`}>{formatHours(total)}</span> <span className={`${TOOLTIP_STYLES.value} font-semibold`}>{formatHours(data.total)}</span>
</div>
<div className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: chartColors.fte }} />
<span className={TOOLTIP_STYLES.name}>FTE</span>
</div>
<span className={TOOLTIP_STYLES.value}>{formatNumber(data.fte, 2)}</span>
</div>
<div className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span className={TOOLTIP_STYLES.name}>Employees</span>
</div>
<span className={TOOLTIP_STYLES.value}>{data.employees}</span>
</div> </div>
</div> </div>
</div> </div>
); );
}; };
function EmployeeTableSkeleton() {
return (
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} overflow-hidden`}>
<div className="p-3 border-b bg-muted/30">
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
</div>
<div className="p-2">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-2 py-1.5">
<div className="h-3 w-24 bg-muted rounded animate-pulse" />
<div className="flex-1" />
<div className="h-3 w-12 bg-muted rounded animate-pulse" />
<div className="h-3 w-12 bg-muted rounded animate-pulse" />
</div>
))}
</div>
</div>
);
}
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 (
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} flex items-center justify-center`}>
<p className="text-sm text-muted-foreground">No employee data</p>
</div>
);
}
const periodLabel = periodCount === 1
? "1 period"
: `${periodCount} periods`;
return (
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} overflow-hidden flex flex-col`}>
<div className="p-3 border-b bg-muted/30 flex-none">
<h4 className="text-sm font-medium">
Employee Summary
<span className="text-muted-foreground font-normal ml-1">({periodLabel})</span>
</h4>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 text-xs px-3">Name</TableHead>
{hasWeekData ? (
<>
<TableHead className="h-8 text-xs px-2 text-right">Wk 1</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">Wk 2</TableHead>
</>
) : (
<TableHead className="h-8 text-xs px-2 text-right">Regular</TableHead>
)}
<TableHead className="h-8 text-xs px-2 text-right">Total</TableHead>
<TableHead className="h-8 text-xs px-2 text-right">OT</TableHead>
{periodCount > 1 && (
<TableHead className="h-8 text-xs px-2 text-right">Avg/Per</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{sortedEmployees.map((emp) => (
<TableRow
key={emp.employeeId}
className={emp.overtimeHours > 0 ? "bg-orange-500/5" : ""}
>
<TableCell className="py-1.5 px-3 text-xs font-medium truncate max-w-[120px]">
{emp.name}
</TableCell>
{hasWeekData ? (
<>
<TableCell className="py-1.5 px-2 text-xs text-right">
<span className={emp.week1Overtime && emp.week1Overtime > 0 ? "text-orange-600 dark:text-orange-400 font-medium" : "text-muted-foreground"}>
{formatHours(emp.week1Hours || 0)}
</span>
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right">
<span className={emp.week2Overtime && emp.week2Overtime > 0 ? "text-orange-600 dark:text-orange-400 font-medium" : "text-muted-foreground"}>
{formatHours(emp.week2Hours || 0)}
</span>
</TableCell>
</>
) : (
<TableCell className="py-1.5 px-2 text-xs text-right text-muted-foreground">
{formatHours(emp.regularHours)}
</TableCell>
)}
<TableCell className="py-1.5 px-2 text-xs text-right font-medium">
{formatHours(emp.totalHours)}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right">
{emp.overtimeHours > 0 ? (
<span className="text-orange-600 dark:text-orange-400 font-medium">
{formatHours(emp.overtimeHours)}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
{periodCount > 1 && (
<TableCell className="py-1.5 px-2 text-xs text-right text-muted-foreground">
{formatHours(emp.totalHours / emp.periodCount)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
{employees.length > maxRows && (
<div className="p-2 border-t text-center flex-none">
<span className="text-xs text-muted-foreground">
+{employees.length - maxRows} more employees
</span>
</div>
)}
</div>
);
}
export default PayrollMetrics; export default PayrollMetrics;

View File

@@ -106,7 +106,7 @@ export const DashboardSectionHeader: React.FC<DashboardSectionHeaderProps> = ({
compact = false, compact = false,
size = "default", 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" const titleClass = size === "large"
? "text-xl font-semibold text-foreground" ? "text-xl font-semibold text-foreground"
: "text-lg font-semibold text-foreground"; : "text-lg font-semibold text-foreground";

View File

@@ -108,12 +108,9 @@ export function useAutoInlineAiValidation() {
typeof row.name === 'string' && typeof row.name === 'string' &&
row.name.trim(); row.name.trim();
// Check description context: company + line + name + description // Check description context: company + line + name (description can be empty)
const hasDescContext = // We want to validate descriptions even when empty so AI can suggest one
hasNameContext && const hasDescContext = hasNameContext;
row.description &&
typeof row.description === 'string' &&
row.description.trim();
// Skip if already auto-validated (shouldn't happen on first run, but be safe) // Skip if already auto-validated (shouldn't happen on first run, but be safe)
const nameAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-name`); const nameAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-name`);

View File

@@ -57,14 +57,14 @@ export function Dashboard() {
</div> </div>
</div> </div>
</Protected> </Protected>
<Protected permission="dashboard:employee_metrics"> <Protected permission="dashboard:payroll">
<div className="grid grid-cols-12 gap-4"> <div id="payroll-metrics">
<div id="payroll-metrics" className="col-span-12 lg:col-span-6"> <PayrollMetrics />
<PayrollMetrics /> </div>
</div> </Protected>
<div id="operations-metrics" className="col-span-12 lg:col-span-6"> <Protected permission="dashboard:operations">
<OperationsMetrics /> <div id="operations-metrics">
</div> <OperationsMetrics />
</div> </div>
</Protected> </Protected>
<div className="grid grid-cols-12 gap-4"> <div className="grid grid-cols-12 gap-4">