Add payroll and operations dashboard components
This commit is contained in:
@@ -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
|
||||
* Prefer solving tasks in a single session. Only spawn subagents for genuinely independent workstreams.
|
||||
@@ -176,16 +176,30 @@ router.get('/', async (req, res) => {
|
||||
// Business day starts at 1 AM, so subtract 1 hour before taking the date
|
||||
const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
|
||||
const pickingTrendQuery = `
|
||||
SELECT
|
||||
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,
|
||||
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
|
||||
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 BY date
|
||||
) 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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
<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
|
||||
open={isPeriodPopoverOpen}
|
||||
onOpenChange={setIsPeriodPopoverOpen}
|
||||
@@ -671,7 +594,7 @@ const OperationsMetrics = () => {
|
||||
actions={headerActions}
|
||||
/>
|
||||
|
||||
<CardContent className="p-6 pt-0 space-y-4">
|
||||
<CardContent className="p-6 pt-0">
|
||||
{!error && (
|
||||
loading ? (
|
||||
<SkeletonStats />
|
||||
@@ -717,7 +640,14 @@ const OperationsMetrics = () => {
|
||||
)}
|
||||
|
||||
{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 ? (
|
||||
<DashboardErrorState error={`Failed to load operations data: ${error}`} className="mx-0 my-0" />
|
||||
) : !hasData ? (
|
||||
@@ -727,7 +657,14 @@ const OperationsMetrics = () => {
|
||||
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">
|
||||
<div className="w-full lg:w-[45%]">
|
||||
<OperationsLeaderboard
|
||||
picking={data?.byEmployee?.picking ?? []}
|
||||
shipping={data?.byEmployee?.shipping ?? []}
|
||||
/>
|
||||
</div>
|
||||
<div className={`h-[300px] w-full lg:w-[55%] ${CARD_STYLES.base} rounded-lg p-0 relative`}>
|
||||
{!hasActiveMetrics ? (
|
||||
<DashboardEmptyState
|
||||
icon={TrendingUp}
|
||||
@@ -750,15 +687,26 @@ const OperationsMetrics = () => {
|
||||
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 && (
|
||||
<Area
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="ordersPicked"
|
||||
name={SERIES_LABELS.ordersPicked}
|
||||
@@ -769,6 +717,7 @@ const OperationsMetrics = () => {
|
||||
)}
|
||||
{metrics.ordersShipped && (
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="ordersShipped"
|
||||
name={SERIES_LABELS.ordersShipped}
|
||||
@@ -781,6 +730,7 @@ const OperationsMetrics = () => {
|
||||
)}
|
||||
{metrics.piecesPicked && (
|
||||
<Line
|
||||
yAxisId={useDualAxis ? "right" : "left"}
|
||||
type="monotone"
|
||||
dataKey="piecesPicked"
|
||||
name={SERIES_LABELS.piecesPicked}
|
||||
@@ -794,6 +744,7 @@ const OperationsMetrics = () => {
|
||||
)}
|
||||
{metrics.piecesShipped && (
|
||||
<Line
|
||||
yAxisId={useDualAxis ? "right" : "left"}
|
||||
type="monotone"
|
||||
dataKey="piecesShipped"
|
||||
name={SERIES_LABELS.piecesShipped}
|
||||
@@ -809,6 +760,7 @@ const OperationsMetrics = () => {
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PayrollMetricsResponse | null>(null);
|
||||
const [historicalData, setHistoricalData] = useState<PayrollMetricsResponse[]>([]);
|
||||
const [currentPeriodData, setCurrentPeriodData] = useState<PayrollMetricsResponse | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [periodCount, setPeriodCount] = useState<PeriodCountOption>(3);
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -147,18 +201,24 @@ const PayrollMetrics = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
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;
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setCurrentPeriodData(currentResponse);
|
||||
|
||||
if (currentResponse.payPeriod?.start) {
|
||||
const referenceStart = currentPayPeriodStart || currentResponse.payPeriod.start;
|
||||
if (!currentPayPeriodStart) {
|
||||
setCurrentPayPeriodStart(currentResponse.payPeriod.start);
|
||||
}
|
||||
|
||||
// @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type
|
||||
const response = (await acotService.getPayrollMetrics(params)) as PayrollMetricsResponse;
|
||||
// Fetch historical data
|
||||
const historical = await fetchHistoricalData(referenceStart, periodCount);
|
||||
if (!cancelled) {
|
||||
setData(response);
|
||||
// Update the current pay period start if not set (first load)
|
||||
if (!currentPayPeriodStart && response.payPeriod?.start) {
|
||||
setCurrentPayPeriodStart(response.payPeriod.start);
|
||||
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<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 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<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">
|
||||
Employee Hours - {data?.payPeriod?.label}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto mt-6">
|
||||
<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">Week 1</TableHead>
|
||||
<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>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select
|
||||
value={String(periodCount)}
|
||||
onValueChange={(value) => {
|
||||
setPeriodCount(Number(value) as PeriodCountOption);
|
||||
setCurrentPayPeriodStart(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PERIOD_COUNT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={String(option.value)}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
@@ -345,19 +478,19 @@ const PayrollMetrics = () => {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 px-3 min-w-[180px]"
|
||||
className="h-9 px-3 min-w-[120px]"
|
||||
onClick={goToCurrentPeriod}
|
||||
disabled={loading || data?.payPeriod?.isCurrent}
|
||||
disabled={loading || isAtCurrentPeriod}
|
||||
>
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
{loading ? "Loading..." : data?.payPeriod?.label || "Loading..."}
|
||||
{loading ? "Loading..." : "Current"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={() => navigatePeriod("next")}
|
||||
disabled={loading || data?.payPeriod?.isCurrent}
|
||||
disabled={loading || isAtCurrentPeriod}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -373,7 +506,7 @@ const PayrollMetrics = () => {
|
||||
actions={headerActions}
|
||||
/>
|
||||
|
||||
<CardContent className="p-6 pt-0 space-y-4">
|
||||
<CardContent className="p-6 pt-0">
|
||||
{!error && (
|
||||
loading ? (
|
||||
<SkeletonStats />
|
||||
@@ -383,7 +516,14 @@ const PayrollMetrics = () => {
|
||||
)}
|
||||
|
||||
{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 ? (
|
||||
<DashboardErrorState error={`Failed to load payroll data: ${error}`} className="mx-0 my-0" />
|
||||
) : !hasData ? (
|
||||
@@ -393,70 +533,76 @@ const PayrollMetrics = () => {
|
||||
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">
|
||||
<div className={`h-[300px] w-full lg:w-[65%] ${CARD_STYLES.base} rounded-lg p-0 relative`}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 20, right: 20, left: 20, bottom: 5 }}>
|
||||
<ComposedChart data={chartData} margin={{ top: 20, right: 20, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
className="text-xs text-muted-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
tick={{ fill: "currentColor", fontSize: 10 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="hours"
|
||||
tickFormatter={(value: number) => `${value}h`}
|
||||
className="text-xs text-muted-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
/>
|
||||
<Tooltip content={<PayrollTooltip />} />
|
||||
<YAxis
|
||||
yAxisId="fte"
|
||||
orientation="right"
|
||||
tickFormatter={(value: number) => value.toFixed(1)}
|
||||
className="text-xs text-muted-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
/>
|
||||
<Tooltip content={<PayrollTrendTooltip />} />
|
||||
<Legend />
|
||||
<Bar
|
||||
yAxisId="hours"
|
||||
dataKey="regular"
|
||||
name="Regular Hours"
|
||||
stackId="hours"
|
||||
fill={chartColors.regular}
|
||||
/>
|
||||
<Bar
|
||||
yAxisId="hours"
|
||||
dataKey="overtime"
|
||||
name="Overtime"
|
||||
stackId="hours"
|
||||
fill={chartColors.overtime}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.overtime > 0 ? chartColors.overtime : chartColors.regular}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
</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 = {
|
||||
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<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;
|
||||
|
||||
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 (
|
||||
<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.row}>
|
||||
<div className={TOOLTIP_STYLES.rowLabel}>
|
||||
<span
|
||||
className={TOOLTIP_STYLES.dot}
|
||||
style={{ backgroundColor: chartColors.regular }}
|
||||
/>
|
||||
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: chartColors.regular }} />
|
||||
<span className={TOOLTIP_STYLES.name}>Regular Hours</span>
|
||||
</div>
|
||||
<span className={TOOLTIP_STYLES.value}>{formatHours(regular || 0)}</span>
|
||||
<span className={TOOLTIP_STYLES.value}>{formatHours(data.regular)}</span>
|
||||
</div>
|
||||
{overtime != null && overtime > 0 && (
|
||||
{data.overtime > 0 && (
|
||||
<div className={TOOLTIP_STYLES.row}>
|
||||
<div className={TOOLTIP_STYLES.rowLabel}>
|
||||
<span
|
||||
className={TOOLTIP_STYLES.dot}
|
||||
style={{ backgroundColor: chartColors.overtime }}
|
||||
/>
|
||||
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: chartColors.overtime }} />
|
||||
<span className={TOOLTIP_STYLES.name}>Overtime</span>
|
||||
</div>
|
||||
<span className={TOOLTIP_STYLES.value}>{formatHours(overtime)}</span>
|
||||
<span className={TOOLTIP_STYLES.value}>{formatHours(data.overtime)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`${TOOLTIP_STYLES.row} border-t border-border/50 pt-1 mt-1`}>
|
||||
<div className={TOOLTIP_STYLES.rowLabel}>
|
||||
<span className={TOOLTIP_STYLES.name}>Total</span>
|
||||
<span className={TOOLTIP_STYLES.name}>Total Hours</span>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -106,7 +106,7 @@ export const DashboardSectionHeader: React.FC<DashboardSectionHeaderProps> = ({
|
||||
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";
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -57,15 +57,15 @@ export function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
</Protected>
|
||||
<Protected permission="dashboard:employee_metrics">
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div id="payroll-metrics" className="col-span-12 lg:col-span-6">
|
||||
<Protected permission="dashboard:payroll">
|
||||
<div id="payroll-metrics">
|
||||
<PayrollMetrics />
|
||||
</div>
|
||||
<div id="operations-metrics" className="col-span-12 lg:col-span-6">
|
||||
</Protected>
|
||||
<Protected permission="dashboard:operations">
|
||||
<div id="operations-metrics">
|
||||
<OperationsMetrics />
|
||||
</div>
|
||||
</div>
|
||||
</Protected>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<Protected permission="dashboard:feed">
|
||||
|
||||
Reference in New Issue
Block a user