3 Commits

11 changed files with 1027 additions and 993 deletions

View File

@@ -44,7 +44,13 @@ import {
import type { TooltipProps } from "recharts"; import type { TooltipProps } from "recharts";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { ArrowUpRight, ArrowDownRight, Minus, TrendingUp, AlertCircle } from "lucide-react"; import {
Tooltip as UITooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "@/components/ui/tooltip";
import { ArrowUp, ArrowDown, Minus, TrendingUp, AlertCircle, Info } from "lucide-react";
import PeriodSelectionPopover, { import PeriodSelectionPopover, {
type QuickPreset, type QuickPreset,
} from "@/components/dashboard/PeriodSelectionPopover"; } from "@/components/dashboard/PeriodSelectionPopover";
@@ -112,7 +118,7 @@ type FinancialResponse = {
trend: FinancialTrendPoint[]; trend: FinancialTrendPoint[];
}; };
type ChartSeriesKey = "income" | "cogs" | "profit" | "margin"; type ChartSeriesKey = "income" | "cogs" | "cogsPercentage" | "profit" | "margin";
type GroupByOption = "day" | "month" | "quarter" | "year"; type GroupByOption = "day" | "month" | "quarter" | "year";
@@ -121,6 +127,7 @@ type ChartPoint = {
timestamp: string | null; timestamp: string | null;
income: number | null; income: number | null;
cogs: number | null; cogs: number | null;
cogsPercentage: number | null;
profit: number | null; profit: number | null;
margin: number | null; margin: number | null;
tooltipLabel: string; tooltipLabel: string;
@@ -128,15 +135,17 @@ type ChartPoint = {
}; };
const chartColors: Record<ChartSeriesKey, string> = { const chartColors: Record<ChartSeriesKey, string> = {
income: "#2563eb", income: "#3b82f6",
cogs: "#f97316", cogs: "#f97316",
cogsPercentage: "#fb923c",
profit: "#10b981", profit: "#10b981",
margin: "#0ea5e9", margin: "#8b5cf6",
}; };
const SERIES_LABELS: Record<ChartSeriesKey, string> = { const SERIES_LABELS: Record<ChartSeriesKey, string> = {
income: "Total Income", income: "Total Income",
cogs: "COGS", cogs: "COGS",
cogsPercentage: "COGS % of Income",
profit: "Gross Profit", profit: "Gross Profit",
margin: "Profit Margin", margin: "Profit Margin",
}; };
@@ -148,15 +157,16 @@ const SERIES_DEFINITIONS: Array<{
}> = [ }> = [
{ key: "income", label: SERIES_LABELS.income, type: "currency" }, { key: "income", label: SERIES_LABELS.income, type: "currency" },
{ key: "cogs", label: SERIES_LABELS.cogs, type: "currency" }, { key: "cogs", label: SERIES_LABELS.cogs, type: "currency" },
{ key: "cogsPercentage", label: SERIES_LABELS.cogsPercentage, type: "percentage" },
{ key: "profit", label: SERIES_LABELS.profit, type: "currency" }, { key: "profit", label: SERIES_LABELS.profit, type: "currency" },
{ key: "margin", label: SERIES_LABELS.margin, type: "percentage" }, { key: "margin", label: SERIES_LABELS.margin, type: "percentage" },
]; ];
const GROUP_BY_CHOICES: Array<{ value: GroupByOption; label: string }> = [ const GROUP_BY_CHOICES: Array<{ value: GroupByOption; label: string }> = [
{ value: "day", label: "Daily" }, { value: "day", label: "Days" },
{ value: "month", label: "Monthly" }, { value: "month", label: "Months" },
{ value: "quarter", label: "Quarterly" }, { value: "quarter", label: "Quarters" },
{ value: "year", label: "Yearly" }, { value: "year", label: "Years" },
]; ];
const MONTHS = [ const MONTHS = [
@@ -176,9 +186,9 @@ const MONTHS = [
const QUARTERS = ["Q1", "Q2", "Q3", "Q4"]; const QUARTERS = ["Q1", "Q2", "Q3", "Q4"];
const MONTH_COUNT_LIMIT = 12; const MONTH_COUNT_LIMIT = 999;
const QUARTER_COUNT_LIMIT = 8; const QUARTER_COUNT_LIMIT = 999;
const YEAR_COUNT_LIMIT = 5; const YEAR_COUNT_LIMIT = 999;
const formatMonthLabel = (year: number, monthIndex: number) => `${MONTHS[monthIndex]} ${year}`; const formatMonthLabel = (year: number, monthIndex: number) => `${MONTHS[monthIndex]} ${year}`;
@@ -252,6 +262,7 @@ type AggregatedTrendPoint = {
timestamp: string; timestamp: string;
income: number; income: number;
cogs: number; cogs: number;
cogsPercentage: number;
profit: number; profit: number;
margin: number; margin: number;
isFuture: boolean; isFuture: boolean;
@@ -364,6 +375,7 @@ const aggregateTrendPoints = (points: RawTrendPoint[], groupBy: GroupByOption):
const income = bucket.income; const income = bucket.income;
const profit = income - bucket.cogs; const profit = income - bucket.cogs;
const margin = income !== 0 ? (profit / income) * 100 : 0; const margin = income !== 0 ? (profit / income) * 100 : 0;
const cogsPercentage = income !== 0 ? (bucket.cogs / income) * 100 : 0;
return { return {
id: bucket.key, id: bucket.key,
@@ -373,6 +385,7 @@ const aggregateTrendPoints = (points: RawTrendPoint[], groupBy: GroupByOption):
timestamp: bucket.start.toISOString(), timestamp: bucket.start.toISOString(),
income, income,
cogs: bucket.cogs, cogs: bucket.cogs,
cogsPercentage,
profit, profit,
margin, margin,
isFuture: false, isFuture: false,
@@ -472,6 +485,7 @@ const extendAggregatedTrendPoints = (
timestamp: bucketStart.toISOString(), timestamp: bucketStart.toISOString(),
income: 0, income: 0,
cogs: 0, cogs: 0,
cogsPercentage: 0,
profit: 0, profit: 0,
margin: 0, margin: 0,
isFuture: isFutureBucket, isFuture: isFutureBucket,
@@ -509,14 +523,14 @@ const buildTrendLabel = (
if (options?.isPercentage) { if (options?.isPercentage) {
return { return {
direction, direction,
label: `${absolute > 0 ? "+" : absolute < 0 ? "-" : ""}${formatPercentage(absoluteValue, 1, "pp")} vs previous`, label: `${absolute > 0 ? "+" : absolute < 0 ? "-" : ""}${formatPercentage(absoluteValue, 1, "%")}`,
}; };
} }
if (typeof percentage === "number" && Number.isFinite(percentage)) { if (typeof percentage === "number" && Number.isFinite(percentage)) {
return { return {
direction, direction,
label: `${absolute > 0 ? "+" : absolute < 0 ? "-" : ""}${formatPercentage(Math.abs(percentage), 1)} vs previous`, label: `${absolute > 0 ? "+" : absolute < 0 ? "-" : ""}${formatPercentage(Math.abs(percentage), 1)}`,
}; };
} }
@@ -630,6 +644,7 @@ const FinancialOverview = () => {
const [metrics, setMetrics] = useState<Record<ChartSeriesKey, boolean>>({ const [metrics, setMetrics] = useState<Record<ChartSeriesKey, boolean>>({
income: true, income: true,
cogs: true, cogs: true,
cogsPercentage: true,
profit: true, profit: true,
margin: true, margin: true,
}); });
@@ -836,14 +851,7 @@ const FinancialOverview = () => {
const cards = useMemo( const cards = useMemo(
() => { () => {
if (!data?.totals) { if (!data?.totals) {
return [] as Array<{ return [] as FinancialStatCardConfig[];
key: string;
title: string;
value: string;
description?: string;
trend: TrendSummary | null;
accentClass: string;
}>;
} }
const totals = data.totals; const totals = data.totals;
@@ -880,43 +888,57 @@ const FinancialOverview = () => {
: computeMarginFrom(previousProfitValue ?? 0, previousIncome) : computeMarginFrom(previousProfitValue ?? 0, previousIncome)
: null; : null;
const incomeDescription = previousIncome != null ? `Previous: ${safeCurrency(previousIncome, 0)}` : undefined;
const cogsDescription = previousCogs != null ? `Previous: ${safeCurrency(previousCogs, 0)}` : undefined;
const profitDescription = previousProfitValue != null ? `Previous: ${safeCurrency(previousProfitValue, 0)}` : undefined;
const marginDescription = previousMarginValue != null ? `Previous: ${safePercentage(previousMarginValue, 1)}` : undefined;
return [ return [
{ {
key: "income", key: "income",
title: "Total Income", title: "Total Income",
value: safeCurrency(totalIncome, 0), value: safeCurrency(totalIncome, 0),
description: previousIncome != null ? `Previous: ${safeCurrency(previousIncome, 0)}` : undefined, description: incomeDescription,
trend: buildTrendLabel(comparison?.income ?? buildComparisonFromValues(totalIncome, previousIncome ?? null)), trend: buildTrendLabel(comparison?.income ?? buildComparisonFromValues(totalIncome, previousIncome ?? null)),
accentClass: "text-indigo-600 dark:text-indigo-400", accentClass: "text-blue-500 dark:text-blue-400",
tooltip:
"Gross sales minus refunds and discounts, plus shipping fees collected (shipping, small-order, and rush fees). Taxes are excluded.",
showDescription: incomeDescription != null,
}, },
{ {
key: "cogs", key: "cogs",
title: "COGS", title: "COGS",
value: safeCurrency(cogsValue, 0), value: safeCurrency(cogsValue, 0),
description: previousCogs != null ? `Previous: ${safeCurrency(previousCogs, 0)}` : undefined, description: cogsDescription,
trend: buildTrendLabel(comparison?.cogs ?? buildComparisonFromValues(cogsValue, previousCogs ?? null), { trend: buildTrendLabel(comparison?.cogs ?? buildComparisonFromValues(cogsValue, previousCogs ?? null), {
invertDirection: true, invertDirection: true,
}), }),
accentClass: "text-amber-600 dark:text-amber-400", accentClass: "text-orange-500 dark:text-orange-400",
tooltip: "Sum of reported product cost of goods sold (cogs_amount) for completed sales actions in the period.",
showDescription: cogsDescription != null,
}, },
{ {
key: "profit", key: "profit",
title: "Gross Profit", title: "Gross Profit",
value: safeCurrency(profitValue, 0), value: safeCurrency(profitValue, 0),
description: previousProfitValue != null ? `Previous: ${safeCurrency(previousProfitValue, 0)}` : undefined, description: profitDescription,
trend: buildTrendLabel(comparison?.profit ?? buildComparisonFromValues(profitValue, previousProfitValue ?? null)), trend: buildTrendLabel(comparison?.profit ?? buildComparisonFromValues(profitValue, previousProfitValue ?? null)),
accentClass: "text-emerald-600 dark:text-emerald-400", accentClass: "text-emerald-500 dark:text-emerald-400",
tooltip: "Total Income minus COGS.",
showDescription: profitDescription != null,
}, },
{ {
key: "margin", key: "margin",
title: "Profit Margin", title: "Profit Margin",
value: safePercentage(marginValue, 1), value: safePercentage(marginValue, 1),
description: previousMarginValue != null ? `Previous: ${safePercentage(previousMarginValue, 1)}` : undefined, description: marginDescription,
trend: buildTrendLabel( trend: buildTrendLabel(
comparison?.margin ?? buildComparisonFromValues(marginValue, previousMarginValue ?? null), comparison?.margin ?? buildComparisonFromValues(marginValue, previousMarginValue ?? null),
{ isPercentage: true } { isPercentage: true }
), ),
accentClass: "text-sky-600 dark:text-sky-400", accentClass: "text-purple-500 dark:text-purple-400",
tooltip: "Gross Profit divided by Total Income, expressed as a percentage.",
showDescription: marginDescription != null,
}, },
]; ];
}, },
@@ -938,6 +960,7 @@ const FinancialOverview = () => {
timestamp: point.timestamp, timestamp: point.timestamp,
income: point.isFuture ? null : point.income, income: point.isFuture ? null : point.income,
cogs: point.isFuture ? null : point.cogs, cogs: point.isFuture ? null : point.cogs,
cogsPercentage: point.isFuture ? null : point.cogsPercentage,
profit: point.isFuture ? null : point.profit, profit: point.isFuture ? null : point.profit,
margin: point.isFuture ? null : point.margin, margin: point.isFuture ? null : point.margin,
tooltipLabel: point.tooltipLabel, tooltipLabel: point.tooltipLabel,
@@ -966,20 +989,34 @@ const FinancialOverview = () => {
const partialLabel = effectiveRangeEnd.toLocaleDateString("en-US", { const partialLabel = effectiveRangeEnd.toLocaleDateString("en-US", {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric",
}); });
return `${label} (through ${partialLabel})`; return `${label} (through ${partialLabel})`;
}, [isLast30DaysMode, customPeriod, selectedRange, effectiveRangeEnd]); }, [isLast30DaysMode, customPeriod, selectedRange, effectiveRangeEnd]);
const marginDomain = useMemo<[number, number]>(() => { const percentageDomain = useMemo<[number, number]>(() => {
if (!metrics.margin || !chartData.length) { if ((!metrics.margin && !metrics.cogsPercentage) || !chartData.length) {
return [0, 100]; return [0, 100];
} }
const values = chartData const values: number[] = [];
.map((point) => point.margin)
.filter((value): value is number => typeof value === "number" && Number.isFinite(value)); if (metrics.margin) {
values.push(
...chartData
.map((point) => point.margin)
.filter((value): value is number => typeof value === "number" && Number.isFinite(value))
);
}
if (metrics.cogsPercentage) {
values.push(
...chartData
.map((point) => point.cogsPercentage)
.filter((value): value is number => typeof value === "number" && Number.isFinite(value))
);
}
if (!values.length) { if (!values.length) {
return [0, 100]; return [0, 100];
@@ -995,9 +1032,10 @@ const FinancialOverview = () => {
const padding = (max - min) * 0.1; const padding = (max - min) * 0.1;
return [min - padding, max + padding]; return [min - padding, max + padding];
}, [chartData, metrics.margin]); }, [chartData, metrics.margin, metrics.cogsPercentage]);
const hasActiveMetrics = useMemo(() => Object.values(metrics).some(Boolean), [metrics]); const hasActiveMetrics = useMemo(() => Object.values(metrics).some(Boolean), [metrics]);
const showPercentageAxis = metrics.margin || metrics.cogsPercentage;
const detailRows = useMemo( const detailRows = useMemo(
() => () =>
@@ -1007,6 +1045,7 @@ const FinancialOverview = () => {
timestamp: point.timestamp, timestamp: point.timestamp,
income: point.income, income: point.income,
cogs: point.cogs, cogs: point.cogs,
cogsPercentage: point.cogsPercentage,
profit: point.profit, profit: point.profit,
margin: point.margin, margin: point.margin,
isFuture: point.isFuture, isFuture: point.isFuture,
@@ -1124,16 +1163,7 @@ const FinancialOverview = () => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!error && ( {!error && (
<> <>
<PeriodSelectionPopover
open={isPeriodPopoverOpen}
onOpenChange={setIsPeriodPopoverOpen}
selectedLabel={selectedRangeLabel}
referenceDate={currentDate}
isLast30DaysActive={isLast30DaysMode}
onQuickSelect={handleQuickPeriod}
onApplyResult={handleNaturalLanguageResult}
/>
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="h-9" disabled={loading || !detailRows.length}> <Button variant="outline" className="h-9" disabled={loading || !detailRows.length}>
@@ -1191,6 +1221,11 @@ const FinancialOverview = () => {
COGS COGS
</TableHead> </TableHead>
)} )}
{metrics.cogsPercentage && (
<TableHead className="text-center whitespace-nowrap px-6">
COGS % of Income
</TableHead>
)}
{metrics.profit && ( {metrics.profit && (
<TableHead className="text-center whitespace-nowrap px-6"> <TableHead className="text-center whitespace-nowrap px-6">
Gross Profit Gross Profit
@@ -1219,6 +1254,11 @@ const FinancialOverview = () => {
{row.isFuture ? "—" : formatCurrency(row.cogs, 0)} {row.isFuture ? "—" : formatCurrency(row.cogs, 0)}
</TableCell> </TableCell>
)} )}
{metrics.cogsPercentage && (
<TableCell className="text-center whitespace-nowrap px-6">
{row.isFuture ? "—" : formatPercentage(row.cogsPercentage, 1)}
</TableCell>
)}
{metrics.profit && ( {metrics.profit && (
<TableCell className="text-center whitespace-nowrap px-6"> <TableCell className="text-center whitespace-nowrap px-6">
{row.isFuture ? "—" : formatCurrency(row.profit, 0)} {row.isFuture ? "—" : formatCurrency(row.profit, 0)}
@@ -1237,6 +1277,17 @@ const FinancialOverview = () => {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<PeriodSelectionPopover
open={isPeriodPopoverOpen}
onOpenChange={setIsPeriodPopoverOpen}
selectedLabel={selectedRangeLabel}
referenceDate={currentDate}
isLast30DaysActive={isLast30DaysMode}
onQuickSelect={handleQuickPeriod}
onApplyResult={handleNaturalLanguageResult}
/>
</> </>
)} )}
</div> </div>
@@ -1276,8 +1327,11 @@ const FinancialOverview = () => {
className="sm:hidden w-20 my-2" className="sm:hidden w-20 my-2"
/> />
<div className="flex items-center gap-2">
<div className="text-sm text-muted-foreground">Group By:</div>
<Select value={groupBy} onValueChange={handleGroupByChange}> <Select value={groupBy} onValueChange={handleGroupByChange}>
<SelectTrigger className="w-[140px]"> <SelectTrigger className="w-[110px]">
<SelectValue placeholder="Group By" /> <SelectValue placeholder="Group By" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -1286,8 +1340,9 @@ const FinancialOverview = () => {
{option.label} {option.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
</div> </div>
)} )}
</div> </div>
@@ -1327,19 +1382,15 @@ const FinancialOverview = () => {
<EmptyChartState message="Select at least one metric to visualize." /> <EmptyChartState message="Select at least one metric to visualize." />
) : ( ) : (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 5, right: -30, left: -5, bottom: 5 }}> <ComposedChart data={chartData} margin={{ top: 5, right: -25, left: 15, bottom: 5 }}>
<defs> <defs>
<linearGradient id="financialIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={chartColors.income} stopOpacity={0.3} />
<stop offset="95%" stopColor={chartColors.income} stopOpacity={0.05} />
</linearGradient>
<linearGradient id="financialCogs" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="financialCogs" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={chartColors.cogs} stopOpacity={0.25} /> <stop offset="5%" stopColor={chartColors.cogs} stopOpacity={0.8} />
<stop offset="95%" stopColor={chartColors.cogs} stopOpacity={0.05} /> <stop offset="95%" stopColor={chartColors.cogs} stopOpacity={0.6} />
</linearGradient> </linearGradient>
<linearGradient id="financialProfit" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="financialProfit" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={chartColors.profit} stopOpacity={0.35} /> <stop offset="5%" stopColor={chartColors.profit} stopOpacity={0.8} />
<stop offset="95%" stopColor={chartColors.profit} stopOpacity={0.05} /> <stop offset="95%" stopColor={chartColors.profit} stopOpacity={0.6} />
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid <CartesianGrid
@@ -1357,38 +1408,29 @@ const FinancialOverview = () => {
className="text-xs text-muted-foreground" className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }} tick={{ fill: "currentColor" }}
/> />
{metrics.margin && ( {showPercentageAxis && (
<YAxis <YAxis
yAxisId="right" yAxisId="right"
orientation="right" orientation="right"
tickFormatter={(value: number) => formatPercentage(value, 0)} tickFormatter={(value: number) => formatPercentage(value, 0)}
domain={marginDomain} domain={percentageDomain}
className="text-xs text-muted-foreground" className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }} tick={{ fill: "currentColor" }}
/> />
)} )}
<Tooltip content={<FinancialTooltip />} /> <Tooltip content={<FinancialTooltip />} />
<Legend formatter={(value: string) => SERIES_LABELS[value as ChartSeriesKey] ?? value} /> <Legend formatter={(value: string) => SERIES_LABELS[value as ChartSeriesKey] ?? value} />
{metrics.income ? ( {/* Stacked areas showing revenue breakdown */}
<Area
yAxisId="left"
type="monotone"
dataKey="income"
name={SERIES_LABELS.income}
stroke={chartColors.income}
fill="url(#financialIncome)"
strokeWidth={2}
/>
) : null}
{metrics.cogs ? ( {metrics.cogs ? (
<Area <Area
yAxisId="left" yAxisId="left"
type="monotone" type="monotone"
dataKey="cogs" dataKey="cogs"
stackId="revenue"
name={SERIES_LABELS.cogs} name={SERIES_LABELS.cogs}
stroke={chartColors.cogs} stroke={chartColors.cogs}
fill="url(#financialCogs)" fill="url(#financialCogs)"
strokeWidth={2} strokeWidth={1}
/> />
) : null} ) : null}
{metrics.profit ? ( {metrics.profit ? (
@@ -1396,10 +1438,39 @@ const FinancialOverview = () => {
yAxisId="left" yAxisId="left"
type="monotone" type="monotone"
dataKey="profit" dataKey="profit"
stackId="revenue"
name={SERIES_LABELS.profit} name={SERIES_LABELS.profit}
stroke={chartColors.profit} stroke={chartColors.profit}
fill="url(#financialProfit)" fill="url(#financialProfit)"
strokeWidth={1}
/>
) : null}
{/* Show total income as a line for reference if selected */}
{metrics.income ? (
<Line
yAxisId="left"
type="monotone"
dataKey="income"
name={SERIES_LABELS.income}
stroke={chartColors.income}
strokeWidth={2} strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
) : null}
{metrics.cogsPercentage ? (
<Line
yAxisId="right"
type="monotone"
dataKey="cogsPercentage"
name={SERIES_LABELS.cogsPercentage}
stroke={chartColors.cogsPercentage}
strokeWidth={2}
strokeDasharray="6 3"
dot={false}
activeDot={{ r: 4 }}
connectNulls
/> />
) : null} ) : null}
{metrics.margin ? ( {metrics.margin ? (
@@ -1426,24 +1497,41 @@ const FinancialOverview = () => {
); );
}; };
type FinancialStatCardConfig = {
key: string;
title: string;
value: string;
description?: string;
trend: TrendSummary | null;
accentClass: string;
tooltip?: string;
isLoading?: boolean;
showDescription?: boolean;
};
function FinancialStatGrid({ function FinancialStatGrid({
cards, cards,
}: { }: {
cards: Array<{ cards: FinancialStatCardConfig[];
key: string;
title: string;
value: string;
description?: string;
trend: TrendSummary | null;
accentClass: string;
}>;
}) { }) {
return ( return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full"> <TooltipProvider delayDuration={150}>
{cards.map((card) => ( <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
<FinancialStatCard key={card.key} title={card.title} value={card.value} description={card.description} trend={card.trend} accentClass={card.accentClass} /> {cards.map((card) => (
))} <FinancialStatCard
</div> key={card.key}
title={card.title}
value={card.value}
description={card.description}
trend={card.trend}
accentClass={card.accentClass}
tooltip={card.tooltip}
isLoading={card.isLoading}
showDescription={card.showDescription}
/>
))}
</div>
</TooltipProvider>
); );
} }
@@ -1453,18 +1541,69 @@ function FinancialStatCard({
description, description,
trend, trend,
accentClass, accentClass,
tooltip,
isLoading,
showDescription,
}: { }: {
title: string; title: string;
value: string; value: string;
description?: string; description?: string;
trend: TrendSummary | null; trend: TrendSummary | null;
accentClass: string; accentClass: string;
tooltip?: string;
isLoading?: boolean;
showDescription?: boolean;
}) { }) {
const shouldShowDescription = isLoading ? showDescription !== false : Boolean(description);
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<span className="text-sm text-muted-foreground">{title}</span> <div className="flex items-center gap-1 text-sm text-muted-foreground">
{trend?.label && ( {isLoading ? (
<>
<span className="relative block w-24">
<span className="invisible block">Placeholder</span>
<Skeleton className="absolute inset-0 w-full bg-muted rounded-sm" />
</span>
<span className="relative inline-flex rounded-full p-0.5">
<span className="invisible inline-flex">
<Info className="h-4 w-4" />
</span>
<Skeleton className="absolute inset-0 rounded-full" />
</span>
</>
) : (
<>
<span>{title}</span>
{tooltip ? (
<UITooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`What makes up ${title}`}
className="text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-full p-0.5"
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-[260px] text-xs leading-relaxed">
{tooltip}
</TooltipContent>
</UITooltip>
) : null}
</>
)}
</div>
{isLoading ? (
<span className="text-sm text-muted-foreground flex items-center gap-1">
<Skeleton className="h-4 w-4 bg-muted rounded-full" />
<span className="relative block w-16">
<span className="invisible block">Placeholder</span>
<Skeleton className="absolute inset-0 w-full bg-muted rounded-sm" />
</span>
</span>
) : trend?.label ? (
<span <span
className={`text-sm flex items-center gap-1 ${ className={`text-sm flex items-center gap-1 ${
trend.direction === "up" trend.direction === "up"
@@ -1475,23 +1614,39 @@ function FinancialStatCard({
}`} }`}
> >
{trend.direction === "up" ? ( {trend.direction === "up" ? (
<ArrowUpRight className="w-4 h-4" /> <ArrowUp className="w-4 h-4" />
) : trend.direction === "down" ? ( ) : trend.direction === "down" ? (
<ArrowDownRight className="w-4 h-4" /> <ArrowDown className="w-4 h-4" />
) : ( ) : (
<Minus className="w-4 h-4" /> <Minus className="w-4 h-4" />
)} )}
{trend.label} {trend.label}
</span> </span>
)} ) : null}
</CardHeader> </CardHeader>
<CardContent className="p-4 pt-0"> <CardContent className="p-4 pt-0">
<div className={`text-2xl font-bold mb-1 ${accentClass}`}> <div className={`text-2xl font-bold mb-1 ${accentClass}`}>
{value} {isLoading ? (
<span className="relative block">
<span className="invisible">0</span>
<Skeleton className="absolute inset-0 bg-muted rounded-sm" />
</span>
) : (
value
)}
</div> </div>
{description && ( {isLoading ? (
shouldShowDescription ? (
<div className="text-sm text-muted-foreground">
<span className="relative block w-32">
<span className="invisible block">Placeholder text</span>
<Skeleton className="absolute inset-0 w-full bg-muted rounded-sm" />
</span>
</div>
) : null
) : description ? (
<div className="text-sm text-muted-foreground">{description}</div> <div className="text-sm text-muted-foreground">{description}</div>
)} ) : null}
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -1499,18 +1654,18 @@ function FinancialStatCard({
function SkeletonStats() { function SkeletonStats() {
return ( return (
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-2 md:gap-3"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
{Array.from({ length: 4 }).map((_, index) => ( {Array.from({ length: 4 }).map((_, index) => (
<Card key={index} className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <FinancialStatCard
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2"> key={index}
<Skeleton className="h-4 w-24 bg-muted rounded-sm" /> title=""
<Skeleton className="h-4 w-8 bg-muted rounded-sm" /> value=""
</CardHeader> description=""
<CardContent className="p-4 pt-0"> trend={null}
<Skeleton className="h-7 w-32 bg-muted rounded-sm mb-1" /> accentClass=""
<Skeleton className="h-4 w-24 bg-muted rounded-sm" /> isLoading
</CardContent> showDescription
</Card> />
))} ))}
</div> </div>
); );
@@ -1518,36 +1673,46 @@ function SkeletonStats() {
function SkeletonChart() { function SkeletonChart() {
return ( return (
<div className="h-[400px] w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-6"> <div className="h-[400px] mt-4 bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-0 relative">
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<div className="flex-1 relative"> <div className="flex-1 relative">
{/* Grid lines */} {/* Grid lines */}
{[...Array(6)].map((_, i) => ( {[...Array(6)].map((_, i) => (
<div <div
key={i} key={i}
className="absolute w-full h-px bg-muted" className="absolute w-full h-px bg-muted/30"
style={{ top: `${(i + 1) * 16}%` }} style={{ top: `${(i + 1) * 16}%` }}
/> />
))} ))}
{/* Y-axis labels */} {/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-16 flex flex-col justify-between py-4"> <div className="absolute left-0 top-0 bottom-0 w-16 flex flex-col justify-between py-4">
{[...Array(6)].map((_, i) => ( {[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-4 w-12 bg-muted rounded-sm" /> <Skeleton key={i} className="h-3 w-12 bg-muted rounded-sm" />
))} ))}
</div> </div>
{/* X-axis labels */} {/* X-axis labels */}
<div className="absolute left-16 right-0 bottom-0 flex justify-between px-4"> <div className="absolute left-16 right-0 bottom-0 flex justify-between px-4">
{[...Array(7)].map((_, i) => ( {[...Array(7)].map((_, i) => (
<Skeleton key={i} className="h-4 w-12 bg-muted rounded-sm" /> <Skeleton key={i} className="h-3 w-12 bg-muted rounded-sm" />
))} ))}
</div> </div>
{/* Chart line */} {/* Chart area */}
<div className="absolute inset-0 mt-8 mb-8 ml-20 mr-4"> <div className="absolute inset-0 mt-8 mb-8 ml-20 mr-4">
<div <div
className="absolute inset-0 bg-muted/50" className="absolute inset-0 bg-muted/20"
style={{ style={{
clipPath: clipPath:
"polygon(0 50%, 20% 20%, 40% 40%, 60% 30%, 80% 60%, 100% 40%, 100% 100%, 0 100%)", "polygon(0 60%, 15% 45%, 30% 55%, 45% 35%, 60% 50%, 75% 30%, 90% 45%, 100% 40%, 100% 100%, 0 100%)",
}}
/>
{/* Simulated line chart */}
<div
className="absolute inset-0 bg-blue-500/30"
style={{
clipPath:
"polygon(0 60%, 15% 45%, 30% 55%, 45% 35%, 60% 50%, 75% 30%, 90% 45%, 100% 40%, 100% 40%, 90% 45%, 75% 30%, 60% 50%, 45% 35%, 30% 55%, 15% 45%, 0 60%)",
height: "2px",
top: "50%",
}} }}
/> />
</div> </div>
@@ -1575,19 +1740,41 @@ const FinancialTooltip = ({ active, payload, label }: TooltipProps<number, strin
const resolvedLabel = basePoint?.tooltipLabel ?? label; const resolvedLabel = basePoint?.tooltipLabel ?? label;
const isFuturePoint = basePoint?.isFuture ?? false; const isFuturePoint = basePoint?.isFuture ?? false;
// Calculate total income for percentage calculations
const income = basePoint?.income ?? 0;
// Define the desired order: income, cogs, cogs %, profit, margin
const desiredOrder: ChartSeriesKey[] = ["income", "cogs", "cogsPercentage", "profit", "margin"];
// Create a map of payload entries by dataKey for easy lookup
const payloadMap = new Map(payload.map(entry => [entry.dataKey as ChartSeriesKey, entry]));
// Sort payload according to desired order
const orderedPayload = desiredOrder
.map(key => payloadMap.get(key))
.filter((entry): entry is typeof payload[0] => entry !== undefined);
return ( return (
<div className="rounded-md border border-border/60 bg-white dark:bg-gray-900/80 px-3 py-2 shadow-lg"> <div className="rounded-md border border-border/60 bg-white dark:bg-gray-900/80 px-3 py-2 shadow-lg">
<p className="text-xs font-semibold text-gray-900 dark:text-gray-100">{resolvedLabel}</p> <p className="text-xs font-semibold text-gray-900 dark:text-gray-100">{resolvedLabel}</p>
<div className="mt-1 space-y-1 text-xs"> <div className="mt-1 space-y-1 text-xs">
{payload.map((entry, index) => { {orderedPayload.map((entry, index) => {
const key = (entry.dataKey ?? "") as ChartSeriesKey; const key = (entry.dataKey ?? "") as ChartSeriesKey;
const rawValue = entry.value; const rawValue = entry.value;
let formattedValue: string; let formattedValue: string;
let percentageOfRevenue = "";
if (isFuturePoint || rawValue == null) { if (isFuturePoint || rawValue == null) {
formattedValue = "—"; formattedValue = "—";
} else if (typeof rawValue === "number") { } else if (typeof rawValue === "number") {
formattedValue = key === "margin" ? formatPercentage(rawValue, 1) : formatCurrency(rawValue, 0); const isPercentageSeries = key === "margin" || key === "cogsPercentage";
formattedValue = isPercentageSeries ? formatPercentage(rawValue, 1) : formatCurrency(rawValue, 0);
// Add percentage of revenue for COGS only
if (key === "cogs" && income > 0 && !isFuturePoint) {
const percentage = (rawValue / income) * 100;
percentageOfRevenue = ` (${formatPercentage(percentage, 1)})`;
}
} else if (typeof rawValue === "string") { } else if (typeof rawValue === "string") {
formattedValue = rawValue; formattedValue = rawValue;
} else { } else {
@@ -1596,10 +1783,15 @@ const FinancialTooltip = ({ active, payload, label }: TooltipProps<number, strin
return ( return (
<div key={`${key}-${index}`} className="flex items-center justify-between gap-4"> <div key={`${key}-${index}`} className="flex items-center justify-between gap-4">
<span className="flex items-center gap-1" style={{ color: entry.stroke || entry.color || "inherit" }}> <span
className="flex items-center gap-1"
style={{ color: entry.stroke || entry.color || "inherit" }}
>
{SERIES_LABELS[key] ?? entry.name ?? key} {SERIES_LABELS[key] ?? entry.name ?? key}
</span> </span>
<span className="font-semibold text-gray-900 dark:text-gray-100">{formattedValue}</span> <span className="font-semibold text-gray-900 dark:text-gray-100">
{formattedValue}{percentageOfRevenue}
</span>
</div> </div>
); );
})} })}

View File

@@ -7,7 +7,7 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { Calendar } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { import {
generateNaturalLanguagePreview, generateNaturalLanguagePreview,
parseNaturalLanguagePeriod, parseNaturalLanguagePeriod,
@@ -22,29 +22,6 @@ export type QuickPreset =
| "lastQuarter" | "lastQuarter"
| "thisYear"; | "thisYear";
const SUGGESTIONS = [
"last 30 days",
"this month",
"last month",
"this quarter",
"last quarter",
"this year",
"last year",
"last 3 months",
"last 6 months",
"last 2 quarters",
"Q1 2024",
"q1-q3 24",
"q1 24 - q2 25",
"January 2024",
"jan-24",
"jan-may 24",
"2023",
"2021-2023",
"21-23",
"January to March 2024",
"jan 2023 - may 2024",
];
interface PeriodSelectionPopoverProps { interface PeriodSelectionPopoverProps {
open: boolean; open: boolean;
@@ -66,17 +43,7 @@ const PeriodSelectionPopover = ({
onApplyResult, onApplyResult,
}: PeriodSelectionPopoverProps) => { }: PeriodSelectionPopoverProps) => {
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [showSuggestions, setShowSuggestions] = useState(false);
const filteredSuggestions = useMemo(() => {
if (!inputValue) {
return SUGGESTIONS;
}
return SUGGESTIONS.filter((suggestion) =>
suggestion.toLowerCase().includes(inputValue.toLowerCase()) &&
suggestion.toLowerCase() !== inputValue.toLowerCase()
);
}, [inputValue]);
const preview = useMemo(() => { const preview = useMemo(() => {
if (!inputValue) { if (!inputValue) {
@@ -95,7 +62,6 @@ const PeriodSelectionPopover = ({
const resetInput = () => { const resetInput = () => {
setInputValue(""); setInputValue("");
setShowSuggestions(false);
}; };
const applyResult = (value: string) => { const applyResult = (value: string) => {
@@ -110,7 +76,6 @@ const PeriodSelectionPopover = ({
const handleInputChange = (value: string) => { const handleInputChange = (value: string) => {
setInputValue(value); setInputValue(value);
setShowSuggestions(value.length > 0);
}; };
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => { const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
@@ -123,10 +88,6 @@ const PeriodSelectionPopover = ({
} }
}; };
const handleSuggestionClick = (suggestion: string) => {
setInputValue(suggestion);
applyResult(suggestion);
};
const handleQuickSelect = (preset: QuickPreset) => { const handleQuickSelect = (preset: QuickPreset) => {
onQuickSelect(preset); onQuickSelect(preset);
@@ -138,8 +99,8 @@ const PeriodSelectionPopover = ({
<Popover open={open} onOpenChange={onOpenChange}> <Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" className="h-9"> <Button variant="outline" className="h-9">
<Calendar className="w-4 h-4 mr-2" />
{selectedLabel} {selectedLabel}
<ChevronDown className="w-4 h-4 text-muted-foreground" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-96 p-4" align="end"> <PopoverContent className="w-96 p-4" align="end">
@@ -200,11 +161,10 @@ const PeriodSelectionPopover = ({
<Separator /> <Separator />
<div className="space-y-3"> <div className="space-y-3">
<div className="text-xs text-muted-foreground">Or type a custom period</div> <div className="text-xs text-muted-foreground">Or enter a custom period:</div>
<div className="relative"> <div className="relative">
<Input <Input
placeholder="e.g., jan-may 24, 2021-2023, Q1-Q3 2024"
value={inputValue} value={inputValue}
onChange={(event) => handleInputChange(event.target.value)} onChange={(event) => handleInputChange(event.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -212,52 +172,22 @@ const PeriodSelectionPopover = ({
/> />
{inputValue && ( {inputValue && (
<div className="mt-2"> <div className="mt-2 ml-3">
{preview.label ? ( {preview.label ? (
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Recognized as:</span>
<span className="font-medium text-green-600 dark:text-green-400"> <span className="font-medium text-green-600 dark:text-green-400">
{preview.label} {preview.label}
</span> </span>
</div> </div>
) : ( ) : (
<div className="text-xs text-amber-600 dark:text-amber-400"> <div className="text-xs text-amber-600 dark:text-amber-400">
Not recognized - try a different format Not recognized
</div> </div>
)} )}
</div> </div>
)} )}
{showSuggestions && filteredSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-white dark:bg-gray-800 border rounded-md shadow-lg max-h-32 overflow-y-auto">
{filteredSuggestions.slice(0, 6).map((suggestion) => (
<button
key={suggestion}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors"
onClick={() => handleSuggestionClick(suggestion)}
>
{suggestion}
</button>
))}
</div>
)}
{inputValue === "" && (
<div className="text-xs text-muted-foreground mt-2">
<div className="mb-1">Examples:</div>
<div className="flex flex-wrap gap-1">
{SUGGESTIONS.slice(0, 6).map((suggestion) => (
<button
key={suggestion}
className="px-2 py-0.5 bg-muted hover:bg-muted/80 rounded text-xs transition-colors"
onClick={() => handleSuggestionClick(suggestion)}
>
{suggestion}
</button>
))}
</div>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -11,6 +11,7 @@ import InputCell from './cells/InputCell'
import SelectCell from './cells/SelectCell' import SelectCell from './cells/SelectCell'
import MultiSelectCell from './cells/MultiSelectCell' import MultiSelectCell from './cells/MultiSelectCell'
import { TableCell } from '@/components/ui/table' import { TableCell } from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
// Context for copy down selection mode // Context for copy down selection mode
export const CopyDownContext = React.createContext<{ export const CopyDownContext = React.createContext<{
@@ -79,8 +80,7 @@ const BaseCellContent = React.memo(({
className = '', className = '',
fieldKey = '', fieldKey = '',
onStartEdit, onStartEdit,
onEndEdit, onEndEdit
isValidating
}: { }: {
field: Field<string>; field: Field<string>;
value: any; value: any;
@@ -91,7 +91,6 @@ const BaseCellContent = React.memo(({
fieldKey?: string; fieldKey?: string;
onStartEdit?: () => void; onStartEdit?: () => void;
onEndEdit?: () => void; onEndEdit?: () => void;
isValidating?: boolean;
}) => { }) => {
// Get field type information // Get field type information
const fieldType = fieldKey === 'line' || fieldKey === 'subline' const fieldType = fieldKey === 'line' || fieldKey === 'subline'
@@ -124,7 +123,6 @@ const BaseCellContent = React.memo(({
hasErrors={hasErrors} hasErrors={hasErrors}
className={className} className={className}
disabled={field.disabled} disabled={field.disabled}
isValidating={isValidating}
/> />
); );
} }
@@ -141,7 +139,6 @@ const BaseCellContent = React.memo(({
hasErrors={hasErrors} hasErrors={hasErrors}
className={className} className={className}
disabled={field.disabled} disabled={field.disabled}
isValidating={isValidating}
/> />
); );
} }
@@ -158,7 +155,6 @@ const BaseCellContent = React.memo(({
hasErrors={hasErrors} hasErrors={hasErrors}
className={className} className={className}
disabled={field.disabled} disabled={field.disabled}
isValidating={isValidating}
/> />
); );
} }
@@ -174,7 +170,6 @@ const BaseCellContent = React.memo(({
isMultiline={isMultiline} isMultiline={isMultiline}
isPrice={isPrice} isPrice={isPrice}
disabled={field.disabled} disabled={field.disabled}
isValidating={isValidating}
/> />
); );
}, (prev, next) => { }, (prev, next) => {
@@ -471,33 +466,35 @@ const ValidationCell = React.memo(({
</TooltipProvider> </TooltipProvider>
</div> </div>
)} )}
<div {isLoading ? (
className={`relative truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`} <div className={`flex items-center justify-center gap-2 border ${hasError || isRequiredButEmpty ? 'border-red-500' : 'border-input'} rounded-md px-2 py-2`}>
style={{ <Skeleton className="w-full h-4" />
backgroundColor: isSourceCell ? '#dbeafe' : </div>
isSelectedTarget ? '#bfdbfe' : ) : (
isInTargetRow && isTargetRowHovered ? '#dbeafe' : <div
undefined, className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}
borderRadius: (isSourceCell || isSelectedTarget || isInTargetRow) ? '0.375rem' : undefined, style={{
boxShadow: isSourceCell ? '0 0 0 2px #3b82f6' : undefined backgroundColor: isSourceCell ? '#dbeafe' :
}} isSelectedTarget ? '#bfdbfe' :
> isInTargetRow && isTargetRowHovered ? '#dbeafe' :
<BaseCellContent undefined,
field={field} borderRadius: (isSourceCell || isSelectedTarget || isInTargetRow) ? '0.375rem' : undefined,
value={displayValue} boxShadow: isSourceCell ? '0 0 0 2px #3b82f6' : undefined
onChange={onChange} }}
hasErrors={hasError || isRequiredButEmpty} >
options={options} <BaseCellContent
className={cellClassName} field={field}
fieldKey={fieldKey} value={displayValue}
onStartEdit={handleStartEdit} onChange={onChange}
onEndEdit={handleEndEdit} hasErrors={hasError || isRequiredButEmpty}
isValidating={isLoading} options={options}
/> className={cellClassName}
{isLoading && ( fieldKey={fieldKey}
<span className="pointer-events-none absolute right-2 top-2 h-2 w-2 rounded-full bg-muted-foreground animate-pulse" /> onStartEdit={handleStartEdit}
)} onEndEdit={handleEndEdit}
</div> />
</div>
)}
</div> </div>
</TableCell> </TableCell>
); );

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useCallback, useState, useEffect, useRef } from 'react' import React, { useMemo, useCallback, useState } from 'react'
import { import {
useReactTable, useReactTable,
getCoreRowModel, getCoreRowModel,
@@ -6,7 +6,6 @@ import {
RowSelectionState, RowSelectionState,
ColumnDef ColumnDef
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Fields, Field } from '../../../types' import { Fields, Field } from '../../../types'
import { RowData, Template } from '../hooks/validationTypes' import { RowData, Template } from '../hooks/validationTypes'
import ValidationCell, { CopyDownContext } from './ValidationCell' import ValidationCell, { CopyDownContext } from './ValidationCell'
@@ -194,14 +193,6 @@ const ValidationTable = <T extends string>({
upcValidationResults upcValidationResults
}: ValidationTableProps<T>) => { }: ValidationTableProps<T>) => {
const { translations } = useRsi<T>(); const { translations } = useRsi<T>();
const tableRootRef = useRef<HTMLDivElement | null>(null);
const getScrollElement = useCallback(() => tableRootRef.current?.parentElement ?? null, []);
const [, forceRerender] = useState(0);
useEffect(() => {
if (!tableRootRef.current) return;
forceRerender((value) => value + 1);
}, []);
// Add state for copy down selection mode // Add state for copy down selection mode
const [isInCopyDownMode, setIsInCopyDownMode] = useState(false); const [isInCopyDownMode, setIsInCopyDownMode] = useState(false);
@@ -401,10 +392,7 @@ const ValidationTable = <T extends string>({
} else if (fieldKey === 'subline' && rowId && rowSublines[rowId]) { } else if (fieldKey === 'subline' && rowId && rowSublines[rowId]) {
options = rowSublines[rowId]; options = rowSublines[rowId];
} }
const validatingKey = `${row.index}-${fieldKey}`;
const isCellValidating = validatingCells.has(validatingKey);
// Get the current cell value first // Get the current cell value first
const currentValue = fieldKey === 'item_number' && row.original[field.key] const currentValue = fieldKey === 'item_number' && row.original[field.key]
? row.original[field.key] ? row.original[field.key]
@@ -483,7 +471,7 @@ const ValidationTable = <T extends string>({
value={currentValue} value={currentValue}
onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)} onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)}
errors={cellErrors} errors={cellErrors}
isValidating={isLoading || isCellValidating} isValidating={isLoading}
fieldKey={fieldKey} fieldKey={fieldKey}
options={options} options={options}
itemNumber={itemNumber} itemNumber={itemNumber}
@@ -517,47 +505,6 @@ const ValidationTable = <T extends string>({
getRowId: useCallback((_row: RowData<T>, index: number) => String(index), []), getRowId: useCallback((_row: RowData<T>, index: number) => String(index), []),
}); });
const rowModel = table.getRowModel();
const rows = rowModel.rows;
const visibleColumnCount = table.getVisibleFlatColumns().length;
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement,
estimateSize: () => 66,
overscan: 8,
measureElement:
typeof window !== 'undefined'
? (el: Element | null) => el?.getBoundingClientRect().height || 0
: undefined,
});
const scrollElement = getScrollElement();
const virtualRows = scrollElement
? rowVirtualizer.getVirtualItems()
: rows.map((_, index) => ({
index,
key: `row-fallback-${index}`,
start: 0,
end: 0,
size: 0,
}));
const paddingTop = scrollElement && virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom =
scrollElement && virtualRows.length > 0
? rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end
: 0;
const measureVirtualRow = useCallback(
(node: HTMLTableRowElement | null) => {
const scrollEl = getScrollElement();
if (!scrollEl || !node) return;
rowVirtualizer.measureElement(node);
},
[getScrollElement, rowVirtualizer]
);
// Calculate total table width for stable horizontal scrolling // Calculate total table width for stable horizontal scrolling
const totalWidth = useMemo(() => { const totalWidth = useMemo(() => {
return columns.reduce((total, col) => total + (col.size || 0), 0); return columns.reduce((total, col) => total + (col.size || 0), 0);
@@ -578,7 +525,7 @@ const ValidationTable = <T extends string>({
return ( return (
<CopyDownContext.Provider value={copyDownContextValue}> <CopyDownContext.Provider value={copyDownContextValue}>
<div ref={tableRootRef} className="min-w-max relative"> <div className="min-w-max relative">
{/* Add global styles for copy down mode */} {/* Add global styles for copy down mode */}
{isInCopyDownMode && ( {isInCopyDownMode && (
<style> <style>
@@ -683,17 +630,7 @@ const ValidationTable = <T extends string>({
transform: 'translateZ(0)' // Force GPU acceleration transform: 'translateZ(0)' // Force GPU acceleration
}}> }}>
<TableBody> <TableBody>
{paddingTop > 0 && ( {table.getRowModel().rows.map((row) => {
<TableRow key="virtual-padding-top">
<TableCell
colSpan={visibleColumnCount}
style={{ height: `${paddingTop}px`, padding: 0, border: 'none' }}
/>
</TableRow>
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
// Precompute validation error status for this row // Precompute validation error status for this row
const hasErrors = validationErrors.has(parseInt(row.id)) && const hasErrors = validationErrors.has(parseInt(row.id)) &&
Object.keys(validationErrors.get(parseInt(row.id)) || {}).length > 0; Object.keys(validationErrors.get(parseInt(row.id)) || {}).length > 0;
@@ -722,7 +659,6 @@ const ValidationTable = <T extends string>({
isCopyDownTarget ? "cursor-pointer copy-down-target-row" : "" isCopyDownTarget ? "cursor-pointer copy-down-target-row" : ""
)} )}
style={rowStyle} style={rowStyle}
ref={scrollElement ? measureVirtualRow : undefined}
onMouseEnter={() => handleRowMouseEnter(parseInt(row.id))} onMouseEnter={() => handleRowMouseEnter(parseInt(row.id))}
> >
{row.getVisibleCells().map((cell: any) => ( {row.getVisibleCells().map((cell: any) => (
@@ -733,14 +669,6 @@ const ValidationTable = <T extends string>({
</TableRow> </TableRow>
); );
})} })}
{paddingBottom > 0 && (
<TableRow key="virtual-padding-bottom">
<TableCell
colSpan={visibleColumnCount}
style={{ height: `${paddingBottom}px`, padding: 0, border: 'none' }}
/>
</TableRow>
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react' import React, { useState, useCallback, useMemo } from 'react'
import { Field } from '../../../../types' import { Field } from '../../../../types'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -15,10 +15,8 @@ interface InputCellProps<T extends string> {
isPrice?: boolean isPrice?: boolean
disabled?: boolean disabled?: boolean
className?: string className?: string
isValidating?: boolean
} }
// (removed unused formatPrice helper) // (removed unused formatPrice helper)
const InputCell = <T extends string>({ const InputCell = <T extends string>({
@@ -31,12 +29,11 @@ const InputCell = <T extends string>({
isMultiline = false, isMultiline = false,
isPrice = false, isPrice = false,
disabled = false, disabled = false,
className = '', isValidating: _isValidating = false className = ''
}: InputCellProps<T>) => { }: InputCellProps<T>) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(''); const [editValue, setEditValue] = useState('');
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [pendingDisplayValue, setPendingDisplayValue] = useState<string | null>(null);
// Remove optimistic updates and rely on parent state // Remove optimistic updates and rely on parent state
@@ -51,7 +48,6 @@ const InputCell = <T extends string>({
// Handle focus event // Handle focus event
const handleFocus = useCallback(() => { const handleFocus = useCallback(() => {
setIsEditing(true); setIsEditing(true);
setPendingDisplayValue(null);
if (value !== undefined && value !== null) { if (value !== undefined && value !== null) {
if (isPrice) { if (isPrice) {
@@ -71,8 +67,6 @@ const InputCell = <T extends string>({
// Handle blur event - save to parent only // Handle blur event - save to parent only
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
const finalValue = editValue.trim(); const finalValue = editValue.trim();
setPendingDisplayValue(finalValue);
// Save to parent - parent must update immediately for this to work // Save to parent - parent must update immediately for this to work
onChange(finalValue); onChange(finalValue);
@@ -87,29 +81,23 @@ const InputCell = <T extends string>({
const newValue = isPrice ? e.target.value.replace(/[$,]/g, '') : e.target.value; const newValue = isPrice ? e.target.value.replace(/[$,]/g, '') : e.target.value;
setEditValue(newValue); setEditValue(newValue);
}, [isPrice]); }, [isPrice]);
useEffect(() => {
if (pendingDisplayValue === null) return;
const currentValue = value ?? '';
if (String(currentValue) === pendingDisplayValue) {
setPendingDisplayValue(null);
}
}, [value, pendingDisplayValue]);
// Get the display value - prefer pending value when present for immediate feedback // Get the display value - use parent value directly
const displayValue = useMemo(() => { const displayValue = useMemo(() => {
const rawValue = pendingDisplayValue !== null ? pendingDisplayValue : value ?? ''; const currentValue = value ?? '';
if (isPrice && rawValue !== '' && rawValue !== undefined && rawValue !== null) { // Handle price formatting for display
if (typeof rawValue === 'number') { if (isPrice && currentValue !== '' && currentValue !== undefined && currentValue !== null) {
return rawValue.toFixed(2); if (typeof currentValue === 'number') {
} else if (typeof rawValue === 'string' && /^-?\d+(\.\d+)?$/.test(rawValue)) { return currentValue.toFixed(2);
return parseFloat(rawValue).toFixed(2); } else if (typeof currentValue === 'string' && /^-?\d+(\.\d+)?$/.test(currentValue)) {
return parseFloat(currentValue).toFixed(2);
} }
} }
return String(rawValue); // For non-price or invalid price values, return as-is
}, [isPrice, value, pendingDisplayValue]); return String(currentValue);
}, [isPrice, value]);
// Add outline even when not in focus // Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"; const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0";

View File

@@ -24,10 +24,8 @@ interface MultiSelectCellProps<T extends string> {
options?: readonly FieldOption[] options?: readonly FieldOption[]
disabled?: boolean disabled?: boolean
className?: string className?: string
isValidating?: boolean
} }
// Memoized option item to prevent unnecessary renders for large option lists // Memoized option item to prevent unnecessary renders for large option lists
const OptionItem = React.memo(({ const OptionItem = React.memo(({
option, option,
@@ -160,7 +158,7 @@ const MultiSelectCell = <T extends string>({
hasErrors, hasErrors,
options: providedOptions, options: providedOptions,
disabled = false, disabled = false,
className = '', isValidating: _isValidating = false className = ''
}: MultiSelectCellProps<T>) => { }: MultiSelectCellProps<T>) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")

View File

@@ -22,10 +22,8 @@ interface SelectCellProps<T extends string> {
options: readonly any[] options: readonly any[]
disabled?: boolean disabled?: boolean
className?: string className?: string
isValidating?: boolean
} }
// Lightweight version of the select cell with minimal dependencies // Lightweight version of the select cell with minimal dependencies
const SelectCell = <T extends string>({ const SelectCell = <T extends string>({
field, field,
@@ -36,7 +34,7 @@ const SelectCell = <T extends string>({
hasErrors, hasErrors,
options = [], options = [],
disabled = false, disabled = false,
className = '', isValidating = false className = ''
}: SelectCellProps<T>) => { }: SelectCellProps<T>) => {
// State for the open/closed state of the dropdown // State for the open/closed state of the dropdown
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -49,7 +47,6 @@ const SelectCell = <T extends string>({
// State to track if the value is being processed/validated // State to track if the value is being processed/validated
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const showProcessing = isProcessing || isValidating;
// Add state for hover // Add state for hover
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@@ -64,10 +61,8 @@ const SelectCell = <T extends string>({
useEffect(() => { useEffect(() => {
setInternalValue(value); setInternalValue(value);
// When the value prop changes, it means validation is complete // When the value prop changes, it means validation is complete
if (!isValidating) { setIsProcessing(false);
setIsProcessing(false); }, [value]);
}
}, [value, isValidating]);
// Memoize options processing to avoid recalculation on every render // Memoize options processing to avoid recalculation on every render
const selectOptions = useMemo(() => { const selectOptions = useMemo(() => {
@@ -149,9 +144,7 @@ const SelectCell = <T extends string>({
// 6. Clear processing state after a short delay - reduced for responsiveness // 6. Clear processing state after a short delay - reduced for responsiveness
setTimeout(() => { setTimeout(() => {
if (!isValidating) {
setIsProcessing(false); setIsProcessing(false);
}
}, 50); }, 50);
}, [onChange, onEndEdit]); }, [onChange, onEndEdit]);
@@ -207,7 +200,7 @@ const SelectCell = <T extends string>({
"w-full justify-between font-normal", "w-full justify-between font-normal",
"border", "border",
!internalValue && "text-muted-foreground", !internalValue && "text-muted-foreground",
showProcessing && "text-muted-foreground", isProcessing && "text-muted-foreground",
hasErrors ? "border-destructive" : "", hasErrors ? "border-destructive" : "",
className className
)} )}
@@ -240,7 +233,7 @@ const SelectCell = <T extends string>({
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<span className={showProcessing ? "opacity-70" : ""}> <span className={isProcessing ? "opacity-70" : ""}>
{displayValue} {displayValue}
</span> </span>
<ChevronsUpDown className="mr-1.5 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="mr-1.5 h-4 w-4 shrink-0 opacity-50" />

View File

@@ -1,100 +1,21 @@
import { useCallback, useEffect, useMemo, useRef, startTransition } from 'react'; import { useCallback, useMemo } from 'react';
import type { Dispatch, SetStateAction } from 'react'; import { RowData } from './validationTypes';
import { RowData, isEmpty as isValueEmpty } from './validationTypes';
import type { Field, Fields } from '../../../types'; import type { Field, Fields } from '../../../types';
import { ErrorSources, ErrorType, ValidationError } from '../../../types'; import { ErrorSources, ErrorType, ValidationError } from '../../../types';
import { useUniqueValidation } from './useUniqueValidation'; import { useUniqueValidation } from './useUniqueValidation';
import { isEmpty } from './validationTypes';
export const useRowOperations = <T extends string>( export const useRowOperations = <T extends string>(
data: RowData<T>[], data: RowData<T>[],
fields: Fields<T>, fields: Fields<T>,
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>, setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>, setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>,
validateFieldFromHook: (value: any, field: Field<T>) => ValidationError[], validateFieldFromHook: (value: any, field: Field<T>) => ValidationError[]
setValidatingCells?: Dispatch<SetStateAction<Set<string>>>
) => { ) => {
// Uniqueness validation utilities
const { validateUniqueField } = useUniqueValidation<T>(fields); const { validateUniqueField } = useUniqueValidation<T>(fields);
const dataRef = useRef(data); // Determine which field keys are considered uniqueness-constrained
useEffect(() => {
dataRef.current = data;
}, [data]);
type ValidationTask = { cancel: () => void };
const pendingValidationTasksRef = useRef<Map<string, ValidationTask>>(new Map());
const scheduleIdleTask = useCallback((taskKey: string, runTask: () => void) => {
const existingTask = pendingValidationTasksRef.current.get(taskKey);
existingTask?.cancel();
const execute = () => {
pendingValidationTasksRef.current.delete(taskKey);
runTask();
};
if (typeof window !== 'undefined') {
const win = window as Window & typeof globalThis & {
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
cancelIdleCallback?: (handle: number) => void;
};
if (win.requestIdleCallback) {
const handle = win.requestIdleCallback(() => {
execute();
}, { timeout: 250 });
pendingValidationTasksRef.current.set(taskKey, {
cancel: () => win.cancelIdleCallback?.(handle),
});
return;
}
const timeoutId = window.setTimeout(execute, 0);
pendingValidationTasksRef.current.set(taskKey, {
cancel: () => window.clearTimeout(timeoutId),
});
return;
}
execute();
}, []);
const updateValidatingCell = useCallback(
(rowIndex: number, fieldKey: string, pending: boolean) => {
if (!setValidatingCells) return;
const cellKey = `${rowIndex}-${fieldKey}`;
setValidatingCells((prev: Set<string>) => {
const hasKey = prev.has(cellKey);
if (pending && hasKey) return prev;
if (!pending && !hasKey) return prev;
const next = new Set(prev);
if (pending) next.add(cellKey);
else next.delete(cellKey);
return next;
});
},
[setValidatingCells]
);
const scheduleFieldValidation = useCallback(
(rowIndex: number, fieldKey: string, runValidation: () => void) => {
updateValidatingCell(rowIndex, fieldKey, true);
try {
runValidation();
} finally {
updateValidatingCell(rowIndex, fieldKey, false);
}
},
[updateValidatingCell]
);
useEffect(() => {
return () => {
pendingValidationTasksRef.current.forEach((task) => task.cancel());
pendingValidationTasksRef.current.clear();
};
}, []);
const uniquenessFieldKeys = useMemo(() => { const uniquenessFieldKeys = useMemo(() => {
const keys = new Set<string>([ const keys = new Set<string>([
'item_number', 'item_number',
@@ -104,16 +25,15 @@ export const useRowOperations = <T extends string>(
'notions_no', 'notions_no',
'name' 'name'
]); ]);
fields.forEach((f) => {
fields.forEach((field) => { if (f.validations?.some((v) => v.rule === 'unique')) {
if (field.validations?.some((v) => v.rule === 'unique')) { keys.add(String(f.key));
keys.add(String(field.key));
} }
}); });
return keys; return keys;
}, [fields]); }, [fields]);
// Merge per-field uniqueness errors into the validation error map
const mergeUniqueErrorsForFields = useCallback( const mergeUniqueErrorsForFields = useCallback(
( (
baseErrors: Map<number, Record<string, ValidationError[]>>, baseErrors: Map<number, Record<string, ValidationError[]>>,
@@ -124,19 +44,26 @@ export const useRowOperations = <T extends string>(
const newErrors = new Map(baseErrors); const newErrors = new Map(baseErrors);
// For each field, compute duplicates and merge
fieldKeysToCheck.forEach((fieldKey) => { fieldKeysToCheck.forEach((fieldKey) => {
if (!uniquenessFieldKeys.has(fieldKey)) return; if (!uniquenessFieldKeys.has(fieldKey)) return;
// Compute unique errors for this single field
const uniqueMap = validateUniqueField(dataForCalc, fieldKey); const uniqueMap = validateUniqueField(dataForCalc, fieldKey);
// Rows that currently have uniqueness errors for this field
const rowsWithUniqueErrors = new Set<number>(); const rowsWithUniqueErrors = new Set<number>();
uniqueMap.forEach((_, rowIdx) => rowsWithUniqueErrors.add(rowIdx)); uniqueMap.forEach((_, rowIdx) => rowsWithUniqueErrors.add(rowIdx));
// First, apply/overwrite unique errors for rows that have duplicates
uniqueMap.forEach((errorsForRow, rowIdx) => { uniqueMap.forEach((errorsForRow, rowIdx) => {
const existing = { ...(newErrors.get(rowIdx) || {}) }; const existing = { ...(newErrors.get(rowIdx) || {}) };
const info = errorsForRow[fieldKey];
const currentValue = (dataForCalc[rowIdx] as any)?.[fieldKey];
if (info && !isValueEmpty(currentValue)) { // Convert InfoWithSource to ValidationError[] for this field
const info = errorsForRow[fieldKey];
// Only apply uniqueness error when the value is non-empty
const currentValue = (dataForCalc[rowIdx] as any)?.[fieldKey];
if (info && !isEmpty(currentValue)) {
existing[fieldKey] = [ existing[fieldKey] = [
{ {
message: info.message, message: info.message,
@@ -151,7 +78,9 @@ export const useRowOperations = <T extends string>(
else newErrors.delete(rowIdx); else newErrors.delete(rowIdx);
}); });
// Then, remove any stale unique errors for this field where duplicates are resolved
newErrors.forEach((rowErrs, rowIdx) => { newErrors.forEach((rowErrs, rowIdx) => {
// Skip rows that still have unique errors for this field
if (rowsWithUniqueErrors.has(rowIdx)) return; if (rowsWithUniqueErrors.has(rowIdx)) return;
if ((rowErrs as any)[fieldKey]) { if ((rowErrs as any)[fieldKey]) {
@@ -170,372 +99,361 @@ export const useRowOperations = <T extends string>(
[uniquenessFieldKeys, validateUniqueField] [uniquenessFieldKeys, validateUniqueField]
); );
const pendingUniqueFieldsRef = useRef<Set<string>>(new Set()); // Helper function to validate a field value
const runUniqueValidation = useCallback(
(fieldsToProcess: string[]) => {
if (!fieldsToProcess.length) return;
setValidationErrors((prev) =>
mergeUniqueErrorsForFields(prev, dataRef.current, fieldsToProcess)
);
},
[mergeUniqueErrorsForFields, setValidationErrors]
);
const scheduleUniqueValidation = useCallback(
(fieldKeys: string[]) => {
if (!fieldKeys.length) return;
const uniqueKeys = fieldKeys.filter((key) => uniquenessFieldKeys.has(key));
if (!uniqueKeys.length) return;
if (pendingUniqueFieldsRef.current.size === 0 && uniqueKeys.length <= 2) {
const immediateKeys = Array.from(new Set(uniqueKeys));
runUniqueValidation(immediateKeys);
return;
}
uniqueKeys.forEach((fieldKey) => pendingUniqueFieldsRef.current.add(fieldKey));
scheduleIdleTask('unique:batch', () => {
const fieldsToProcess = Array.from(pendingUniqueFieldsRef.current);
pendingUniqueFieldsRef.current.clear();
if (!fieldsToProcess.length) return;
runUniqueValidation(fieldsToProcess);
});
},
[runUniqueValidation, scheduleIdleTask, uniquenessFieldKeys]
);
const fieldValidationHelper = useCallback( const fieldValidationHelper = useCallback(
(rowIndex: number, specificField?: string) => { (rowIndex: number, specificField?: string) => {
const currentData = dataRef.current; // Skip validation if row doesn't exist
if (rowIndex < 0 || rowIndex >= currentData.length) return; if (rowIndex < 0 || rowIndex >= data.length) return;
const row = currentData[rowIndex]; // Get the row data
const row = data[rowIndex];
// If validating a specific field, only check that field
if (specificField) { if (specificField) {
const field = fields.find((f) => String(f.key) === specificField); const field = fields.find((f) => String(f.key) === specificField);
if (!field) return; if (field) {
const value = row[specificField as keyof typeof row];
const value = row[specificField as keyof typeof row]; // Use state setter instead of direct mutation
setValidationErrors((prev) => {
let newErrors = new Map(prev);
const existingErrors = { ...(newErrors.get(rowIndex) || {}) };
updateValidatingCell(rowIndex, specificField, true); // Quick check for required fields - this prevents flashing errors
setValidationErrors((prev) => { const isRequired = field.validations?.some(
const existingErrors = prev.get(rowIndex) || {}; (v) => v.rule === "required"
const newRowErrors = { ...existingErrors }; );
let rowChanged = false; const isEmpty =
value === undefined ||
value === null ||
value === "" ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === "object" &&
value !== null &&
Object.keys(value).length === 0);
const isRequired = field.validations?.some((v) => v.rule === 'required'); // For non-empty values, remove required errors immediately
const valueIsEmpty = if (isRequired && !isEmpty && existingErrors[specificField]) {
value === undefined || const nonRequiredErrors = existingErrors[specificField].filter(
value === null || (e) => e.type !== ErrorType.Required
value === '' || );
(Array.isArray(value) && value.length === 0) || if (nonRequiredErrors.length === 0) {
(typeof value === 'object' && value !== null && Object.keys(value).length === 0); // If no other errors, remove the field entirely from errors
delete existingErrors[specificField];
if (isRequired && !valueIsEmpty && newRowErrors[specificField]) { } else {
const nonRequiredErrors = newRowErrors[specificField].filter((e) => e.type !== ErrorType.Required); existingErrors[specificField] = nonRequiredErrors;
}
if (nonRequiredErrors.length === 0) {
rowChanged = true;
delete newRowErrors[specificField];
} else if (nonRequiredErrors.length !== newRowErrors[specificField].length) {
rowChanged = true;
newRowErrors[specificField] = nonRequiredErrors;
} }
}
const errors = validateFieldFromHook(value, field as unknown as Field<T>); // Run full validation for the field
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
if (errors.length > 0) { // Update validation errors for this field
const existing = newRowErrors[specificField] || []; if (errors.length > 0) {
const sameLength = existing.length === errors.length; existingErrors[specificField] = errors;
const sameContent = sameLength && existing.every((err, idx) => err.message === errors[idx].message && err.type === errors[idx].type);
if (!sameContent) {
rowChanged = true;
newRowErrors[specificField] = errors;
}
} else if (newRowErrors[specificField]) {
rowChanged = true;
delete newRowErrors[specificField];
}
let resultMap = prev;
if (rowChanged) {
resultMap = new Map(prev);
if (Object.keys(newRowErrors).length > 0) {
resultMap.set(rowIndex, newRowErrors);
} else { } else {
resultMap.delete(rowIndex); delete existingErrors[specificField];
} }
}
if (uniquenessFieldKeys.has(specificField)) { // Update validation errors map
scheduleUniqueValidation([specificField]); if (Object.keys(existingErrors).length > 0) {
return rowChanged ? resultMap : prev; newErrors.set(rowIndex, existingErrors);
} } else {
newErrors.delete(rowIndex);
}
return rowChanged ? resultMap : prev; // If field is uniqueness-constrained, also re-validate uniqueness for the column
}); if (uniquenessFieldKeys.has(specificField)) {
updateValidatingCell(rowIndex, specificField, false); const dataForCalc = data; // latest data
newErrors = mergeUniqueErrorsForFields(newErrors, dataForCalc, [specificField]);
}
return newErrors;
});
}
} else { } else {
// Validate all fields in the row
setValidationErrors((prev) => { setValidationErrors((prev) => {
const rowErrors: Record<string, ValidationError[]> = {}; const newErrors = new Map(prev);
const rowErrors: Record<string, ValidationError[]> = {};
fields.forEach((field) => { fields.forEach((field) => {
const fieldKey = String(field.key); const fieldKey = String(field.key);
const valueForField = row[fieldKey as keyof typeof row]; const value = row[fieldKey as keyof typeof row];
const errors = validateFieldFromHook(valueForField, field as unknown as Field<T>); const errors = validateFieldFromHook(value, field as unknown as Field<T>);
if (errors.length > 0) { if (errors.length > 0) {
rowErrors[fieldKey] = errors; rowErrors[fieldKey] = errors;
} }
}); });
if (Object.keys(rowErrors).length === 0) { // Update validation errors map
if (!prev.has(rowIndex)) return prev; if (Object.keys(rowErrors).length > 0) {
const result = new Map(prev); newErrors.set(rowIndex, rowErrors);
result.delete(rowIndex); } else {
return result; newErrors.delete(rowIndex);
} }
const existing = prev.get(rowIndex); return newErrors;
const sameEntries = existing && Object.keys(rowErrors).length === Object.keys(existing).length && Object.entries(rowErrors).every(([key, val]) => { });
const existingVal = existing[key];
return (
existingVal &&
existingVal.length === val.length &&
existingVal.every((err, idx) => err.message === val[idx].message && err.type === val[idx].type)
);
});
if (sameEntries) return prev;
const result = new Map(prev);
result.set(rowIndex, rowErrors);
return result;
});
const uniqueKeys = fields
.map((field) => String(field.key))
.filter((fieldKey) => uniquenessFieldKeys.has(fieldKey));
if (uniqueKeys.length > 0) {
scheduleUniqueValidation(uniqueKeys);
}
} }
}, },
[fields, scheduleUniqueValidation, setValidationErrors, uniquenessFieldKeys, validateFieldFromHook] [data, fields, validateFieldFromHook, setValidationErrors, mergeUniqueErrorsForFields, uniquenessFieldKeys]
); );
// Use validateRow as an alias for fieldValidationHelper for compatibility
const validateRow = fieldValidationHelper; const validateRow = fieldValidationHelper;
// Modified updateRow function that properly handles field-specific validation
const updateRow = useCallback( const updateRow = useCallback(
(rowIndex: number, key: T, value: any) => { (rowIndex: number, key: T, value: any) => {
// Process value before updating data
let processedValue = value; let processedValue = value;
if ((key === 'msrp' || key === 'cost_each') && typeof value === 'string') { // Strip dollar signs from price fields
processedValue = value.replace(/[$,]/g, ''); if (
(key === "msrp" || key === "cost_each") &&
typeof value === "string"
) {
processedValue = value.replace(/[$,]/g, "");
// Also ensure it's a valid number
const numValue = parseFloat(processedValue); const numValue = parseFloat(processedValue);
if (!Number.isNaN(numValue)) { if (!isNaN(numValue)) {
processedValue = numValue.toFixed(2); processedValue = numValue.toFixed(2);
} }
} }
const currentData = dataRef.current; // Find the row data first
const rowData = currentData[rowIndex]; const rowData = data[rowIndex];
if (!rowData) { if (!rowData) {
console.error(`No row data found for index ${rowIndex}`); console.error(`No row data found for index ${rowIndex}`);
return; return;
} }
// Create a copy of the row to avoid mutation
const updatedRow = { ...rowData, [key]: processedValue }; const updatedRow = { ...rowData, [key]: processedValue };
const nextData = [...currentData]; // Update the data immediately - this sets the value
if (rowIndex >= 0 && rowIndex < nextData.length) { setData((prevData) => {
nextData[rowIndex] = updatedRow; const newData = [...prevData];
} if (rowIndex >= 0 && rowIndex < newData.length) {
dataRef.current = nextData; newData[rowIndex] = updatedRow;
}
startTransition(() => { return newData;
setData(() => nextData);
}); });
// Find the field definition
const field = fields.find((f) => String(f.key) === key); const field = fields.find((f) => String(f.key) === key);
if (!field) return; if (!field) return;
scheduleFieldValidation(rowIndex, String(key), () => { // CRITICAL FIX: Combine both validation operations into a single state update
setValidationErrors((prev) => { // to prevent intermediate rendering that causes error icon flashing
const existingErrors = prev.get(rowIndex) || {}; setValidationErrors((prev) => {
const newRowErrors = { ...existingErrors }; // Start with previous errors
let rowChanged = false; let newMap = new Map(prev);
const existingErrors = newMap.get(rowIndex) || {};
const newRowErrors = { ...existingErrors };
const latestRow = dataRef.current[rowIndex]; // Check for required field first
const currentValue = latestRow ? (latestRow[String(key) as keyof typeof latestRow] as unknown) : processedValue; const isRequired = field.validations?.some(
(v) => v.rule === "required"
);
const isEmpty =
processedValue === undefined ||
processedValue === null ||
processedValue === "" ||
(Array.isArray(processedValue) && processedValue.length === 0) ||
(typeof processedValue === "object" &&
processedValue !== null &&
Object.keys(processedValue).length === 0);
const isRequired = field.validations?.some((v) => v.rule === 'required'); // For required fields with values, remove required errors
const valueIsEmpty = if (isRequired && !isEmpty && newRowErrors[key as string]) {
currentValue === undefined || const hasRequiredError = newRowErrors[key as string].some(
currentValue === null || (e) => e.type === ErrorType.Required
currentValue === '' || );
(Array.isArray(currentValue) && currentValue.length === 0) ||
(typeof currentValue === 'object' && currentValue !== null && Object.keys(currentValue).length === 0); if (hasRequiredError) {
// Remove required errors but keep other types of errors
const nonRequiredErrors = newRowErrors[key as string].filter(
(e) => e.type !== ErrorType.Required
);
if (isRequired && !valueIsEmpty && newRowErrors[String(key)]) {
const nonRequiredErrors = newRowErrors[String(key)].filter((e) => e.type !== ErrorType.Required);
if (nonRequiredErrors.length === 0) { if (nonRequiredErrors.length === 0) {
if (newRowErrors[String(key)]) { // If no other errors, delete the field's errors entirely
rowChanged = true; delete newRowErrors[key as string];
delete newRowErrors[String(key)];
}
} else if (nonRequiredErrors.length !== newRowErrors[String(key)].length) {
rowChanged = true;
newRowErrors[String(key)] = nonRequiredErrors;
}
}
const errors = validateFieldFromHook(
currentValue,
field as unknown as Field<T>
).filter((e) => e.type !== ErrorType.Required || valueIsEmpty);
if (errors.length > 0) {
const existing = newRowErrors[String(key)] || [];
const sameLength = existing.length === errors.length;
const sameContent = sameLength && existing.every((err, idx) => err.message === errors[idx].message && err.type === errors[idx].type);
if (!sameContent) {
rowChanged = true;
newRowErrors[String(key)] = errors;
}
} else if (newRowErrors[String(key)]) {
rowChanged = true;
delete newRowErrors[String(key)];
}
let resultMap = prev;
if (rowChanged) {
resultMap = new Map(prev);
if (Object.keys(newRowErrors).length > 0) {
resultMap.set(rowIndex, newRowErrors);
} else { } else {
resultMap.delete(rowIndex); // Otherwise keep non-required errors
newRowErrors[key as string] = nonRequiredErrors;
} }
} }
}
const fieldsToCheck: string[] = []; // Now run full validation for the field (except for required which we already handled)
if (uniquenessFieldKeys.has(String(key))) fieldsToCheck.push(String(key)); const errors = validateFieldFromHook(
if (key === ('upc' as T) || key === ('barcode' as T) || key === ('supplier' as T)) { processedValue,
if (uniquenessFieldKeys.has('item_number')) fieldsToCheck.push('item_number'); field as unknown as Field<T>
} ).filter((e) => e.type !== ErrorType.Required || isEmpty);
if (fieldsToCheck.length > 0) { // Update with new validation results
scheduleUniqueValidation(fieldsToCheck); if (errors.length > 0) {
} newRowErrors[key as string] = errors;
} else {
// Clear any existing errors for this field
delete newRowErrors[key as string];
}
return rowChanged ? resultMap : prev; // Update the map
}); if (Object.keys(newRowErrors).length > 0) {
newMap.set(rowIndex, newRowErrors);
} else {
newMap.delete(rowIndex);
}
// If uniqueness applies, validate affected columns
const fieldsToCheck: string[] = [];
if (uniquenessFieldKeys.has(String(key))) fieldsToCheck.push(String(key));
if (key === ('upc' as T) || key === ('barcode' as T) || key === ('supplier' as T)) {
if (uniquenessFieldKeys.has('item_number')) fieldsToCheck.push('item_number');
}
if (fieldsToCheck.length > 0) {
const dataForCalc = (() => {
const copy = [...data];
if (rowIndex >= 0 && rowIndex < copy.length) {
copy[rowIndex] = { ...(copy[rowIndex] || {}), [key]: processedValue } as RowData<T>;
}
return copy;
})();
newMap = mergeUniqueErrorsForFields(newMap, dataForCalc, fieldsToCheck);
}
return newMap;
}); });
// Handle simple secondary effects here
setTimeout(() => { setTimeout(() => {
// Use __index to find the actual row in the full data array
const rowId = rowData.__index; const rowId = rowData.__index;
if (key === 'company' && processedValue) { // Handle company change - clear line/subline
const nextData = [...dataRef.current]; if (key === "company" && processedValue) {
const idx = nextData.findIndex((item) => item.__index === rowId); // Clear any existing line/subline values
if (idx >= 0) { setData((prevData) => {
nextData[idx] = { const newData = [...prevData];
...nextData[idx], const idx = newData.findIndex((item) => item.__index === rowId);
line: undefined, if (idx >= 0) {
subline: undefined, newData[idx] = {
}; ...newData[idx],
dataRef.current = nextData; line: undefined,
startTransition(() => { subline: undefined,
setData(() => nextData); };
}); }
} return newData;
});
} }
if (key === 'line' && processedValue) { // Handle line change - clear subline
const nextData = [...dataRef.current]; if (key === "line" && processedValue) {
const idx = nextData.findIndex((item) => item.__index === rowId); // Clear any existing subline value
if (idx >= 0) { setData((prevData) => {
nextData[idx] = { const newData = [...prevData];
...nextData[idx], const idx = newData.findIndex((item) => item.__index === rowId);
subline: undefined, if (idx >= 0) {
}; newData[idx] = {
dataRef.current = nextData; ...newData[idx],
startTransition(() => { subline: undefined,
setData(() => nextData); };
}); }
} return newData;
});
} }
}, 5); }, 5); // Reduced delay for faster secondary effects
}, },
[fields, scheduleFieldValidation, scheduleUniqueValidation, setData, setValidationErrors, uniquenessFieldKeys, validateFieldFromHook] [data, fields, validateFieldFromHook, setData, setValidationErrors, mergeUniqueErrorsForFields, uniquenessFieldKeys]
); );
// Improved revalidateRows function
const revalidateRows = useCallback( const revalidateRows = useCallback(
async ( async (
rowIndexes: number[], rowIndexes: number[],
updatedFields?: { [rowIndex: number]: string[] } updatedFields?: { [rowIndex: number]: string[] }
) => { ) => {
const uniqueFieldsToCheck = new Set<string>(); // Process all specified rows using a single state update to avoid race conditions
const fieldsMarked: Array<[number, string]> = [];
setValidationErrors((prev) => { setValidationErrors((prev) => {
let newErrors = new Map(prev); let newErrors = new Map(prev);
const currentData = dataRef.current;
// Track which uniqueness fields need to be revalidated across the dataset
const uniqueFieldsToCheck = new Set<string>();
// Process each row
for (const rowIndex of rowIndexes) { for (const rowIndex of rowIndexes) {
if (rowIndex < 0 || rowIndex >= currentData.length) continue; if (rowIndex < 0 || rowIndex >= data.length) continue;
const row = currentData[rowIndex];
const row = data[rowIndex];
if (!row) continue; if (!row) continue;
// If we have specific fields to update for this row
const fieldsToValidate = updatedFields?.[rowIndex] || []; const fieldsToValidate = updatedFields?.[rowIndex] || [];
if (fieldsToValidate.length > 0) { if (fieldsToValidate.length > 0) {
// Get existing errors for this row
const existingRowErrors = { ...(newErrors.get(rowIndex) || {}) }; const existingRowErrors = { ...(newErrors.get(rowIndex) || {}) };
// Validate each specified field
for (const fieldKey of fieldsToValidate) { for (const fieldKey of fieldsToValidate) {
const field = fields.find((f) => String(f.key) === fieldKey); const field = fields.find((f) => String(f.key) === fieldKey);
if (!field) continue; if (!field) continue;
updateValidatingCell(rowIndex, fieldKey, true);
fieldsMarked.push([rowIndex, fieldKey]);
const value = row[fieldKey as keyof typeof row]; const value = row[fieldKey as keyof typeof row];
// Run validation for this field
const errors = validateFieldFromHook(value, field as unknown as Field<T>); const errors = validateFieldFromHook(value, field as unknown as Field<T>);
// Update errors for this field
if (errors.length > 0) { if (errors.length > 0) {
existingRowErrors[fieldKey] = errors; existingRowErrors[fieldKey] = errors;
} else { } else {
delete existingRowErrors[fieldKey]; delete existingRowErrors[fieldKey];
} }
// If field is uniqueness-constrained, mark for uniqueness pass
if (uniquenessFieldKeys.has(fieldKey)) { if (uniquenessFieldKeys.has(fieldKey)) {
uniqueFieldsToCheck.add(fieldKey); uniqueFieldsToCheck.add(fieldKey);
} }
} }
// Update the row's errors
if (Object.keys(existingRowErrors).length > 0) { if (Object.keys(existingRowErrors).length > 0) {
newErrors.set(rowIndex, existingRowErrors); newErrors.set(rowIndex, existingRowErrors);
} else { } else {
newErrors.delete(rowIndex); newErrors.delete(rowIndex);
} }
} else { } else {
// No specific fields provided - validate the entire row
const rowErrors: Record<string, ValidationError[]> = {}; const rowErrors: Record<string, ValidationError[]> = {};
// Validate all fields in the row
for (const field of fields) { for (const field of fields) {
const fieldKey = String(field.key); const fieldKey = String(field.key);
const value = row[fieldKey as keyof typeof row]; const value = row[fieldKey as keyof typeof row];
// Run validation for this field
const errors = validateFieldFromHook(value, field as unknown as Field<T>); const errors = validateFieldFromHook(value, field as unknown as Field<T>);
// Update errors for this field
if (errors.length > 0) { if (errors.length > 0) {
rowErrors[fieldKey] = errors; rowErrors[fieldKey] = errors;
} }
// If field is uniqueness-constrained and we validated it, include for uniqueness pass
if (uniquenessFieldKeys.has(fieldKey)) { if (uniquenessFieldKeys.has(fieldKey)) {
uniqueFieldsToCheck.add(fieldKey); uniqueFieldsToCheck.add(fieldKey);
} }
} }
// Update the row's errors
if (Object.keys(rowErrors).length > 0) { if (Object.keys(rowErrors).length > 0) {
newErrors.set(rowIndex, rowErrors); newErrors.set(rowIndex, rowErrors);
} else { } else {
@@ -544,40 +462,31 @@ export const useRowOperations = <T extends string>(
} }
} }
// Run per-field uniqueness checks and merge results
if (uniqueFieldsToCheck.size > 0) {
newErrors = mergeUniqueErrorsForFields(newErrors, data, Array.from(uniqueFieldsToCheck));
}
return newErrors; return newErrors;
}); });
fieldsMarked.forEach(([rowIndex, fieldKey]) => {
updateValidatingCell(rowIndex, fieldKey, false);
});
if (uniqueFieldsToCheck.size > 0) {
scheduleUniqueValidation(Array.from(uniqueFieldsToCheck));
}
}, },
[ [data, fields, validateFieldFromHook, mergeUniqueErrorsForFields, uniquenessFieldKeys]
fields,
scheduleUniqueValidation,
setValidationErrors,
uniquenessFieldKeys,
validateFieldFromHook,
updateValidatingCell
]
); );
// Copy a cell value to all cells below it in the same column
const copyDown = useCallback( const copyDown = useCallback(
(rowIndex: number, key: T) => { (rowIndex: number, key: T) => {
const currentData = dataRef.current; // Get the source value to copy
const sourceRow = currentData[rowIndex]; const sourceValue = data[rowIndex][key];
if (!sourceRow) return;
const sourceValue = sourceRow[key]; // Update all rows below with the same value using the existing updateRow function
// This ensures all validation logic runs consistently
for (let i = rowIndex + 1; i < currentData.length; i++) { for (let i = rowIndex + 1; i < data.length; i++) {
// Just use updateRow which will handle validation with proper timing
updateRow(i, key, sourceValue); updateRow(i, key, sourceValue);
} }
}, },
[updateRow] [data, updateRow]
); );
return { return {
@@ -586,4 +495,4 @@ export const useRowOperations = <T extends string>(
revalidateRows, revalidateRows,
copyDown copyDown
}; };
}; };

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo, useRef, startTransition } from "react"; import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useRsi } from "../../../hooks/useRsi"; import { useRsi } from "../../../hooks/useRsi";
import { ErrorType } from "../../../types"; import { ErrorType } from "../../../types";
import { RowSelectionState } from "@tanstack/react-table"; import { RowSelectionState } from "@tanstack/react-table";
@@ -11,7 +11,7 @@ import { useTemplateManagement } from "./useTemplateManagement";
import { useFilterManagement } from "./useFilterManagement"; import { useFilterManagement } from "./useFilterManagement";
import { useUniqueItemNumbersValidation } from "./useUniqueItemNumbersValidation"; import { useUniqueItemNumbersValidation } from "./useUniqueItemNumbersValidation";
import { useUpcValidation } from "./useUpcValidation"; import { useUpcValidation } from "./useUpcValidation";
import { Props, RowData, isEmpty as isValueEmpty } from "./validationTypes"; import { Props, RowData } from "./validationTypes";
// Country normalization helper (common mappings) - function declaration for hoisting // Country normalization helper (common mappings) - function declaration for hoisting
function normalizeCountryCode(input: string): string | null { function normalizeCountryCode(input: string): string | null {
@@ -145,7 +145,6 @@ export const useValidationState = <T extends string>({
// isValidatingRef unused; remove to satisfy TS // isValidatingRef unused; remove to satisfy TS
// Track last seen item_number signature to drive targeted uniqueness checks // Track last seen item_number signature to drive targeted uniqueness checks
const lastItemNumberSigRef = useRef<string | null>(null); const lastItemNumberSigRef = useRef<string | null>(null);
const pendingItemNumberValidationRef = useRef<{ cancel: () => void } | null>(null);
// Use row operations hook // Use row operations hook
const { validateRow, updateRow, revalidateRows, copyDown } = useRowOperations<T>( const { validateRow, updateRow, revalidateRows, copyDown } = useRowOperations<T>(
@@ -153,8 +152,7 @@ export const useValidationState = <T extends string>({
fields, fields,
setData, setData,
setValidationErrors, setValidationErrors,
validateFieldFromHook, validateFieldFromHook
setValidatingCells
); );
// Use UPC validation hook - MUST be initialized before template management // Use UPC validation hook - MUST be initialized before template management
@@ -308,325 +306,313 @@ export const useValidationState = <T extends string>({
// Initialize validation once, after initial UPC-based item number generation completes // Initialize validation once, after initial UPC-based item number generation completes
useEffect(() => { useEffect(() => {
if (initialValidationDoneRef.current) return; if (initialValidationDoneRef.current) return;
// Wait for initial UPC validation to finish to avoid double work and ensure
// item_number values are in place before uniqueness checks
if (!upcValidation.initialValidationDone) return; if (!upcValidation.initialValidationDone) return;
if (!data || data.length === 0) return;
const requiredFields = fields.filter((field) => const runCompleteValidation = async () => {
field.validations?.some((v) => v.rule === "required") if (!data || data.length === 0) return;
);
const regexFields = fields.filter((field) =>
field.validations?.some((v) => v.rule === "regex")
);
const validationErrorsTemp = new Map<number, Record<string, any[]>>(); console.log("Running complete validation...");
const mutatedRows: Array<[number, RowData<T>]> = [];
const totalRows = data.length;
let currentIndex = 0;
let cancelled = false;
const cleanupCallbacks = new Set<() => void>(); // Get required fields
const requiredFields = fields.filter((field) =>
field.validations?.some((v) => v.rule === "required")
);
console.log(`Found ${requiredFields.length} required fields`);
const processRow = (rowIndex: number) => { // Get fields that have regex validation
const row = data[rowIndex]; const regexFields = fields.filter((field) =>
if (!row) return; field.validations?.some((v) => v.rule === "regex")
);
console.log(`Found ${regexFields.length} fields with regex validation`);
const rowErrors: Record<string, any[]> = {}; // Get fields that need uniqueness validation
let hasErrors = false; const uniqueFields = fields.filter((field) =>
field.validations?.some((v) => v.rule === "unique")
);
console.log(
`Found ${uniqueFields.length} fields requiring uniqueness validation`
);
const rowAsRecord = row as Record<string, any>; // Dynamic batch size based on dataset size
let updatedRow: Record<string, any> | null = null; const BATCH_SIZE = data.length <= 50 ? data.length : 25; // Process all at once for small datasets
const totalRows = data.length;
const ensureUpdatedRow = () => { // Initialize new data for any modifications
if (!updatedRow) { const newData = [...data];
updatedRow = { ...rowAsRecord };
// Create a temporary Map to collect all validation errors
const validationErrorsTemp = new Map<
number,
Record<string, any[]>
>();
// Variables for batching
let currentBatch = 0;
const totalBatches = Math.ceil(totalRows / BATCH_SIZE);
const processBatch = async () => {
// Calculate batch range
const startIdx = currentBatch * BATCH_SIZE;
const endIdx = Math.min(startIdx + BATCH_SIZE, totalRows);
console.log(
`Processing batch ${
currentBatch + 1
}/${totalBatches} (rows ${startIdx} to ${endIdx - 1})`
);
// Process rows in this batch
const batchPromises: Promise<void>[] = [];
for (let rowIndex = startIdx; rowIndex < endIdx; rowIndex++) {
batchPromises.push(
new Promise<void>((resolve) => {
const row = data[rowIndex];
// Skip if row is empty or undefined
if (!row) {
resolve();
return;
}
// Store field errors for this row
const fieldErrors: Record<string, any[]> = {};
let hasErrors = false;
// Check if price fields need formatting
const rowAsRecord = row as Record<string, any>;
let mSrpNeedsProcessing = false;
let costEachNeedsProcessing = false;
if (
rowAsRecord.msrp &&
typeof rowAsRecord.msrp === "string" &&
(rowAsRecord.msrp.includes("$") ||
rowAsRecord.msrp.includes(","))
) {
mSrpNeedsProcessing = true;
}
if (
rowAsRecord.cost_each &&
typeof rowAsRecord.cost_each === "string" &&
(rowAsRecord.cost_each.includes("$") ||
rowAsRecord.cost_each.includes(","))
) {
costEachNeedsProcessing = true;
}
// Process price fields if needed
if (mSrpNeedsProcessing || costEachNeedsProcessing) {
// Create a clean copy only if needed
const cleanedRow = { ...row } as Record<string, any>;
if (mSrpNeedsProcessing) {
const msrpValue = rowAsRecord.msrp.replace(/[$,]/g, "");
const numValue = parseFloat(msrpValue);
cleanedRow.msrp = !isNaN(numValue)
? numValue.toFixed(2)
: msrpValue;
}
if (costEachNeedsProcessing) {
const costValue = rowAsRecord.cost_each.replace(/[$,]/g, "");
const numValue = parseFloat(costValue);
cleanedRow.cost_each = !isNaN(numValue)
? numValue.toFixed(2)
: costValue;
}
newData[rowIndex] = cleanedRow as RowData<T>;
}
// Validate required fields
for (const field of requiredFields) {
const key = String(field.key);
const value = row[key as keyof typeof row];
// Skip non-required empty fields
if (
value === undefined ||
value === null ||
value === "" ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === "object" &&
value !== null &&
Object.keys(value).length === 0)
) {
// Add error for empty required fields
fieldErrors[key] = [
{
message:
field.validations?.find((v) => v.rule === "required")
?.errorMessage || "This field is required",
level: "error",
source: "row",
type: "required",
},
];
hasErrors = true;
}
}
// Validate regex fields - even if they have data
for (const field of regexFields) {
const key = String(field.key);
const value = row[key as keyof typeof row];
// Skip empty values as they're handled by required validation
if (value === undefined || value === null || value === "") {
continue;
}
// Find regex validation
const regexValidation = field.validations?.find(
(v) => v.rule === "regex"
);
if (regexValidation) {
try {
// Check if value matches regex
const regex = new RegExp(
regexValidation.value,
regexValidation.flags
);
if (!regex.test(String(value))) {
// Add regex validation error
fieldErrors[key] = [
{
message: regexValidation.errorMessage,
level: regexValidation.level || "error",
source: "row",
type: "regex",
},
];
hasErrors = true;
}
} catch (error) {
console.error("Invalid regex in validation:", error);
}
}
}
// Update validation errors for this row
if (hasErrors) {
validationErrorsTemp.set(rowIndex, fieldErrors);
}
resolve();
})
);
} }
return updatedRow;
// Wait for all row validations to complete
await Promise.all(batchPromises);
}; };
if (typeof rowAsRecord.msrp === "string" && /[$,]/.test(rowAsRecord.msrp)) { const processAllBatches = async () => {
const msrpValue = rowAsRecord.msrp.replace(/[$,]/g, ""); for (let batch = 0; batch < totalBatches; batch++) {
const numValue = parseFloat(msrpValue); currentBatch = batch;
ensureUpdatedRow().msrp = Number.isNaN(numValue) ? msrpValue : numValue.toFixed(2); await processBatch();
}
if (typeof rowAsRecord.cost_each === "string" && /[$,]/.test(rowAsRecord.cost_each)) { // Yield to UI thread more frequently for large datasets
const costValue = rowAsRecord.cost_each.replace(/[$,]/g, ""); if (batch % 2 === 1 || totalRows > 500) {
const numValue = parseFloat(costValue); await new Promise((resolve) => setTimeout(resolve, totalRows > 1000 ? 10 : 5));
ensureUpdatedRow().cost_each = Number.isNaN(numValue) ? costValue : numValue.toFixed(2);
}
for (const field of requiredFields) {
const key = String(field.key);
const value = rowAsRecord[key];
if (isValueEmpty(value)) {
rowErrors[key] = [
{
message:
field.validations?.find((v) => v.rule === "required")?.errorMessage ||
"This field is required",
level: "error",
source: "row",
type: "required",
},
];
hasErrors = true;
}
}
for (const field of regexFields) {
const key = String(field.key);
const value = rowAsRecord[key];
if (value === undefined || value === null || value === "") continue;
const regexValidation = field.validations?.find((v) => v.rule === "regex");
if (!regexValidation) continue;
try {
const regex = new RegExp(regexValidation.value, regexValidation.flags);
if (!regex.test(String(value))) {
rowErrors[key] = [
{
message: regexValidation.errorMessage,
level: regexValidation.level || "error",
source: "row",
type: "regex",
},
];
hasErrors = true;
} }
} catch (error) {
console.error("Invalid regex in validation:", error);
}
}
if (updatedRow) {
mutatedRows.push([rowIndex, updatedRow as RowData<T>]);
}
if (hasErrors) {
validationErrorsTemp.set(rowIndex, rowErrors);
}
};
const finalize = () => {
if (cancelled) return;
startTransition(() => {
setValidationErrors(new Map(validationErrorsTemp));
});
if (mutatedRows.length > 0) {
setData((prev) => {
if (cancelled) return prev;
let applied = false;
const next = [...prev];
mutatedRows.forEach(([index, updatedRow]) => {
if (index < 0 || index >= prev.length) return;
if (prev[index] !== data[index]) return;
next[index] = updatedRow;
applied = true;
});
return applied ? next : prev;
});
}
validateUniqueItemNumbers();
initialValidationDoneRef.current = true;
};
const runChunk = (deadline?: IdleDeadline) => {
if (cancelled) return;
let iterations = 0;
const maxIterationsPerChunk = 20;
while (currentIndex < totalRows) {
if (deadline) {
if (deadline.timeRemaining() <= 0 && iterations > 0) break;
} else if (iterations >= maxIterationsPerChunk) {
break;
} }
processRow(currentIndex); // All batches complete
currentIndex += 1; console.log("All initial validation batches complete");
iterations += 1;
}
if (currentIndex >= totalRows) { // Apply collected validation errors all at once
finalize(); setValidationErrors(validationErrorsTemp);
return;
}
scheduleNext(); // Apply any data changes (like price formatting)
}; if (JSON.stringify(data) !== JSON.stringify(newData)) {
setData(newData);
const scheduleNext = () => {
if (cancelled) return;
if (typeof window !== 'undefined') {
const win = window as Window & typeof globalThis & {
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
cancelIdleCallback?: (handle: number) => void;
};
if (win.requestIdleCallback) {
let cancel: () => void = () => {};
const handle = win.requestIdleCallback((deadline) => {
cleanupCallbacks.delete(cancel);
runChunk(deadline);
}, { timeout: 250 });
cancel = () => win.cancelIdleCallback?.(handle);
cleanupCallbacks.add(cancel);
return;
} }
let cancel: () => void = () => {}; // Run uniqueness validation after the basic validation
const timeoutId = window.setTimeout(() => { validateUniqueItemNumbers();
cleanupCallbacks.delete(cancel);
runChunk();
}, 16);
cancel = () => window.clearTimeout(timeoutId);
cleanupCallbacks.add(cancel);
return;
}
setTimeout(() => runChunk(), 0); // Mark that initial validation is done
initialValidationDoneRef.current = true;
console.log("Initial validation complete");
};
// Start the validation process
processAllBatches();
}; };
scheduleNext(); // Run the complete validation
runCompleteValidation();
return () => {
cancelled = true;
cleanupCallbacks.forEach((cancel) => cancel());
cleanupCallbacks.clear();
};
}, [data, fields, setData, setValidationErrors, validateUniqueItemNumbers, upcValidation.initialValidationDone]); }, [data, fields, setData, setValidationErrors, validateUniqueItemNumbers, upcValidation.initialValidationDone]);
// Targeted uniqueness revalidation: run only when item_number values change // Targeted uniqueness revalidation: run only when item_number values change
useEffect(() => { useEffect(() => {
if (!data || data.length === 0) return; if (!data || data.length === 0) return;
const sig = data // Build a simple signature of the item_number column
.map((r) => String((r as Record<string, any>).item_number ?? '')) const sig = data.map((r) => String((r as Record<string, any>).item_number ?? '')).join('|');
.join('|');
if (lastItemNumberSigRef.current === sig) return; if (lastItemNumberSigRef.current === sig) return;
lastItemNumberSigRef.current = sig; lastItemNumberSigRef.current = sig;
pendingItemNumberValidationRef.current?.cancel(); // Compute unique errors for item_number only and merge
const uniqueMap = validateUniqueField(data, 'item_number');
const rowsWithUnique = new Set<number>();
uniqueMap.forEach((_, idx) => rowsWithUnique.add(idx));
let cancelled = false; setValidationErrors((prev) => {
const currentData = data; const newMap = new Map(prev);
const runValidation = () => { // Apply unique errors
if (cancelled) return; uniqueMap.forEach((errorsForRow, rowIdx) => {
const existing = { ...(newMap.get(rowIdx) || {}) } as Record<string, any[]>;
const uniqueMap = validateUniqueField(currentData, 'item_number'); const info = (errorsForRow as any)['item_number'];
const rowsWithUnique = new Set<number>(); const currentValue = (data[rowIdx] as any)?.['item_number'];
uniqueMap.forEach((_, idx) => rowsWithUnique.add(idx)); // Only apply uniqueness error when the value is non-empty
if (info && currentValue !== undefined && currentValue !== null && String(currentValue) !== '') {
startTransition(() => { existing['item_number'] = [
setValidationErrors((prev) => { {
const newMap = new Map(prev); message: info.message,
level: info.level,
uniqueMap.forEach((errorsForRow, rowIdx) => { source: info.source,
const existing = { ...(newMap.get(rowIdx) || {}) } as Record<string, any[]>; type: info.type,
const info = (errorsForRow as any)['item_number']; },
const currentValue = (currentData[rowIdx] as any)?.['item_number']; ];
}
if (info && currentValue !== undefined && currentValue !== null && String(currentValue) !== '') { // If value is now present, make sure to clear any lingering Required error
existing['item_number'] = [ if (currentValue !== undefined && currentValue !== null && String(currentValue) !== '' && existing['item_number']) {
{ existing['item_number'] = (existing['item_number'] as any[]).filter((e) => e.type !== ErrorType.Required);
message: info.message, if ((existing['item_number'] as any[]).length === 0) delete existing['item_number'];
level: info.level, }
source: info.source, if (Object.keys(existing).length > 0) newMap.set(rowIdx, existing);
type: info.type, else newMap.delete(rowIdx);
},
];
}
if (currentValue !== undefined && currentValue !== null && String(currentValue) !== '' && existing['item_number']) {
existing['item_number'] = (existing['item_number'] as any[]).filter((e) => e.type !== ErrorType.Required);
if ((existing['item_number'] as any[]).length === 0) delete existing['item_number'];
}
if (Object.keys(existing).length > 0) newMap.set(rowIdx, existing);
else newMap.delete(rowIdx);
});
newMap.forEach((rowErrs, rowIdx) => {
const currentValue = (currentData[rowIdx] as any)?.['item_number'];
const shouldRemoveUnique =
!rowsWithUnique.has(rowIdx) ||
currentValue === undefined ||
currentValue === null ||
String(currentValue) === '';
if (shouldRemoveUnique && (rowErrs as any)['item_number']) {
const filtered = (rowErrs as any)['item_number'].filter((e: any) => e.type !== ErrorType.Unique);
if (filtered.length > 0) (rowErrs as any)['item_number'] = filtered;
else delete (rowErrs as any)['item_number'];
}
if (currentValue !== undefined && currentValue !== null && String(currentValue) !== '' && (rowErrs as any)['item_number']) {
const nonRequired = (rowErrs as any)['item_number'].filter((e: any) => e.type !== ErrorType.Required);
if (nonRequired.length > 0) (rowErrs as any)['item_number'] = nonRequired;
else delete (rowErrs as any)['item_number'];
}
if (Object.keys(rowErrs).length > 0) newMap.set(rowIdx, rowErrs);
else newMap.delete(rowIdx);
});
return newMap;
});
}); });
};
const schedule = () => { // Remove stale unique errors for rows no longer duplicated
if (typeof window === 'undefined') { newMap.forEach((rowErrs, rowIdx) => {
runValidation(); const currentValue = (data[rowIdx] as any)?.['item_number'];
return; const shouldRemoveUnique = !rowsWithUnique.has(rowIdx) || currentValue === undefined || currentValue === null || String(currentValue) === '';
} if (shouldRemoveUnique && (rowErrs as any)['item_number']) {
const filtered = (rowErrs as any)['item_number'].filter((e: any) => e.type !== ErrorType.Unique);
if (filtered.length > 0) (rowErrs as any)['item_number'] = filtered;
else delete (rowErrs as any)['item_number'];
}
// If value now present, also clear any lingering Required error for this field
if (currentValue !== undefined && currentValue !== null && String(currentValue) !== '' && (rowErrs as any)['item_number']) {
const nonRequired = (rowErrs as any)['item_number'].filter((e: any) => e.type !== ErrorType.Required);
if (nonRequired.length > 0) (rowErrs as any)['item_number'] = nonRequired;
else delete (rowErrs as any)['item_number'];
}
const win = window as Window & typeof globalThis & { if (Object.keys(rowErrs).length > 0) newMap.set(rowIdx, rowErrs);
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number; else newMap.delete(rowIdx);
cancelIdleCallback?: (handle: number) => void; });
};
if (win.requestIdleCallback) { return newMap;
const handle = win.requestIdleCallback(() => { });
pendingItemNumberValidationRef.current = null;
if (cancelled) return;
runValidation();
}, { timeout: 250 });
pendingItemNumberValidationRef.current = {
cancel: () => win.cancelIdleCallback?.(handle),
};
return;
}
const timeoutId = window.setTimeout(() => {
pendingItemNumberValidationRef.current = null;
if (cancelled) return;
runValidation();
}, 16);
pendingItemNumberValidationRef.current = {
cancel: () => window.clearTimeout(timeoutId),
};
};
schedule();
return () => {
cancelled = true;
pendingItemNumberValidationRef.current?.cancel();
pendingItemNumberValidationRef.current = null;
};
}, [data, validateUniqueField, setValidationErrors]); }, [data, validateUniqueField, setValidationErrors]);
// Update fields with latest options // Update fields with latest options

View File

@@ -8,6 +8,7 @@ import { motion } from "framer-motion";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import config from "@/config"; import config from "@/config";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import type { DataValue, FieldType, Result } from "@/components/product-import/types";
// Define base fields without dynamic options // Define base fields without dynamic options
const BASE_IMPORT_FIELDS = [ const BASE_IMPORT_FIELDS = [
{ {
@@ -343,7 +344,11 @@ const BASE_IMPORT_FIELDS = [
export function Import() { export function Import() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [importedData, setImportedData] = useState<any[] | null>(null); type ImportFieldKey = (typeof BASE_IMPORT_FIELDS)[number]["key"];
type NormalizedProduct = Record<ImportFieldKey | "product_images", string | string[] | boolean | null>;
type ImportResult = Result<string> & { all?: Result<string>["validData"] };
const [importedData, setImportedData] = useState<NormalizedProduct[] | null>(null);
const [selectedCompany, setSelectedCompany] = useState<string | null>(null); const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
const [selectedLine, setSelectedLine] = useState<string | null>(null); const [selectedLine, setSelectedLine] = useState<string | null>(null);
const [startFromScratch, setStartFromScratch] = useState(false); const [startFromScratch, setStartFromScratch] = useState(false);
@@ -390,7 +395,6 @@ export function Import() {
// Handle field value changes // Handle field value changes
const handleFieldChange = (field: string, value: any) => { const handleFieldChange = (field: string, value: any) => {
console.log('Field change:', field, value);
if (field === "company") { if (field === "company") {
setSelectedCompany(value); setSelectedCompany(value);
setSelectedLine(null); // Reset line when company changes setSelectedLine(null); // Reset line when company changes
@@ -412,7 +416,6 @@ export function Import() {
options: fieldOptions.companies || [], options: fieldOptions.companies || [],
}, },
onChange: (value: string) => { onChange: (value: string) => {
console.log('Company selected:', value);
handleFieldChange("company", value); handleFieldChange("company", value);
}, },
}; };
@@ -424,7 +427,6 @@ export function Import() {
options: productLines || [], options: productLines || [],
}, },
onChange: (value: string) => { onChange: (value: string) => {
console.log('Line selected:', value);
handleFieldChange("line", value); handleFieldChange("line", value);
}, },
disabled: !selectedCompany, disabled: !selectedCompany,
@@ -450,7 +452,7 @@ export function Import() {
return { return {
...field, ...field,
fieldType: { fieldType: {
type: "multi-select" as const, type: "select" as const,
options: fieldOptions.taxCategories || [], options: fieldOptions.taxCategories || [],
}, },
}; };
@@ -507,11 +509,122 @@ export function Import() {
} }
}); });
const handleData = async (data: any, file: File) => { const getDefaultValue = (fieldType: FieldType): string | string[] | boolean | null => {
switch (fieldType.type) {
case "multi-select":
case "multi-input":
return [];
case "checkbox":
return false;
default:
return null;
}
};
const expandScientificNotation = (input: string): string => {
const trimmed = input.trim();
const match = trimmed.match(/^(-?\d+)(?:\.(\d+))?[eE]\+?(\d+)$/);
if (!match) {
return trimmed;
}
const [, integerPart, fractionPart = "", exponentRaw] = match;
const exponent = Number.parseInt(exponentRaw, 10);
const digits = `${integerPart}${fractionPart}`;
const decimalShift = exponent - fractionPart.length;
if (decimalShift >= 0) {
return `${digits}${"0".repeat(decimalShift)}`;
}
const splitIndex = digits.length + decimalShift;
if (splitIndex <= 0) {
const sign = digits.startsWith("-") ? "-" : "";
const unsignedDigits = sign ? digits.slice(1) : digits;
return `${sign}0.${"0".repeat(Math.abs(splitIndex))}${unsignedDigits}`;
}
const sign = digits.startsWith("-") ? "-" : "";
const unsignedDigits = sign ? digits.slice(1) : digits;
return `${sign}${unsignedDigits.slice(0, splitIndex)}.${unsignedDigits.slice(splitIndex)}`;
};
const normalizeUpcValue = (value: string): string => {
const expanded = expandScientificNotation(value);
const digitsOnly = expanded.replace(/[^0-9]/g, "");
return digitsOnly || expanded;
};
const normalizeValue = (
key: ImportFieldKey,
value: DataValue,
fieldType: FieldType
): string | string[] | boolean | null => {
if (value === undefined || value === null || value === "") {
return getDefaultValue(fieldType);
}
if (fieldType.type === "multi-select" || fieldType.type === "multi-input") {
if (Array.isArray(value)) {
return value.length ? value : [];
}
if (typeof value === "string") {
const separator = fieldType.separator ?? ",";
return value
.split(separator)
.map((part) => part.trim())
.filter(Boolean);
}
return [];
}
if (Array.isArray(value)) {
return value.length ? value : [];
}
if (typeof value === "boolean") {
return value;
}
const stringValue = String(value);
if (key === "upc") {
return normalizeUpcValue(stringValue);
}
return stringValue;
};
const handleData = async (data: ImportResult, _file: File) => {
try { try {
console.log("Imported Data:", data); const rows = (data.all?.length ? data.all : data.validData) ?? [];
console.log("File:", file); const formattedRows: NormalizedProduct[] = rows.map((row) => {
setImportedData(data); const baseValues = importFields.reduce((acc, field) => {
const rawRow = row as Record<string, DataValue>;
const fieldKey = field.key as ImportFieldKey;
acc[fieldKey] = normalizeValue(fieldKey, rawRow[field.key], field.fieldType);
return acc;
}, {} as Record<ImportFieldKey, string | string[] | boolean | null>);
const rawProductImages = (row as Record<string, unknown>).product_images;
let normalizedProductImages: string | string[] | boolean | null = null;
if (Array.isArray(rawProductImages)) {
normalizedProductImages = rawProductImages;
} else if (typeof rawProductImages === "string" && rawProductImages.trim().length > 0) {
normalizedProductImages = rawProductImages;
}
return {
...baseValues,
product_images: normalizedProductImages,
} as NormalizedProduct;
});
setImportedData(formattedRows);
setIsOpen(false); setIsOpen(false);
toast.success("Data imported successfully"); toast.success("Data imported successfully");
} catch (error) { } catch (error) {
@@ -587,4 +700,4 @@ export function Import() {
/> />
</motion.div> </motion.div>
); );
} }

File diff suppressed because one or more lines are too long