-
-
-
-
- `${value}h`}
- className="text-xs text-muted-foreground"
- tick={{ fill: "currentColor" }}
- />
- } />
-
-
-
- {chartData.map((entry, index) => (
- 0 ? chartColors.overtime : chartColors.regular}
- />
- ))}
- |
-
-
+
+
+
+
+
+
+ `${value}h`}
+ className="text-xs text-muted-foreground"
+ tick={{ fill: "currentColor" }}
+ />
+ value.toFixed(1)}
+ className="text-xs text-muted-foreground"
+ tick={{ fill: "currentColor" }}
+ />
+ } />
+
+
+
+
+
+
+
+
)}
- {!loading && !error && data?.byWeek && data.byWeek.some(w => w.overtime > 0) && (
-
-
-
- Overtime detected: {formatHours(data.totals.overtimeHours)} total
- ({data.byEmployee?.filter(e => e.overtimeHours > 0).length || 0} employees)
-
-
- )}
+
);
};
-function formatWeekRange(start: string, end: string): string {
- const startDate = new Date(start + "T00:00:00");
- const endDate = new Date(end + "T00:00:00");
-
- const startStr = startDate.toLocaleDateString("en-US", { month: "short", day: "numeric" });
- const endStr = endDate.toLocaleDateString("en-US", { month: "short", day: "numeric" });
-
- return `${startStr} – ${endStr}`;
-}
-
type PayrollStatCardConfig = {
key: string;
title: string;
@@ -472,7 +618,7 @@ const ICON_MAP = {
hours: Clock,
overtime: AlertTriangle,
fte: Users,
- avgHours: Clock,
+ periods: TrendingUp,
} as const;
function PayrollStatGrid({ cards }: { cards: PayrollStatCardConfig[] }) {
@@ -511,12 +657,21 @@ function SkeletonStats() {
);
}
-const PayrollTooltip = ({ active, payload, label }: TooltipProps
) => {
+type TrendChartPoint = {
+ label: string;
+ periodStart: string;
+ regular: number;
+ overtime: number;
+ total: number;
+ fte: number;
+ employees: number;
+};
+
+const PayrollTrendTooltip = ({ active, payload, label }: TooltipProps) => {
if (!active || !payload?.length) return null;
- const regular = payload.find(p => p.dataKey === "regular")?.value as number | undefined;
- const overtime = payload.find(p => p.dataKey === "overtime")?.value as number | undefined;
- const total = (regular || 0) + (overtime || 0);
+ const data = payload[0]?.payload as TrendChartPoint | undefined;
+ if (!data) return null;
return (
@@ -524,35 +679,192 @@ const PayrollTooltip = ({ active, payload, label }: TooltipProps
-
+
Regular Hours
-
{formatHours(regular || 0)}
+
{formatHours(data.regular)}
- {overtime != null && overtime > 0 && (
+ {data.overtime > 0 && (
-
+
Overtime
-
{formatHours(overtime)}
+
{formatHours(data.overtime)}
)}
- Total
+ Total Hours
-
{formatHours(total)}
+
{formatHours(data.total)}
+
+
+
+
+ FTE
+
+
{formatNumber(data.fte, 2)}
+
+
+
+ Employees
+
+
{data.employees}
);
};
+function EmployeeTableSkeleton() {
+ return (
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
+
+type AggregatedEmployee = {
+ employeeId: number;
+ name: string;
+ totalHours: number;
+ overtimeHours: number;
+ regularHours: number;
+ periodCount: number;
+ week1Hours?: number;
+ week2Hours?: number;
+ week1Overtime?: number;
+ week2Overtime?: number;
+};
+
+function PayrollEmployeeSummary({
+ employees,
+ periodCount,
+ maxRows = 10
+}: {
+ employees: AggregatedEmployee[];
+ periodCount: number;
+ maxRows?: number;
+}) {
+ const sortedEmployees = useMemo(() => {
+ return [...employees]
+ .sort((a, b) => b.totalHours - a.totalHours)
+ .slice(0, maxRows);
+ }, [employees, maxRows]);
+
+ const hasWeekData = periodCount === 1 && employees.some((e) => e.week1Hours != null);
+
+ if (sortedEmployees.length === 0) {
+ return (
+
+ );
+ }
+
+ const periodLabel = periodCount === 1
+ ? "1 period"
+ : `${periodCount} periods`;
+
+ return (
+
+
+
+ Employee Summary
+ ({periodLabel})
+
+
+
+
+
+
+ Name
+ {hasWeekData ? (
+ <>
+ Wk 1
+ Wk 2
+ >
+ ) : (
+ Regular
+ )}
+ Total
+ OT
+ {periodCount > 1 && (
+ Avg/Per
+ )}
+
+
+
+ {sortedEmployees.map((emp) => (
+ 0 ? "bg-orange-500/5" : ""}
+ >
+
+ {emp.name}
+
+ {hasWeekData ? (
+ <>
+
+ 0 ? "text-orange-600 dark:text-orange-400 font-medium" : "text-muted-foreground"}>
+ {formatHours(emp.week1Hours || 0)}
+
+
+
+ 0 ? "text-orange-600 dark:text-orange-400 font-medium" : "text-muted-foreground"}>
+ {formatHours(emp.week2Hours || 0)}
+
+
+ >
+ ) : (
+
+ {formatHours(emp.regularHours)}
+
+ )}
+
+ {formatHours(emp.totalHours)}
+
+
+ {emp.overtimeHours > 0 ? (
+
+ {formatHours(emp.overtimeHours)}
+
+ ) : (
+ —
+ )}
+
+ {periodCount > 1 && (
+
+ {formatHours(emp.totalHours / emp.periodCount)}
+
+ )}
+
+ ))}
+
+
+
+ {employees.length > maxRows && (
+
+
+ +{employees.length - maxRows} more employees
+
+
+ )}
+
+ );
+}
+
export default PayrollMetrics;
diff --git a/inventory/src/components/dashboard/shared/DashboardSectionHeader.tsx b/inventory/src/components/dashboard/shared/DashboardSectionHeader.tsx
index 3792c57..07e6271 100644
--- a/inventory/src/components/dashboard/shared/DashboardSectionHeader.tsx
+++ b/inventory/src/components/dashboard/shared/DashboardSectionHeader.tsx
@@ -106,7 +106,7 @@ export const DashboardSectionHeader: React.FC = ({
compact = false,
size = "default",
}) => {
- const paddingClass = compact ? "p-4 pb-2" : "p-6 pb-4";
+ const paddingClass = compact ? "p-4 pb-2" : "p-6 pb-2";
const titleClass = size === "large"
? "text-xl font-semibold text-foreground"
: "text-lg font-semibold text-foreground";
diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts
index 34ee091..941dc3b 100644
--- a/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts
+++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts
@@ -108,12 +108,9 @@ export function useAutoInlineAiValidation() {
typeof row.name === 'string' &&
row.name.trim();
- // Check description context: company + line + name + description
- const hasDescContext =
- hasNameContext &&
- row.description &&
- typeof row.description === 'string' &&
- row.description.trim();
+ // Check description context: company + line + name (description can be empty)
+ // We want to validate descriptions even when empty so AI can suggest one
+ const hasDescContext = hasNameContext;
// Skip if already auto-validated (shouldn't happen on first run, but be safe)
const nameAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-name`);
diff --git a/inventory/src/pages/Dashboard.tsx b/inventory/src/pages/Dashboard.tsx
index 7078e92..288fb66 100644
--- a/inventory/src/pages/Dashboard.tsx
+++ b/inventory/src/pages/Dashboard.tsx
@@ -57,14 +57,14 @@ export function Dashboard() {