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.
|
* 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.
|
||||||
@@ -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),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user