Compare commits
3 Commits
d8b39979cd
...
138251cf86
| Author | SHA1 | Date | |
|---|---|---|---|
| 138251cf86 | |||
| 24aee1db90 | |||
| 2fe7fd5b2f |
@@ -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[] = [];
|
||||||
|
|
||||||
|
if (metrics.margin) {
|
||||||
|
values.push(
|
||||||
|
...chartData
|
||||||
.map((point) => point.margin)
|
.map((point) => point.margin)
|
||||||
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
.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,15 +1163,6 @@ 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>
|
||||||
@@ -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>
|
||||||
@@ -1289,6 +1343,7 @@ const FinancialOverview = () => {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -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 = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function FinancialStatGrid({
|
type FinancialStatCardConfig = {
|
||||||
cards,
|
|
||||||
}: {
|
|
||||||
cards: Array<{
|
|
||||||
key: string;
|
key: string;
|
||||||
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FinancialStatGrid({
|
||||||
|
cards,
|
||||||
|
}: {
|
||||||
|
cards: FinancialStatCardConfig[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
<TooltipProvider delayDuration={150}>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<FinancialStatCard key={card.key} title={card.title} value={card.value} description={card.description} trend={card.trend} accentClass={card.accentClass} />
|
<FinancialStatCard
|
||||||
|
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>
|
</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 ? (
|
||||||
</div>
|
<span className="relative block">
|
||||||
{description && (
|
<span className="invisible">0</span>
|
||||||
<div className="text-sm text-muted-foreground">{description}</div>
|
<Skeleton className="absolute inset-0 bg-muted rounded-sm" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
value
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
{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>
|
||||||
|
) : 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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,8 +466,13 @@ const ValidationCell = React.memo(({
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className={`flex items-center justify-center gap-2 border ${hasError || isRequiredButEmpty ? 'border-red-500' : 'border-input'} rounded-md px-2 py-2`}>
|
||||||
|
<Skeleton className="w-full h-4" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`relative truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}
|
className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isSourceCell ? '#dbeafe' :
|
backgroundColor: isSourceCell ? '#dbeafe' :
|
||||||
isSelectedTarget ? '#bfdbfe' :
|
isSelectedTarget ? '#bfdbfe' :
|
||||||
@@ -492,12 +492,9 @@ const ValidationCell = React.memo(({
|
|||||||
fieldKey={fieldKey}
|
fieldKey={fieldKey}
|
||||||
onStartEdit={handleStartEdit}
|
onStartEdit={handleStartEdit}
|
||||||
onEndEdit={handleEndEdit}
|
onEndEdit={handleEndEdit}
|
||||||
isValidating={isLoading}
|
|
||||||
/>
|
/>
|
||||||
{isLoading && (
|
|
||||||
<span className="pointer-events-none absolute right-2 top-2 h-2 w-2 rounded-full bg-muted-foreground animate-pulse" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -402,9 +393,6 @@ const ValidationTable = <T extends string>({
|
|||||||
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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -72,8 +68,6 @@ const InputCell = <T extends string>({
|
|||||||
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);
|
||||||
|
|
||||||
@@ -88,28 +82,22 @@ const InputCell = <T extends string>({
|
|||||||
setEditValue(newValue);
|
setEditValue(newValue);
|
||||||
}, [isPrice]);
|
}, [isPrice]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Get the display value - use parent value directly
|
||||||
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
|
|
||||||
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";
|
||||||
|
|||||||
@@ -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("")
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,255 +99,219 @@ 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];
|
||||||
|
|
||||||
updateValidatingCell(rowIndex, specificField, true);
|
// Use state setter instead of direct mutation
|
||||||
setValidationErrors((prev) => {
|
setValidationErrors((prev) => {
|
||||||
const existingErrors = prev.get(rowIndex) || {};
|
let newErrors = new Map(prev);
|
||||||
const newRowErrors = { ...existingErrors };
|
const existingErrors = { ...(newErrors.get(rowIndex) || {}) };
|
||||||
let rowChanged = false;
|
|
||||||
|
|
||||||
const isRequired = field.validations?.some((v) => v.rule === 'required');
|
// Quick check for required fields - this prevents flashing errors
|
||||||
const valueIsEmpty =
|
const isRequired = field.validations?.some(
|
||||||
|
(v) => v.rule === "required"
|
||||||
|
);
|
||||||
|
const isEmpty =
|
||||||
value === undefined ||
|
value === undefined ||
|
||||||
value === null ||
|
value === null ||
|
||||||
value === '' ||
|
value === "" ||
|
||||||
(Array.isArray(value) && value.length === 0) ||
|
(Array.isArray(value) && value.length === 0) ||
|
||||||
(typeof value === 'object' && value !== null && Object.keys(value).length === 0);
|
(typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
if (isRequired && !valueIsEmpty && newRowErrors[specificField]) {
|
Object.keys(value).length === 0);
|
||||||
const nonRequiredErrors = newRowErrors[specificField].filter((e) => e.type !== ErrorType.Required);
|
|
||||||
|
|
||||||
|
// For non-empty values, remove required errors immediately
|
||||||
|
if (isRequired && !isEmpty && existingErrors[specificField]) {
|
||||||
|
const nonRequiredErrors = existingErrors[specificField].filter(
|
||||||
|
(e) => e.type !== ErrorType.Required
|
||||||
|
);
|
||||||
if (nonRequiredErrors.length === 0) {
|
if (nonRequiredErrors.length === 0) {
|
||||||
rowChanged = true;
|
// If no other errors, remove the field entirely from errors
|
||||||
delete newRowErrors[specificField];
|
delete existingErrors[specificField];
|
||||||
} else if (nonRequiredErrors.length !== newRowErrors[specificField].length) {
|
} else {
|
||||||
rowChanged = true;
|
existingErrors[specificField] = nonRequiredErrors;
|
||||||
newRowErrors[specificField] = nonRequiredErrors;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run full validation for the field
|
||||||
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
|
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
|
||||||
|
|
||||||
|
// Update validation errors for this field
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
const existing = newRowErrors[specificField] || [];
|
existingErrors[specificField] = errors;
|
||||||
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[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];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update validation errors map
|
||||||
|
if (Object.keys(existingErrors).length > 0) {
|
||||||
|
newErrors.set(rowIndex, existingErrors);
|
||||||
|
} else {
|
||||||
|
newErrors.delete(rowIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If field is uniqueness-constrained, also re-validate uniqueness for the column
|
||||||
if (uniquenessFieldKeys.has(specificField)) {
|
if (uniquenessFieldKeys.has(specificField)) {
|
||||||
scheduleUniqueValidation([specificField]);
|
const dataForCalc = data; // latest data
|
||||||
return rowChanged ? resultMap : prev;
|
newErrors = mergeUniqueErrorsForFields(newErrors, dataForCalc, [specificField]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return rowChanged ? resultMap : prev;
|
return newErrors;
|
||||||
});
|
});
|
||||||
updateValidatingCell(rowIndex, specificField, false);
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Validate all fields in the row
|
||||||
setValidationErrors((prev) => {
|
setValidationErrors((prev) => {
|
||||||
|
const newErrors = new Map(prev);
|
||||||
const rowErrors: Record<string, ValidationError[]> = {};
|
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) {
|
||||||
|
newData[rowIndex] = updatedRow;
|
||||||
}
|
}
|
||||||
dataRef.current = nextData;
|
return newData;
|
||||||
|
|
||||||
startTransition(() => {
|
|
||||||
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
|
||||||
|
// to prevent intermediate rendering that causes error icon flashing
|
||||||
setValidationErrors((prev) => {
|
setValidationErrors((prev) => {
|
||||||
const existingErrors = prev.get(rowIndex) || {};
|
// Start with previous errors
|
||||||
|
let newMap = new Map(prev);
|
||||||
|
const existingErrors = newMap.get(rowIndex) || {};
|
||||||
const newRowErrors = { ...existingErrors };
|
const newRowErrors = { ...existingErrors };
|
||||||
let rowChanged = false;
|
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now run full validation for the field (except for required which we already handled)
|
||||||
|
const errors = validateFieldFromHook(
|
||||||
|
processedValue,
|
||||||
|
field as unknown as Field<T>
|
||||||
|
).filter((e) => e.type !== ErrorType.Required || isEmpty);
|
||||||
|
|
||||||
|
// Update with new validation results
|
||||||
|
if (errors.length > 0) {
|
||||||
|
newRowErrors[key as string] = errors;
|
||||||
|
} else {
|
||||||
|
// Clear any existing errors for this field
|
||||||
|
delete newRowErrors[key as string];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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[] = [];
|
const fieldsToCheck: string[] = [];
|
||||||
if (uniquenessFieldKeys.has(String(key))) fieldsToCheck.push(String(key));
|
if (uniquenessFieldKeys.has(String(key))) fieldsToCheck.push(String(key));
|
||||||
if (key === ('upc' as T) || key === ('barcode' as T) || key === ('supplier' as T)) {
|
if (key === ('upc' as T) || key === ('barcode' as T) || key === ('supplier' as T)) {
|
||||||
@@ -426,116 +319,141 @@ export const useRowOperations = <T extends string>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fieldsToCheck.length > 0) {
|
if (fieldsToCheck.length > 0) {
|
||||||
scheduleUniqueValidation(fieldsToCheck);
|
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 rowChanged ? resultMap : prev;
|
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
|
||||||
|
setData((prevData) => {
|
||||||
|
const newData = [...prevData];
|
||||||
|
const idx = newData.findIndex((item) => item.__index === rowId);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
nextData[idx] = {
|
newData[idx] = {
|
||||||
...nextData[idx],
|
...newData[idx],
|
||||||
line: undefined,
|
line: undefined,
|
||||||
subline: undefined,
|
subline: undefined,
|
||||||
};
|
};
|
||||||
dataRef.current = nextData;
|
}
|
||||||
startTransition(() => {
|
return newData;
|
||||||
setData(() => nextData);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
|
setData((prevData) => {
|
||||||
|
const newData = [...prevData];
|
||||||
|
const idx = newData.findIndex((item) => item.__index === rowId);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
nextData[idx] = {
|
newData[idx] = {
|
||||||
...nextData[idx],
|
...newData[idx],
|
||||||
subline: undefined,
|
subline: undefined,
|
||||||
};
|
};
|
||||||
dataRef.current = nextData;
|
}
|
||||||
startTransition(() => {
|
return newData;
|
||||||
setData(() => nextData);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}, 5); // Reduced delay for faster secondary effects
|
||||||
}, 5);
|
|
||||||
},
|
},
|
||||||
[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 {
|
||||||
|
|||||||
@@ -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,63 +306,148 @@ 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;
|
||||||
|
|
||||||
|
const runCompleteValidation = async () => {
|
||||||
if (!data || data.length === 0) return;
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
|
console.log("Running complete validation...");
|
||||||
|
|
||||||
|
// Get required fields
|
||||||
const requiredFields = fields.filter((field) =>
|
const requiredFields = fields.filter((field) =>
|
||||||
field.validations?.some((v) => v.rule === "required")
|
field.validations?.some((v) => v.rule === "required")
|
||||||
);
|
);
|
||||||
|
console.log(`Found ${requiredFields.length} required fields`);
|
||||||
|
|
||||||
|
// Get fields that have regex validation
|
||||||
const regexFields = fields.filter((field) =>
|
const regexFields = fields.filter((field) =>
|
||||||
field.validations?.some((v) => v.rule === "regex")
|
field.validations?.some((v) => v.rule === "regex")
|
||||||
);
|
);
|
||||||
|
console.log(`Found ${regexFields.length} fields with regex validation`);
|
||||||
|
|
||||||
const validationErrorsTemp = new Map<number, Record<string, any[]>>();
|
// Get fields that need uniqueness validation
|
||||||
const mutatedRows: Array<[number, RowData<T>]> = [];
|
const uniqueFields = fields.filter((field) =>
|
||||||
|
field.validations?.some((v) => v.rule === "unique")
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Found ${uniqueFields.length} fields requiring uniqueness validation`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dynamic batch size based on dataset size
|
||||||
|
const BATCH_SIZE = data.length <= 50 ? data.length : 25; // Process all at once for small datasets
|
||||||
const totalRows = data.length;
|
const totalRows = data.length;
|
||||||
let currentIndex = 0;
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const cleanupCallbacks = new Set<() => void>();
|
// Initialize new data for any modifications
|
||||||
|
const newData = [...data];
|
||||||
|
|
||||||
const processRow = (rowIndex: number) => {
|
// 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];
|
const row = data[rowIndex];
|
||||||
if (!row) return;
|
|
||||||
|
|
||||||
const rowErrors: Record<string, any[]> = {};
|
// 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;
|
let hasErrors = false;
|
||||||
|
|
||||||
|
// Check if price fields need formatting
|
||||||
const rowAsRecord = row as Record<string, any>;
|
const rowAsRecord = row as Record<string, any>;
|
||||||
let updatedRow: Record<string, any> | null = null;
|
let mSrpNeedsProcessing = false;
|
||||||
|
let costEachNeedsProcessing = false;
|
||||||
|
|
||||||
const ensureUpdatedRow = () => {
|
if (
|
||||||
if (!updatedRow) {
|
rowAsRecord.msrp &&
|
||||||
updatedRow = { ...rowAsRecord };
|
typeof rowAsRecord.msrp === "string" &&
|
||||||
|
(rowAsRecord.msrp.includes("$") ||
|
||||||
|
rowAsRecord.msrp.includes(","))
|
||||||
|
) {
|
||||||
|
mSrpNeedsProcessing = true;
|
||||||
}
|
}
|
||||||
return updatedRow;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof rowAsRecord.msrp === "string" && /[$,]/.test(rowAsRecord.msrp)) {
|
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 msrpValue = rowAsRecord.msrp.replace(/[$,]/g, "");
|
||||||
const numValue = parseFloat(msrpValue);
|
const numValue = parseFloat(msrpValue);
|
||||||
ensureUpdatedRow().msrp = Number.isNaN(numValue) ? msrpValue : numValue.toFixed(2);
|
cleanedRow.msrp = !isNaN(numValue)
|
||||||
|
? numValue.toFixed(2)
|
||||||
|
: msrpValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof rowAsRecord.cost_each === "string" && /[$,]/.test(rowAsRecord.cost_each)) {
|
if (costEachNeedsProcessing) {
|
||||||
const costValue = rowAsRecord.cost_each.replace(/[$,]/g, "");
|
const costValue = rowAsRecord.cost_each.replace(/[$,]/g, "");
|
||||||
const numValue = parseFloat(costValue);
|
const numValue = parseFloat(costValue);
|
||||||
ensureUpdatedRow().cost_each = Number.isNaN(numValue) ? costValue : numValue.toFixed(2);
|
cleanedRow.cost_each = !isNaN(numValue)
|
||||||
|
? numValue.toFixed(2)
|
||||||
|
: costValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newData[rowIndex] = cleanedRow as RowData<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
for (const field of requiredFields) {
|
for (const field of requiredFields) {
|
||||||
const key = String(field.key);
|
const key = String(field.key);
|
||||||
const value = rowAsRecord[key];
|
const value = row[key as keyof typeof row];
|
||||||
|
|
||||||
if (isValueEmpty(value)) {
|
// Skip non-required empty fields
|
||||||
rowErrors[key] = [
|
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:
|
message:
|
||||||
field.validations?.find((v) => v.rule === "required")?.errorMessage ||
|
field.validations?.find((v) => v.rule === "required")
|
||||||
"This field is required",
|
?.errorMessage || "This field is required",
|
||||||
level: "error",
|
level: "error",
|
||||||
source: "row",
|
source: "row",
|
||||||
type: "required",
|
type: "required",
|
||||||
@@ -374,18 +457,30 @@ export const useValidationState = <T extends string>({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate regex fields - even if they have data
|
||||||
for (const field of regexFields) {
|
for (const field of regexFields) {
|
||||||
const key = String(field.key);
|
const key = String(field.key);
|
||||||
const value = rowAsRecord[key];
|
const value = row[key as keyof typeof row];
|
||||||
if (value === undefined || value === null || value === "") continue;
|
|
||||||
|
|
||||||
const regexValidation = field.validations?.find((v) => v.rule === "regex");
|
// Skip empty values as they're handled by required validation
|
||||||
if (!regexValidation) continue;
|
if (value === undefined || value === null || value === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find regex validation
|
||||||
|
const regexValidation = field.validations?.find(
|
||||||
|
(v) => v.rule === "regex"
|
||||||
|
);
|
||||||
|
if (regexValidation) {
|
||||||
try {
|
try {
|
||||||
const regex = new RegExp(regexValidation.value, regexValidation.flags);
|
// Check if value matches regex
|
||||||
|
const regex = new RegExp(
|
||||||
|
regexValidation.value,
|
||||||
|
regexValidation.flags
|
||||||
|
);
|
||||||
if (!regex.test(String(value))) {
|
if (!regex.test(String(value))) {
|
||||||
rowErrors[key] = [
|
// Add regex validation error
|
||||||
|
fieldErrors[key] = [
|
||||||
{
|
{
|
||||||
message: regexValidation.errorMessage,
|
message: regexValidation.errorMessage,
|
||||||
level: regexValidation.level || "error",
|
level: regexValidation.level || "error",
|
||||||
@@ -399,143 +494,84 @@ export const useValidationState = <T extends string>({
|
|||||||
console.error("Invalid regex in validation:", error);
|
console.error("Invalid regex in validation:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updatedRow) {
|
|
||||||
mutatedRows.push([rowIndex, updatedRow as RowData<T>]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update validation errors for this row
|
||||||
if (hasErrors) {
|
if (hasErrors) {
|
||||||
validationErrorsTemp.set(rowIndex, rowErrors);
|
validationErrorsTemp.set(rowIndex, fieldErrors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all row validations to complete
|
||||||
|
await Promise.all(batchPromises);
|
||||||
};
|
};
|
||||||
|
|
||||||
const finalize = () => {
|
const processAllBatches = async () => {
|
||||||
if (cancelled) return;
|
for (let batch = 0; batch < totalBatches; batch++) {
|
||||||
|
currentBatch = batch;
|
||||||
|
await processBatch();
|
||||||
|
|
||||||
startTransition(() => {
|
// Yield to UI thread more frequently for large datasets
|
||||||
setValidationErrors(new Map(validationErrorsTemp));
|
if (batch % 2 === 1 || totalRows > 500) {
|
||||||
});
|
await new Promise((resolve) => setTimeout(resolve, totalRows > 1000 ? 10 : 5));
|
||||||
|
}
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All batches complete
|
||||||
|
console.log("All initial validation batches complete");
|
||||||
|
|
||||||
|
// Apply collected validation errors all at once
|
||||||
|
setValidationErrors(validationErrorsTemp);
|
||||||
|
|
||||||
|
// Apply any data changes (like price formatting)
|
||||||
|
if (JSON.stringify(data) !== JSON.stringify(newData)) {
|
||||||
|
setData(newData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run uniqueness validation after the basic validation
|
||||||
validateUniqueItemNumbers();
|
validateUniqueItemNumbers();
|
||||||
|
|
||||||
|
// Mark that initial validation is done
|
||||||
initialValidationDoneRef.current = true;
|
initialValidationDoneRef.current = true;
|
||||||
|
|
||||||
|
console.log("Initial validation complete");
|
||||||
};
|
};
|
||||||
|
|
||||||
const runChunk = (deadline?: IdleDeadline) => {
|
// Start the validation process
|
||||||
if (cancelled) return;
|
processAllBatches();
|
||||||
|
|
||||||
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);
|
|
||||||
currentIndex += 1;
|
|
||||||
iterations += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentIndex >= totalRows) {
|
|
||||||
finalize();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleNext();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleNext = () => {
|
// Run the complete validation
|
||||||
if (cancelled) return;
|
runCompleteValidation();
|
||||||
|
|
||||||
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 = () => {};
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
|
||||||
cleanupCallbacks.delete(cancel);
|
|
||||||
runChunk();
|
|
||||||
}, 16);
|
|
||||||
cancel = () => window.clearTimeout(timeoutId);
|
|
||||||
cleanupCallbacks.add(cancel);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => runChunk(), 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
scheduleNext();
|
|
||||||
|
|
||||||
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');
|
||||||
let cancelled = false;
|
|
||||||
const currentData = data;
|
|
||||||
|
|
||||||
const runValidation = () => {
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
const uniqueMap = validateUniqueField(currentData, 'item_number');
|
|
||||||
const rowsWithUnique = new Set<number>();
|
const rowsWithUnique = new Set<number>();
|
||||||
uniqueMap.forEach((_, idx) => rowsWithUnique.add(idx));
|
uniqueMap.forEach((_, idx) => rowsWithUnique.add(idx));
|
||||||
|
|
||||||
startTransition(() => {
|
|
||||||
setValidationErrors((prev) => {
|
setValidationErrors((prev) => {
|
||||||
const newMap = new Map(prev);
|
const newMap = new Map(prev);
|
||||||
|
|
||||||
|
// Apply unique errors
|
||||||
uniqueMap.forEach((errorsForRow, rowIdx) => {
|
uniqueMap.forEach((errorsForRow, rowIdx) => {
|
||||||
const existing = { ...(newMap.get(rowIdx) || {}) } as Record<string, any[]>;
|
const existing = { ...(newMap.get(rowIdx) || {}) } as Record<string, any[]>;
|
||||||
const info = (errorsForRow as any)['item_number'];
|
const info = (errorsForRow as any)['item_number'];
|
||||||
const currentValue = (currentData[rowIdx] as any)?.['item_number'];
|
const currentValue = (data[rowIdx] as any)?.['item_number'];
|
||||||
|
// Only apply uniqueness error when the value is non-empty
|
||||||
if (info && currentValue !== undefined && currentValue !== null && String(currentValue) !== '') {
|
if (info && currentValue !== undefined && currentValue !== null && String(currentValue) !== '') {
|
||||||
existing['item_number'] = [
|
existing['item_number'] = [
|
||||||
{
|
{
|
||||||
@@ -546,30 +582,25 @@ export const useValidationState = <T extends string>({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
// If value is now present, make sure to clear any lingering Required error
|
||||||
if (currentValue !== undefined && currentValue !== null && String(currentValue) !== '' && 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);
|
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 ((existing['item_number'] as any[]).length === 0) delete existing['item_number'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(existing).length > 0) newMap.set(rowIdx, existing);
|
if (Object.keys(existing).length > 0) newMap.set(rowIdx, existing);
|
||||||
else newMap.delete(rowIdx);
|
else newMap.delete(rowIdx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Remove stale unique errors for rows no longer duplicated
|
||||||
newMap.forEach((rowErrs, rowIdx) => {
|
newMap.forEach((rowErrs, rowIdx) => {
|
||||||
const currentValue = (currentData[rowIdx] as any)?.['item_number'];
|
const currentValue = (data[rowIdx] as any)?.['item_number'];
|
||||||
const shouldRemoveUnique =
|
const shouldRemoveUnique = !rowsWithUnique.has(rowIdx) || currentValue === undefined || currentValue === null || String(currentValue) === '';
|
||||||
!rowsWithUnique.has(rowIdx) ||
|
|
||||||
currentValue === undefined ||
|
|
||||||
currentValue === null ||
|
|
||||||
String(currentValue) === '';
|
|
||||||
|
|
||||||
if (shouldRemoveUnique && (rowErrs as any)['item_number']) {
|
if (shouldRemoveUnique && (rowErrs as any)['item_number']) {
|
||||||
const filtered = (rowErrs as any)['item_number'].filter((e: any) => e.type !== ErrorType.Unique);
|
const filtered = (rowErrs as any)['item_number'].filter((e: any) => e.type !== ErrorType.Unique);
|
||||||
if (filtered.length > 0) (rowErrs as any)['item_number'] = filtered;
|
if (filtered.length > 0) (rowErrs as any)['item_number'] = filtered;
|
||||||
else delete (rowErrs as any)['item_number'];
|
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']) {
|
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);
|
const nonRequired = (rowErrs as any)['item_number'].filter((e: any) => e.type !== ErrorType.Required);
|
||||||
if (nonRequired.length > 0) (rowErrs as any)['item_number'] = nonRequired;
|
if (nonRequired.length > 0) (rowErrs as any)['item_number'] = nonRequired;
|
||||||
@@ -582,51 +613,6 @@ export const useValidationState = <T extends string>({
|
|||||||
|
|
||||||
return newMap;
|
return newMap;
|
||||||
});
|
});
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const schedule = () => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
runValidation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const win = window as Window & typeof globalThis & {
|
|
||||||
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
|
|
||||||
cancelIdleCallback?: (handle: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (win.requestIdleCallback) {
|
|
||||||
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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user