Add surcharges to discount simulator, add new employee-related components to dashboard

This commit is contained in:
2026-01-25 15:21:57 -05:00
parent 3831cef234
commit aec02e490a
12 changed files with 3470 additions and 22 deletions
@@ -0,0 +1,913 @@
import { useEffect, useMemo, useState } from "react";
import { acotService } from "@/services/dashboard/acotService";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Area,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { TooltipProps } from "recharts";
import { Package, Truck, Gauge, TrendingUp } from "lucide-react";
import PeriodSelectionPopover, {
type QuickPreset,
} from "@/components/dashboard/PeriodSelectionPopover";
import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
DashboardSectionHeader,
DashboardStatCard,
DashboardStatCardSkeleton,
ChartSkeleton,
DashboardEmptyState,
DashboardErrorState,
TOOLTIP_STYLES,
METRIC_COLORS,
} from "@/components/dashboard/shared";
type ComparisonValue = {
absolute: number | null;
percentage: number | null;
};
type OperationsTotals = {
ordersPicked: number;
piecesPicked: number;
ticketCount: number;
pickingHours: number;
ordersShipped: number;
piecesShipped: number;
ordersPerHour: number;
piecesPerHour: number;
avgPickingSpeed: number;
};
type OperationsComparison = {
ordersPicked?: ComparisonValue;
piecesPicked?: ComparisonValue;
ordersShipped?: ComparisonValue;
piecesShipped?: ComparisonValue;
ordersPerHour?: ComparisonValue;
piecesPerHour?: ComparisonValue;
};
type EmployeePickingEntry = {
employeeId: number;
name: string;
ticketCount: number;
ordersPicked: number;
piecesPicked: number;
pickingHours: number;
avgPickingSpeed: number | null;
};
type EmployeeShippingEntry = {
employeeId: number;
name: string;
ordersShipped: number;
piecesShipped: number;
};
type TrendPoint = {
date: string;
timestamp: string;
ordersPicked: number;
piecesPicked: number;
ordersShipped: number;
piecesShipped: number;
};
type OperationsMetricsResponse = {
dateRange?: { label?: string };
totals: OperationsTotals;
previousTotals?: OperationsTotals | null;
comparison?: OperationsComparison | null;
byEmployee: {
picking: EmployeePickingEntry[];
shipping: EmployeeShippingEntry[];
};
trend: TrendPoint[];
};
type ChartSeriesKey = "ordersPicked" | "piecesPicked" | "ordersShipped" | "piecesShipped";
type GroupByOption = "day" | "week" | "month";
type ChartPoint = {
label: string;
timestamp: string | null;
ordersPicked: number | null;
piecesPicked: number | null;
ordersShipped: number | null;
piecesShipped: number | null;
tooltipLabel: string;
};
const chartColors: Record<ChartSeriesKey, string> = {
ordersPicked: METRIC_COLORS.orders,
piecesPicked: METRIC_COLORS.aov,
ordersShipped: METRIC_COLORS.profit,
piecesShipped: METRIC_COLORS.secondary,
};
const SERIES_LABELS: Record<ChartSeriesKey, string> = {
ordersPicked: "Orders Picked",
piecesPicked: "Pieces Picked",
ordersShipped: "Orders Shipped",
piecesShipped: "Pieces Shipped",
};
const SERIES_DEFINITIONS: Array<{
key: ChartSeriesKey;
label: string;
}> = [
{ key: "ordersPicked", label: SERIES_LABELS.ordersPicked },
{ key: "piecesPicked", label: SERIES_LABELS.piecesPicked },
{ key: "ordersShipped", label: SERIES_LABELS.ordersShipped },
{ key: "piecesShipped", label: SERIES_LABELS.piecesShipped },
];
const GROUP_BY_CHOICES: Array<{ value: GroupByOption; label: string }> = [
{ value: "day", label: "Days" },
{ value: "week", label: "Weeks" },
{ value: "month", label: "Months" },
];
const MONTHS = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
const MONTH_COUNT_LIMIT = 999;
const QUARTER_COUNT_LIMIT = 999;
const YEAR_COUNT_LIMIT = 999;
const formatMonthLabel = (year: number, monthIndex: number) => `${MONTHS[monthIndex]} ${year}`;
const formatQuarterLabel = (year: number, quarterIndex: number) => `Q${quarterIndex + 1} ${year}`;
function formatPeriodRangeLabel(period: CustomPeriod): string {
const range = computePeriodRange(period);
if (!range) return "";
const start = range.start;
const end = range.end;
if (period.type === "month") {
const startLabel = formatMonthLabel(start.getFullYear(), start.getMonth());
const endLabel = formatMonthLabel(end.getFullYear(), end.getMonth());
return period.count === 1 ? startLabel : `${startLabel} ${endLabel}`;
}
if (period.type === "quarter") {
const startQuarter = Math.floor(start.getMonth() / 3);
const endQuarter = Math.floor(end.getMonth() / 3);
const startLabel = formatQuarterLabel(start.getFullYear(), startQuarter);
const endLabel = formatQuarterLabel(end.getFullYear(), endQuarter);
return period.count === 1 ? startLabel : `${startLabel} ${endLabel}`;
}
const startYear = start.getFullYear();
const endYear = end.getFullYear();
return period.count === 1 ? `${startYear}` : `${startYear} ${endYear}`;
}
const formatNumber = (value: number, decimals = 0) => {
if (!Number.isFinite(value)) return "0";
return value.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
};
const formatHours = (value: number) => {
if (!Number.isFinite(value)) return "0h";
return `${value.toFixed(1)}h`;
};
const ensureValidCustomPeriod = (period: CustomPeriod): CustomPeriod => {
if (period.count < 1) {
return { ...period, count: 1 };
}
switch (period.type) {
case "month":
return {
...period,
startMonth: Math.min(Math.max(period.startMonth, 0), 11),
count: Math.min(period.count, MONTH_COUNT_LIMIT),
};
case "quarter":
return {
...period,
startQuarter: Math.min(Math.max(period.startQuarter, 0), 3),
count: Math.min(period.count, QUARTER_COUNT_LIMIT),
};
case "year":
default:
return {
...period,
count: Math.min(period.count, YEAR_COUNT_LIMIT),
};
}
};
function computePeriodRange(period: CustomPeriod): { start: Date; end: Date } | null {
const safePeriod = ensureValidCustomPeriod(period);
let start: Date;
if (safePeriod.type === "month") {
start = new Date(safePeriod.startYear, safePeriod.startMonth, 1, 0, 0, 0, 0);
const endExclusive = new Date(start);
endExclusive.setMonth(endExclusive.getMonth() + safePeriod.count);
endExclusive.setMilliseconds(endExclusive.getMilliseconds() - 1);
return { start, end: endExclusive };
}
if (safePeriod.type === "quarter") {
const startMonth = safePeriod.startQuarter * 3;
start = new Date(safePeriod.startYear, startMonth, 1, 0, 0, 0, 0);
const endExclusive = new Date(start);
endExclusive.setMonth(endExclusive.getMonth() + safePeriod.count * 3);
endExclusive.setMilliseconds(endExclusive.getMilliseconds() - 1);
return { start, end: endExclusive };
}
start = new Date(safePeriod.startYear, 0, 1, 0, 0, 0, 0);
const endExclusive = new Date(start);
endExclusive.setFullYear(endExclusive.getFullYear() + safePeriod.count);
endExclusive.setMilliseconds(endExclusive.getMilliseconds() - 1);
return { start, end: endExclusive };
}
const OperationsMetrics = () => {
const currentDate = useMemo(() => new Date(), []);
const currentYear = currentDate.getFullYear();
const [customPeriod, setCustomPeriod] = useState<CustomPeriod>({
type: "month",
startYear: currentYear,
startMonth: currentDate.getMonth(),
count: 1,
});
const [isLast30DaysMode, setIsLast30DaysMode] = useState<boolean>(true);
const [isPeriodPopoverOpen, setIsPeriodPopoverOpen] = useState<boolean>(false);
const [metrics, setMetrics] = useState<Record<ChartSeriesKey, boolean>>({
ordersPicked: true,
piecesPicked: false,
ordersShipped: true,
piecesShipped: false,
});
const [groupBy, setGroupBy] = useState<GroupByOption>("day");
const [data, setData] = useState<OperationsMetricsResponse | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const selectedRange = useMemo(() => {
if (isLast30DaysMode) {
const end = new Date(currentDate);
const start = new Date(currentDate);
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
start.setDate(start.getDate() - 29);
return { start, end };
}
return computePeriodRange(customPeriod);
}, [isLast30DaysMode, customPeriod, currentDate]);
const effectiveRangeEnd = useMemo(() => {
if (!selectedRange) return null;
const rangeEndMs = selectedRange.end.getTime();
const currentMs = currentDate.getTime();
const startMs = selectedRange.start.getTime();
const clampedMs = Math.min(rangeEndMs, currentMs);
const safeEndMs = clampedMs < startMs ? startMs : clampedMs;
return new Date(safeEndMs);
}, [selectedRange, currentDate]);
const requestRange = useMemo(() => {
if (!selectedRange) return null;
const end = effectiveRangeEnd ?? selectedRange.end;
return {
start: new Date(selectedRange.start),
end: new Date(end),
};
}, [selectedRange, effectiveRangeEnd]);
useEffect(() => {
let cancelled = false;
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const params: Record<string, string> = {};
if (isLast30DaysMode) {
params.timeRange = "last30days";
} else {
if (!selectedRange || !requestRange) {
setData(null);
return;
}
params.timeRange = "custom";
params.startDate = requestRange.start.toISOString();
params.endDate = requestRange.end.toISOString();
}
// @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type
const response = (await acotService.getOperationsMetrics(params)) as OperationsMetricsResponse;
if (!cancelled) {
setData(response);
}
} catch (err: unknown) {
if (!cancelled) {
let message = "Failed to load operations metrics";
if (typeof err === "object" && err !== null) {
const maybeError = err as { response?: { data?: { error?: unknown } }; message?: unknown };
const responseError = maybeError.response?.data?.error;
if (typeof responseError === "string" && responseError.trim().length > 0) {
message = responseError;
} else if (typeof maybeError.message === "string") {
message = maybeError.message;
}
}
setError(message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void fetchData();
return () => { cancelled = true; };
}, [isLast30DaysMode, selectedRange, requestRange]);
const cards = useMemo(() => {
if (!data?.totals) return [];
const totals = data.totals;
const comparison = data.comparison ?? {};
return [
{
key: "ordersPicked",
title: "Orders Picked",
value: formatNumber(totals.ordersPicked),
description: `${formatNumber(totals.piecesPicked)} pieces`,
trendValue: comparison.ordersPicked?.percentage,
iconColor: "blue" as const,
tooltip: "Total distinct orders picked (ship-together groups count as 1).",
},
{
key: "ordersShipped",
title: "Orders Shipped",
value: formatNumber(totals.ordersShipped),
description: `${formatNumber(totals.piecesShipped)} pieces`,
trendValue: comparison.ordersShipped?.percentage,
iconColor: "emerald" as const,
tooltip: "Total orders shipped (ship-together groups count as 1).",
},
{
key: "productivity",
title: "Productivity",
value: `${formatNumber(totals.ordersPerHour, 1)}/h`,
description: `${formatNumber(totals.piecesPerHour, 1)} pieces/hour`,
trendValue: comparison.ordersPerHour?.percentage,
iconColor: "purple" as const,
tooltip: "Orders and pieces picked per picking hour.",
},
{
key: "pickingSpeed",
title: "Picking Speed",
value: `${formatNumber(totals.avgPickingSpeed, 1)}/h`,
description: `${formatHours(totals.pickingHours)} picking time`,
iconColor: "orange" as const,
tooltip: "Average pieces picked per hour while actively picking.",
},
];
}, [data]);
const chartData = useMemo<ChartPoint[]>(() => {
if (!data?.trend?.length) return [];
const groupedData = new Map<string, {
label: string;
tooltipLabel: string;
timestamp: string;
ordersPicked: number;
piecesPicked: number;
ordersShipped: number;
piecesShipped: number;
}>();
data.trend.forEach((point) => {
const date = new Date(point.timestamp);
let key: string;
let label: string;
let tooltipLabel: string;
switch (groupBy) {
case "week": {
const weekStart = new Date(date);
weekStart.setDate(date.getDate() - date.getDay());
key = weekStart.toISOString().split("T")[0];
label = weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" });
tooltipLabel = `Week of ${weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
break;
}
case "month": {
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
label = date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
tooltipLabel = date.toLocaleDateString("en-US", { month: "long", year: "numeric" });
break;
}
default: {
key = point.date;
label = date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
tooltipLabel = date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" });
}
}
const existing = groupedData.get(key);
if (existing) {
existing.ordersPicked += point.ordersPicked || 0;
existing.piecesPicked += point.piecesPicked || 0;
existing.ordersShipped += point.ordersShipped || 0;
existing.piecesShipped += point.piecesShipped || 0;
} else {
groupedData.set(key, {
label,
tooltipLabel,
timestamp: point.timestamp,
ordersPicked: point.ordersPicked || 0,
piecesPicked: point.piecesPicked || 0,
ordersShipped: point.ordersShipped || 0,
piecesShipped: point.piecesShipped || 0,
});
}
});
return Array.from(groupedData.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, group]) => ({
label: group.label,
timestamp: group.timestamp,
ordersPicked: group.ordersPicked,
piecesPicked: group.piecesPicked,
ordersShipped: group.ordersShipped,
piecesShipped: group.piecesShipped,
tooltipLabel: group.tooltipLabel,
}));
}, [data, groupBy]);
const selectedRangeLabel = useMemo(() => {
if (isLast30DaysMode) return "Last 30 Days";
const label = formatPeriodRangeLabel(customPeriod);
if (!label) return "";
if (!selectedRange || !effectiveRangeEnd) return label;
const isPartial = effectiveRangeEnd.getTime() < selectedRange.end.getTime();
if (!isPartial) return label;
const partialLabel = effectiveRangeEnd.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
return `${label} (through ${partialLabel})`;
}, [isLast30DaysMode, customPeriod, selectedRange, effectiveRangeEnd]);
const hasActiveMetrics = useMemo(() => Object.values(metrics).some(Boolean), [metrics]);
const hasData = chartData.length > 0;
const handleGroupByChange = (value: string) => {
setGroupBy(value as GroupByOption);
};
const toggleMetric = (series: ChartSeriesKey) => {
setMetrics((prev) => ({
...prev,
[series]: !prev[series],
}));
};
const handleNaturalLanguageResult = (result: NaturalLanguagePeriodResult) => {
if (result === "last30days") {
setIsLast30DaysMode(true);
return;
}
if (result) {
setIsLast30DaysMode(false);
setCustomPeriod(result);
}
};
const handleQuickPeriod = (preset: QuickPreset) => {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const quarter = Math.floor(month / 3);
switch (preset) {
case "last30days":
setIsLast30DaysMode(true);
break;
case "thisMonth":
setIsLast30DaysMode(false);
setCustomPeriod({ type: "month", startYear: year, startMonth: month, count: 1 });
break;
case "lastMonth":
setIsLast30DaysMode(false);
const lastMonth = month === 0 ? 11 : month - 1;
const lastMonthYear = month === 0 ? year - 1 : year;
setCustomPeriod({ type: "month", startYear: lastMonthYear, startMonth: lastMonth, count: 1 });
break;
case "thisQuarter":
setIsLast30DaysMode(false);
setCustomPeriod({ type: "quarter", startYear: year, startQuarter: quarter, count: 1 });
break;
case "lastQuarter":
setIsLast30DaysMode(false);
const lastQuarter = quarter === 0 ? 3 : quarter - 1;
const lastQuarterYear = quarter === 0 ? year - 1 : year;
setCustomPeriod({ type: "quarter", startYear: lastQuarterYear, startQuarter: lastQuarter, count: 1 });
break;
case "thisYear":
setIsLast30DaysMode(false);
setCustomPeriod({ type: "year", startYear: year, count: 1 });
break;
default:
break;
}
};
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}
selectedLabel={selectedRangeLabel}
referenceDate={currentDate}
isLast30DaysActive={isLast30DaysMode}
onQuickSelect={handleQuickPeriod}
onApplyResult={handleNaturalLanguageResult}
/>
</>
) : null;
return (
<Card className={`w-full h-full ${CARD_STYLES.elevated}`}>
<DashboardSectionHeader
title="Operations"
size="large"
actions={headerActions}
/>
<CardContent className="p-6 pt-0 space-y-4">
{!error && (
loading ? (
<SkeletonStats />
) : (
cards.length > 0 && <OperationsStatGrid cards={cards} />
)
)}
{!error && (
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
<div className="flex flex-wrap gap-1">
{SERIES_DEFINITIONS.map((series) => (
<Button
key={series.key}
variant={metrics[series.key] ? "default" : "outline"}
size="sm"
onClick={() => toggleMetric(series.key)}
>
{series.label}
</Button>
))}
</div>
<Separator orientation="vertical" className="h-6 hidden sm:block" />
<Separator orientation="horizontal" className="sm:hidden w-20 my-2" />
<div className="flex items-center gap-2">
<div className="text-sm text-muted-foreground">Group:</div>
<Select value={groupBy} onValueChange={handleGroupByChange}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="Group By" />
</SelectTrigger>
<SelectContent>
{GROUP_BY_CHOICES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{loading ? (
<ChartSkeleton type="area" height="default" withCard={false} />
) : error ? (
<DashboardErrorState error={`Failed to load operations data: ${error}`} className="mx-0 my-0" />
) : !hasData ? (
<DashboardEmptyState
icon={Package}
title="No operations data available"
description="Try selecting a different time range"
/>
) : (
<div className={`h-[280px] ${CARD_STYLES.base} rounded-lg p-0 relative`}>
{!hasActiveMetrics ? (
<DashboardEmptyState
icon={TrendingUp}
title="No metrics selected"
description="Select at least one metric to visualize."
/>
) : (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 5, right: 15, left: 15, bottom: 5 }}>
<defs>
<linearGradient id="operationsOrdersPicked" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={chartColors.ordersPicked} stopOpacity={0.8} />
<stop offset="95%" stopColor={chartColors.ordersPicked} stopOpacity={0.3} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="label"
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
/>
<YAxis
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
type="monotone"
dataKey="ordersPicked"
name={SERIES_LABELS.ordersPicked}
stroke={chartColors.ordersPicked}
fill="url(#operationsOrdersPicked)"
strokeWidth={2}
/>
)}
{metrics.ordersShipped && (
<Line
type="monotone"
dataKey="ordersShipped"
name={SERIES_LABELS.ordersShipped}
stroke={chartColors.ordersShipped}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
)}
{metrics.piecesPicked && (
<Line
type="monotone"
dataKey="piecesPicked"
name={SERIES_LABELS.piecesPicked}
stroke={chartColors.piecesPicked}
strokeWidth={2}
strokeDasharray="5 3"
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
)}
{metrics.piecesShipped && (
<Line
type="monotone"
dataKey="piecesShipped"
name={SERIES_LABELS.piecesShipped}
stroke={chartColors.piecesShipped}
strokeWidth={2}
strokeDasharray="3 3"
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
)}
</ComposedChart>
</ResponsiveContainer>
)}
</div>
)}
</CardContent>
</Card>
);
};
type OperationsStatCardConfig = {
key: string;
title: string;
value: string;
description?: string;
trendValue?: number | null;
trendInverted?: boolean;
iconColor: "blue" | "orange" | "emerald" | "purple" | "cyan" | "amber";
tooltip?: string;
};
const ICON_MAP = {
ordersPicked: Package,
ordersShipped: Truck,
productivity: Gauge,
pickingSpeed: TrendingUp,
} as const;
function OperationsStatGrid({ cards }: { cards: OperationsStatCardConfig[] }) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full dashboard-stagger">
{cards.map((card) => (
<DashboardStatCard
key={card.key}
title={card.title}
value={card.value}
subtitle={card.description}
trend={
card.trendValue != null && Number.isFinite(card.trendValue)
? {
value: card.trendValue,
moreIsBetter: !card.trendInverted,
}
: undefined
}
icon={ICON_MAP[card.key as keyof typeof ICON_MAP]}
iconColor={card.iconColor}
tooltip={card.tooltip}
/>
))}
</div>
);
}
function SkeletonStats() {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
{Array.from({ length: 4 }).map((_, index) => (
<DashboardStatCardSkeleton key={index} hasIcon hasSubtitle />
))}
</div>
);
}
const OperationsTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
if (!active || !payload?.length) return null;
const basePoint = payload[0]?.payload as ChartPoint | undefined;
const resolvedLabel = basePoint?.tooltipLabel ?? label;
const desiredOrder: ChartSeriesKey[] = ["ordersPicked", "piecesPicked", "ordersShipped", "piecesShipped"];
const payloadMap = new Map(payload.map((entry) => [entry.dataKey as ChartSeriesKey, entry]));
const orderedPayload = desiredOrder
.map((key) => payloadMap.get(key))
.filter((entry): entry is (typeof payload)[0] => entry !== undefined);
return (
<div className={TOOLTIP_STYLES.container}>
<p className={TOOLTIP_STYLES.header}>{resolvedLabel}</p>
<div className={TOOLTIP_STYLES.content}>
{orderedPayload.map((entry, index) => {
const key = (entry.dataKey ?? "") as ChartSeriesKey;
const rawValue = entry.value;
const formattedValue = rawValue != null ? formatNumber(rawValue as number) : "—";
return (
<div key={`${key}-${index}`} className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span
className={TOOLTIP_STYLES.dot}
style={{ backgroundColor: entry.stroke || entry.color || "#888" }}
/>
<span className={TOOLTIP_STYLES.name}>
{SERIES_LABELS[key] ?? entry.name ?? key}
</span>
</div>
<span className={TOOLTIP_STYLES.value}>{formattedValue}</span>
</div>
);
})}
</div>
</div>
);
};
export default OperationsMetrics;
@@ -0,0 +1,558 @@
import { useEffect, useMemo, useState } 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";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { TooltipProps } from "recharts";
import { Clock, Users, AlertTriangle, ChevronLeft, ChevronRight, Calendar } from "lucide-react";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
DashboardSectionHeader,
DashboardStatCard,
DashboardStatCardSkeleton,
ChartSkeleton,
DashboardEmptyState,
DashboardErrorState,
TOOLTIP_STYLES,
METRIC_COLORS,
} from "@/components/dashboard/shared";
type ComparisonValue = {
absolute: number | null;
percentage: number | null;
};
type PayPeriodWeek = {
start: string;
end: string;
label: string;
};
type PayPeriod = {
start: string;
end: string;
label: string;
week1: PayPeriodWeek;
week2: PayPeriodWeek;
isCurrent: boolean;
};
type PayrollTotals = {
hours: number;
breakHours: number;
overtimeHours: number;
regularHours: number;
activeEmployees: number;
fte: number;
avgHoursPerEmployee: number;
};
type PayrollComparison = {
hours?: ComparisonValue;
overtimeHours?: ComparisonValue;
fte?: ComparisonValue;
activeEmployees?: ComparisonValue;
};
type EmployeePayrollEntry = {
employeeId: number;
name: string;
week1Hours: number;
week1BreakHours: number;
week1Overtime: number;
week1Regular: number;
week2Hours: number;
week2BreakHours: number;
week2Overtime: number;
week2Regular: number;
totalHours: number;
totalBreakHours: number;
overtimeHours: number;
regularHours: number;
};
type WeekSummary = {
week: number;
start: string;
end: string;
hours: number;
overtime: number;
regular: number;
};
type PayrollMetricsResponse = {
payPeriod: PayPeriod;
totals: PayrollTotals;
previousTotals?: PayrollTotals | null;
comparison?: PayrollComparison | null;
byEmployee: EmployeePayrollEntry[];
byWeek: WeekSummary[];
};
const chartColors = {
regular: METRIC_COLORS.orders,
overtime: METRIC_COLORS.expense,
};
const formatNumber = (value: number, decimals = 0) => {
if (!Number.isFinite(value)) return "0";
return value.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
};
const formatHours = (value: number) => {
if (!Number.isFinite(value)) return "0h";
return `${value.toFixed(1)}h`;
};
const PayrollMetrics = () => {
const [data, setData] = useState<PayrollMetricsResponse | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [currentPayPeriodStart, setCurrentPayPeriodStart] = useState<string | null>(null);
// Fetch data
useEffect(() => {
let cancelled = false;
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const params: Record<string, string> = {};
if (currentPayPeriodStart) {
params.payPeriodStart = currentPayPeriodStart;
}
// @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type
const response = (await acotService.getPayrollMetrics(params)) as PayrollMetricsResponse;
if (!cancelled) {
setData(response);
// Update the current pay period start if not set (first load)
if (!currentPayPeriodStart && response.payPeriod?.start) {
setCurrentPayPeriodStart(response.payPeriod.start);
}
}
} catch (err: unknown) {
if (!cancelled) {
let message = "Failed to load payroll metrics";
if (typeof err === "object" && err !== null) {
const maybeError = err as { response?: { data?: { error?: unknown } }; message?: unknown };
const responseError = maybeError.response?.data?.error;
if (typeof responseError === "string" && responseError.trim().length > 0) {
message = responseError;
} else if (typeof maybeError.message === "string") {
message = maybeError.message;
}
}
setError(message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void fetchData();
return () => { cancelled = true; };
}, [currentPayPeriodStart]);
const navigatePeriod = (direction: "prev" | "next") => {
if (!data?.payPeriod?.start) 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;
currentStart.setDate(currentStart.getDate() + offset);
setCurrentPayPeriodStart(currentStart.toISOString().split("T")[0]);
};
const goToCurrentPeriod = () => {
setCurrentPayPeriodStart(null); // null triggers loading current period
};
const cards = useMemo(() => {
if (!data?.totals) return [];
const totals = data.totals;
const comparison = data.comparison ?? {};
return [
{
key: "hours",
title: "Total Hours",
value: formatHours(totals.hours),
description: `${formatHours(totals.regularHours)} regular`,
trendValue: comparison.hours?.percentage,
iconColor: "blue" as const,
tooltip: "Total hours worked by all employees in this pay period.",
},
{
key: "overtime",
title: "Overtime",
value: formatHours(totals.overtimeHours),
description: totals.overtimeHours > 0
? `${formatNumber((totals.overtimeHours / totals.hours) * 100, 1)}% of total`
: "No overtime",
trendValue: comparison.overtimeHours?.percentage,
trendInverted: true,
iconColor: totals.overtimeHours > 0 ? "orange" as const : "emerald" as const,
tooltip: "Hours exceeding 40 per employee per week.",
},
{
key: "fte",
title: "FTE",
value: formatNumber(totals.fte, 2),
description: `${formatNumber(totals.activeEmployees)} employees`,
trendValue: comparison.fte?.percentage,
iconColor: "emerald" as const,
tooltip: "Full-Time Equivalents (80 hours = 1 FTE for 2-week period).",
},
{
key: "avgHours",
title: "Avg Hours",
value: formatHours(totals.avgHoursPerEmployee),
description: "Per employee",
iconColor: "purple" as const,
tooltip: "Average hours worked per active employee in this pay period.",
},
];
}, [data]);
const chartData = useMemo(() => {
if (!data?.byWeek) 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,
}));
}, [data]);
const hasData = data?.byWeek && data.byWeek.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>
))}
</TableBody>
</Table>
</div>
</div>
</DialogContent>
</Dialog>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-9 w-9"
onClick={() => navigatePeriod("prev")}
disabled={loading}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-9 px-3 min-w-[180px]"
onClick={goToCurrentPeriod}
disabled={loading || data?.payPeriod?.isCurrent}
>
<Calendar className="h-4 w-4 mr-2" />
{loading ? "Loading..." : data?.payPeriod?.label || "Loading..."}
</Button>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
onClick={() => navigatePeriod("next")}
disabled={loading || data?.payPeriod?.isCurrent}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
) : null;
return (
<Card className={`w-full h-full ${CARD_STYLES.elevated}`}>
<DashboardSectionHeader
title="Payroll"
size="large"
actions={headerActions}
/>
<CardContent className="p-6 pt-0 space-y-4">
{!error && (
loading ? (
<SkeletonStats />
) : (
cards.length > 0 && <PayrollStatGrid cards={cards} />
)
)}
{loading ? (
<ChartSkeleton type="bar" height="default" withCard={false} />
) : error ? (
<DashboardErrorState error={`Failed to load payroll data: ${error}`} className="mx-0 my-0" />
) : !hasData ? (
<DashboardEmptyState
icon={Clock}
title="No payroll data available"
description="Try selecting a different pay period"
/>
) : (
<div className={`h-[280px] ${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 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="label"
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
/>
<YAxis
tickFormatter={(value: number) => `${value}h`}
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
/>
<Tooltip content={<PayrollTooltip />} />
<Legend />
<Bar
dataKey="regular"
name="Regular Hours"
stackId="hours"
fill={chartColors.regular}
/>
<Bar
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>
</ResponsiveContainer>
</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;
value: string;
description?: string;
trendValue?: number | null;
trendInverted?: boolean;
iconColor: "blue" | "orange" | "emerald" | "purple" | "cyan" | "amber";
tooltip?: string;
};
const ICON_MAP = {
hours: Clock,
overtime: AlertTriangle,
fte: Users,
avgHours: Clock,
} as const;
function PayrollStatGrid({ cards }: { cards: PayrollStatCardConfig[] }) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full dashboard-stagger">
{cards.map((card) => (
<DashboardStatCard
key={card.key}
title={card.title}
value={card.value}
subtitle={card.description}
trend={
card.trendValue != null && Number.isFinite(card.trendValue)
? {
value: card.trendValue,
moreIsBetter: !card.trendInverted,
}
: undefined
}
icon={ICON_MAP[card.key as keyof typeof ICON_MAP]}
iconColor={card.iconColor}
tooltip={card.tooltip}
/>
))}
</div>
);
}
function SkeletonStats() {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
{Array.from({ length: 4 }).map((_, index) => (
<DashboardStatCardSkeleton key={index} hasIcon hasSubtitle />
))}
</div>
);
}
const PayrollTooltip = ({ 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);
return (
<div className={TOOLTIP_STYLES.container}>
<p className={TOOLTIP_STYLES.header}>{label}</p>
<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.name}>Regular Hours</span>
</div>
<span className={TOOLTIP_STYLES.value}>{formatHours(regular || 0)}</span>
</div>
{overtime != null && 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.name}>Overtime</span>
</div>
<span className={TOOLTIP_STYLES.value}>{formatHours(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>
</div>
<span className={`${TOOLTIP_STYLES.value} font-semibold`}>{formatHours(total)}</span>
</div>
</div>
</div>
);
};
export default PayrollMetrics;
@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, DiscountSimulationResponse, CogsCalculationMode } from "@/types/discount-simulator";
import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, SurchargeConfig, DiscountSimulationResponse, CogsCalculationMode } from "@/types/discount-simulator";
import { formatNumber } from "@/utils/productUtils";
import { PlusIcon, X } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
@@ -35,6 +35,8 @@ interface ConfigPanelProps {
onShippingPromoChange: (update: Partial<ConfigPanelProps["shippingPromo"]>) => void;
shippingTiers: ShippingTierConfig[];
onShippingTiersChange: (tiers: ShippingTierConfig[]) => void;
surcharges: SurchargeConfig[];
onSurchargesChange: (surcharges: SurchargeConfig[]) => void;
merchantFeePercent: number;
onMerchantFeeChange: (value: number) => void;
fixedCostPerOrder: number;
@@ -43,6 +45,7 @@ interface ConfigPanelProps {
onCogsCalculationModeChange: (mode: CogsCalculationMode) => void;
pointsPerDollar: number;
redemptionRate: number;
onRedemptionRateChange: (value: number) => void;
pointDollarValue: number;
onPointDollarValueChange: (value: number) => void;
onConfigInputChange: () => void;
@@ -65,6 +68,7 @@ const formatPercent = (value: number) => {
};
const generateTierId = () => `tier-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const generateSurchargeId = () => `surcharge-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const parseDateToTimestamp = (value?: string | null): number | undefined => {
if (!value) {
@@ -101,6 +105,8 @@ export function ConfigPanel({
onShippingPromoChange,
shippingTiers,
onShippingTiersChange,
surcharges,
onSurchargesChange,
merchantFeePercent,
onMerchantFeeChange,
fixedCostPerOrder,
@@ -109,6 +115,7 @@ export function ConfigPanel({
onCogsCalculationModeChange,
pointsPerDollar,
redemptionRate,
onRedemptionRateChange,
pointDollarValue,
onPointDollarValueChange,
onConfigInputChange,
@@ -235,6 +242,93 @@ export function ConfigPanel({
handleFieldBlur();
}, [sortShippingTiers, handleFieldBlur]);
// Surcharge handlers
useEffect(() => {
if (surcharges.length === 0) {
return;
}
const surchargesMissingIds = surcharges.some((s) => !s.id);
if (!surchargesMissingIds) {
return;
}
const normalizedSurcharges = surcharges.map((s) =>
s.id ? s : { ...s, id: generateSurchargeId() }
);
onSurchargesChange(normalizedSurcharges);
}, [surcharges, onSurchargesChange]);
const handleSurchargeUpdate = (index: number, update: Partial<SurchargeConfig>) => {
const items = [...surcharges];
const current = items[index];
if (!current) {
return;
}
const surchargeId = current.id ?? generateSurchargeId();
const merged = {
...current,
...update,
id: surchargeId,
};
const normalized: SurchargeConfig = {
...merged,
threshold: Number.isFinite(merged.threshold) ? merged.threshold ?? 0 : 0,
maxThreshold: Number.isFinite(merged.maxThreshold) && (merged.maxThreshold ?? 0) > 0 ? merged.maxThreshold : undefined,
amount: Number.isFinite(merged.amount) ? merged.amount ?? 0 : 0,
};
items[index] = normalized;
onSurchargesChange(items);
};
const handleSurchargeRemove = (index: number) => {
onConfigInputChange();
const items = surcharges.filter((_, i) => i !== index);
onSurchargesChange(items);
};
const handleSurchargeAdd = () => {
onConfigInputChange();
const lastThreshold = surcharges[surcharges.length - 1]?.threshold ?? 0;
const items = [
...surcharges,
{
threshold: lastThreshold,
target: "shipping" as const,
amount: 0,
id: generateSurchargeId(),
},
];
onSurchargesChange(items);
};
const sortSurcharges = useCallback(() => {
if (surcharges.length < 2) {
return;
}
const originalIds = surcharges.map((s) => s.id);
const sorted = [...surcharges]
.map((s) => ({
...s,
threshold: Number.isFinite(s.threshold) ? s.threshold : 0,
amount: Number.isFinite(s.amount) ? s.amount : 0,
}))
.sort((a, b) => a.threshold - b.threshold);
const orderChanged = sorted.some((s, index) => s.id !== originalIds[index]);
if (orderChanged) {
onSurchargesChange(sorted);
}
}, [surcharges, onSurchargesChange]);
const handleSurchargeBlur = useCallback(() => {
sortSurcharges();
handleFieldBlur();
}, [sortSurcharges, handleFieldBlur]);
const sectionTitleClass = "text-[0.65rem] font-semibold uppercase tracking-[0.18em] text-muted-foreground";
const sectionBaseClass = "flex flex-col rounded-md border border-border/60 bg-muted/30 px-3 py-2.5";
@@ -244,10 +338,10 @@ export function ConfigPanel({
const fieldClass = "flex flex-col gap-1";
const labelClass = "text-[0.65rem] uppercase tracking-wide text-muted-foreground";
const fieldRowClass = "flex flex-col gap-2";
const fieldRowHorizontalClass = "flex flex-col gap-2 sm:flex-row sm:items-end sm:gap-3";
const compactTriggerClass = "h-8 px-2 text-xs";
const compactNumberClass = "h-8 px-2 text-sm";
const compactWideNumberClass = "h-8 px-2 text-sm";
const fieldRowHorizontalClass = "flex flex-col gap-2 sm:flex-row sm:gap-3";
const compactTriggerClass = "h-8 px-1.5 text-xs";
const compactNumberClass = "h-8 px-1.5 text-sm";
const compactWideNumberClass = "h-8 px-1.5 text-sm";
const metricPillClass = "flex items-center gap-1 rounded border border-border/60 bg-background px-2 py-1 text-[0.68rem] font-medium text-foreground";
const showProductAdjustments = productPromo.type !== "none";
const showShippingAdjustments = shippingPromo.type !== "none";
@@ -255,8 +349,8 @@ export function ConfigPanel({
return (
<Card className="w-full">
<CardContent className="flex flex-col gap-3 px-4 py-4">
<div className="space-y-4">
<CardContent className="flex flex-col gap-2 px-2 py-2">
<div className="space-y-2">
<section className={sectionClass}>
<div className={fieldRowClass}>
<div className={fieldClass}>
@@ -487,7 +581,7 @@ export function ConfigPanel({
return (
<div
key={tierKey}
className="relative grid gap-2 rounded px-2 py-2 text-xs sm:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_minmax(0,1fr)_auto] sm:items-end"
className="relative grid gap-2 rounded px-2 py-0.5 text-xs sm:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_minmax(0,1fr)_auto] sm:items-end"
>
<div>
<Input
@@ -553,6 +647,114 @@ export function ConfigPanel({
)}
</section>
<section className={compactSectionClass}>
<div className={sectionHeaderClass}>
<span className={sectionTitleClass}>Surcharges</span>
<Button variant="outline" size="sm" onClick={handleSurchargeAdd} className="flex items-center gap-1">
<PlusIcon className="w-3 h-3" />
Add surcharge
</Button>
</div>
{surcharges.length === 0 ? (
<p className="text-xs text-muted-foreground">Add surcharges to model fees at different order values.</p>
) : (
<ScrollArea>
<div className="flex flex-col gap-2 pr-1 -mx-2">
<div className="grid gap-2 px-2 py-1 text-[0.65rem] font-medium uppercase tracking-[0.18em] text-muted-foreground sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,0.8fr)_auto]">
<div>Min</div>
<div>Max</div>
<div>Add To</div>
<div>Amount</div>
<div className="w-1.5" aria-hidden="true" />
</div>
{surcharges.map((surcharge, index) => {
const surchargeKey = surcharge.id ?? `surcharge-${index}`;
return (
<div
key={surchargeKey}
className="relative grid gap-2 rounded px-2 py-2 text-xs sm:grid-cols-[minmax(0,0.9fr)_minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,0.9fr)_auto] sm:items-end"
>
<div>
<Input
className={compactNumberClass}
type="number"
step="1"
value={surcharge.threshold}
onChange={(event) => {
onConfigInputChange();
handleSurchargeUpdate(index, {
threshold: parseNumber(event.target.value, 0),
});
}}
onBlur={handleSurchargeBlur}
/>
</div>
<div>
<Input
className={compactNumberClass}
type="number"
step="1"
placeholder="∞"
value={surcharge.maxThreshold ?? ''}
onChange={(event) => {
onConfigInputChange();
const val = event.target.value;
handleSurchargeUpdate(index, {
maxThreshold: val === '' ? undefined : parseNumber(val, 0),
});
}}
onBlur={handleSurchargeBlur}
/>
</div>
<div>
<Select
value={surcharge.target}
onValueChange={(value) => {
onConfigInputChange();
handleSurchargeUpdate(index, { target: value as SurchargeConfig["target"] });
}}
>
<SelectTrigger className={`${compactTriggerClass} w-full`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="shipping">Shipping</SelectItem>
<SelectItem value="order">Order</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Input
className={compactNumberClass}
type="number"
step="0.01"
value={surcharge.amount}
onChange={(event) => {
onConfigInputChange();
handleSurchargeUpdate(index, { amount: parseNumber(event.target.value, 0) });
}}
onBlur={handleSurchargeBlur}
/>
</div>
<div className="w-1.5" aria-hidden="true" />
<div className="absolute -right-0.5 top-1/2 -translate-y-1/2 flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => handleSurchargeRemove(index)}
className="p-1"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
</div>
</ScrollArea>
)}
</section>
<section className={sectionClass}>
<div className={sectionHeaderClass}>
<span className={sectionTitleClass}>Order costs</span>
@@ -614,14 +816,24 @@ export function ConfigPanel({
<span className={sectionTitleClass}>Rewards points</span>
</div>
<div className={fieldRowClass}>
<div className="grid gap-3 sm:grid-cols-2">
<div className={fieldRowHorizontalClass}>
<div className="flex flex-col gap-1.5">
<span className={labelClass}>Points per $</span>
<span className="text-sm font-medium">{Number.isFinite(pointsPerDollar) ? pointsPerDollar.toFixed(4) : '—'}</span>
<span className="text-sm font-medium mt-1">{Number.isFinite(pointsPerDollar) ? pointsPerDollar.toFixed(4) : '—'}</span>
</div>
<div className="flex flex-col gap-1.5">
<span className={labelClass}>Redemption rate</span>
<span className="text-sm font-medium">{formatPercent(redemptionRate)}</span>
<div className={fieldClass}>
<Label className={labelClass}>Redemption rate (%)</Label>
<Input
className={compactNumberClass}
type="number"
step="1"
value={Math.round(redemptionRate * 100)}
onChange={(event) => {
onConfigInputChange();
onRedemptionRateChange(parseNumber(event.target.value, 90) / 100);
}}
onBlur={handleFieldBlur}
/>
</div>
</div>
<div className={fieldClass}>
+12
View File
@@ -14,6 +14,8 @@ import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard";
import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics";
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
import TypeformDashboard from "@/components/dashboard/TypeformDashboard";
import PayrollMetrics from "@/components/dashboard/PayrollMetrics";
import OperationsMetrics from "@/components/dashboard/OperationsMetrics";
import Header from "@/components/dashboard/Header";
import Navigation from "@/components/dashboard/Navigation";
@@ -55,6 +57,16 @@ 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">
<PayrollMetrics />
</div>
<div id="operations-metrics" className="col-span-12 lg:col-span-6">
<OperationsMetrics />
</div>
</div>
</Protected>
<div className="grid grid-cols-12 gap-4">
<Protected permission="dashboard:feed">
<div id="feed" className="col-span-12 lg:col-span-6 xl:col-span-4 h-[600px] lg:h-[740px]">
+33 -6
View File
@@ -16,13 +16,15 @@ import {
DiscountPromoType,
ShippingPromoType,
ShippingTierConfig,
SurchargeConfig,
CogsCalculationMode,
} from "@/types/discount-simulator";
import { useToast } from "@/hooks/use-toast";
const DEFAULT_POINT_VALUE = 0.005;
const DEFAULT_REDEMPTION_RATE = 0.9;
const DEFAULT_MERCHANT_FEE = 2.9;
const DEFAULT_FIXED_COST = 1.5;
const DEFAULT_FIXED_COST = 1.25;
const STORAGE_KEY = 'discount-simulator-config-v1';
const getDefaultDateRange = (): DateRange => ({
@@ -56,11 +58,13 @@ export function DiscountSimulator() {
const [productPromo, setProductPromo] = useState(defaultProductPromo);
const [shippingPromo, setShippingPromo] = useState(defaultShippingPromo);
const [shippingTiers, setShippingTiers] = useState<ShippingTierConfig[]>([]);
const [surcharges, setSurcharges] = useState<SurchargeConfig[]>([]);
const [merchantFeePercent, setMerchantFeePercent] = useState(DEFAULT_MERCHANT_FEE);
const [fixedCostPerOrder, setFixedCostPerOrder] = useState(DEFAULT_FIXED_COST);
const [cogsCalculationMode, setCogsCalculationMode] = useState<CogsCalculationMode>('actual');
const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE);
const [pointDollarTouched, setPointDollarTouched] = useState(false);
const [redemptionRate, setRedemptionRate] = useState(DEFAULT_REDEMPTION_RATE);
const [simulationResult, setSimulationResult] = useState<DiscountSimulationResponse | undefined>(undefined);
const [baselineResult, setBaselineResult] = useState<DiscountSimulationResponse | undefined>(undefined);
const [isSimulating, setIsSimulating] = useState(false);
@@ -135,7 +139,7 @@ export function DiscountSimulator() {
const payloadPointsConfig = {
pointsPerDollar: null,
redemptionRate: null,
redemptionRate,
pointDollarValue,
};
@@ -156,6 +160,11 @@ export function DiscountSimulator() {
void id;
return rest;
}),
surcharges: surcharges.map((surcharge) => {
const { id, ...rest } = surcharge;
void id;
return rest;
}),
merchantFeePercent,
fixedCostPerOrder,
cogsCalculationMode,
@@ -168,10 +177,12 @@ export function DiscountSimulator() {
productPromo,
shippingPromo,
shippingTiers,
surcharges,
merchantFeePercent,
fixedCostPerOrder,
cogsCalculationMode,
pointDollarValue,
redemptionRate,
]);
const simulationMutation = useMutation<
@@ -249,6 +260,7 @@ export function DiscountSimulator() {
productPromo?: typeof defaultProductPromo;
shippingPromo?: typeof defaultShippingPromo;
shippingTiers?: ShippingTierConfig[];
surcharges?: SurchargeConfig[];
merchantFeePercent?: number;
fixedCostPerOrder?: number;
cogsCalculationMode?: CogsCalculationMode;
@@ -258,6 +270,7 @@ export function DiscountSimulator() {
pointDollarValue?: number | null;
};
pointDollarValue?: number;
redemptionRate?: number;
};
skipAutoRunRef.current = true;
@@ -290,6 +303,10 @@ export function DiscountSimulator() {
setShippingTiers(parsed.shippingTiers);
}
if (Array.isArray(parsed.surcharges)) {
setSurcharges(parsed.surcharges);
}
if (typeof parsed.merchantFeePercent === 'number') {
setMerchantFeePercent(parsed.merchantFeePercent);
}
@@ -312,6 +329,10 @@ export function DiscountSimulator() {
setPointDollarTouched(true);
}
if (typeof parsed.redemptionRate === 'number') {
setRedemptionRate(parsed.redemptionRate);
}
setLoadedFromStorage(true);
} catch (error) {
console.error('Failed to load discount simulator config', error);
@@ -336,12 +357,14 @@ export function DiscountSimulator() {
productPromo,
shippingPromo,
shippingTiers,
surcharges,
merchantFeePercent,
fixedCostPerOrder,
cogsCalculationMode,
pointDollarValue,
redemptionRate,
});
}, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, pointDollarValue]);
}, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, surcharges, merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, pointDollarValue, redemptionRate]);
useEffect(() => {
if (!hasLoadedConfig) {
@@ -388,7 +411,6 @@ export function DiscountSimulator() {
}, [loadedFromStorage, runSimulation]);
const currentPointsPerDollar = simulationResult?.totals?.pointsPerDollar ?? 0;
const currentRedemptionRate = simulationResult?.totals?.redemptionRate ?? 0;
const recommendedPointDollarValue = simulationResult?.totals?.pointDollarValue;
const handlePointDollarValueChange = (value: number) => {
@@ -422,11 +444,13 @@ export function DiscountSimulator() {
setProductPromo(defaultProductPromo);
setShippingPromo(defaultShippingPromo);
setShippingTiers([]);
setSurcharges([]);
setMerchantFeePercent(DEFAULT_MERCHANT_FEE);
setFixedCostPerOrder(DEFAULT_FIXED_COST);
setCogsCalculationMode('actual');
setPointDollarValue(DEFAULT_POINT_VALUE);
setPointDollarTouched(false);
setRedemptionRate(DEFAULT_REDEMPTION_RATE);
setSimulationResult(undefined);
if (typeof window !== 'undefined') {
@@ -451,7 +475,7 @@ export function DiscountSimulator() {
<h1 className="text-3xl font-bold">Discount Simulator</h1>
</div>
<div className="grid gap-6 lg:grid-cols-[300px,1fr] xl:grid-cols-[300px,1fr]">
<div className="grid gap-4 md:grid-cols-[300px,1fr] lg:grid-cols-[340px,1fr]">
{/* Left Sidebar - Configuration */}
<div className="space-y-4">
<ConfigPanel
@@ -467,6 +491,8 @@ export function DiscountSimulator() {
onShippingPromoChange={(update) => setShippingPromo((prev) => ({ ...prev, ...update }))}
shippingTiers={shippingTiers}
onShippingTiersChange={setShippingTiers}
surcharges={surcharges}
onSurchargesChange={setSurcharges}
merchantFeePercent={merchantFeePercent}
onMerchantFeeChange={setMerchantFeePercent}
fixedCostPerOrder={fixedCostPerOrder}
@@ -474,7 +500,8 @@ export function DiscountSimulator() {
cogsCalculationMode={cogsCalculationMode}
onCogsCalculationModeChange={setCogsCalculationMode}
pointsPerDollar={currentPointsPerDollar}
redemptionRate={currentRedemptionRate}
redemptionRate={redemptionRate}
onRedemptionRateChange={setRedemptionRate}
pointDollarValue={pointDollarValue}
onPointDollarValueChange={handlePointDollarValueChange}
onConfigInputChange={handleConfigInputChange}
@@ -214,6 +214,39 @@ export const acotService = {
);
},
// Get employee metrics data (hours, picking, shipping) - legacy, kept for backwards compatibility
getEmployeeMetrics: async (params) => {
const cacheKey = `employee_metrics_${JSON.stringify(params)}`;
return deduplicatedRequest(cacheKey, () =>
retryRequest(async () => {
const response = await acotApi.get('/api/acot/employee-metrics', { params });
return response.data;
})
);
},
// Get payroll metrics data (hours, overtime, pay periods)
getPayrollMetrics: async (params) => {
const cacheKey = `payroll_metrics_${JSON.stringify(params)}`;
return deduplicatedRequest(cacheKey, () =>
retryRequest(async () => {
const response = await acotApi.get('/api/acot/payroll-metrics', { params });
return response.data;
})
);
},
// Get operations metrics data (picking, shipping)
getOperationsMetrics: async (params) => {
const cacheKey = `operations_metrics_${JSON.stringify(params)}`;
return deduplicatedRequest(cacheKey, () =>
retryRequest(async () => {
const response = await acotApi.get('/api/acot/operations-metrics', { params });
return response.data;
})
);
},
// Utility functions
clearCache,
};
+13
View File
@@ -19,6 +19,16 @@ export interface ShippingTierConfig {
id?: string;
}
export type SurchargeTarget = 'shipping' | 'order';
export interface SurchargeConfig {
threshold: number;
maxThreshold?: number;
target: SurchargeTarget;
amount: number;
id?: string;
}
export interface DiscountSimulationBucket {
key: string;
label: string;
@@ -33,6 +43,8 @@ export interface DiscountSimulationBucket {
shippingChargeBase: number;
shippingAfterAuto: number;
shipPromoDiscount: number;
shippingSurcharge: number;
orderSurcharge: number;
customerShipCost: number;
actualShippingCost: number;
totalRevenue: number;
@@ -90,6 +102,7 @@ export interface DiscountSimulationRequest {
maxDiscount: number;
};
shippingTiers: ShippingTierConfig[];
surcharges: SurchargeConfig[];
merchantFeePercent: number;
fixedCostPerOrder: number;
cogsCalculationMode: CogsCalculationMode;