Rewrite validation step part 1

This commit is contained in:
2026-01-17 19:19:47 -05:00
parent ef50aec33c
commit 262890a7be
40 changed files with 7806 additions and 70 deletions

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
@@ -15,12 +15,19 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import config from "@/config";
import { createProductCategory, type CreateProductCategoryResponse } from "@/services/apiv2";
@@ -85,8 +92,24 @@ export function CreateProductCategoryDialog({
const [isLoadingLines, setIsLoadingLines] = useState(false);
const [lines, setLines] = useState<Option[]>([]);
const [linesCache, setLinesCache] = useState<Record<string, Option[]>>({});
// Popover open states
const [companyOpen, setCompanyOpen] = useState(false);
const [lineOpen, setLineOpen] = useState(false);
const companyOptions = useMemo(() => normalizeOptions(companies), [companies]);
// Get display label for selected company
const selectedCompanyLabel = useMemo(() => {
const company = companyOptions.find((c) => c.value === companyId);
return company?.label || "";
}, [companyOptions, companyId]);
// Get display label for selected line
const selectedLineLabel = useMemo(() => {
const line = lines.find((l) => l.value === lineId);
return line?.label || "";
}, [lines, lineId]);
useEffect(() => {
if (!isOpen) {
@@ -241,56 +264,109 @@ export function CreateProductCategoryDialog({
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Company Select - Searchable */}
<div className="space-y-2">
<Label htmlFor="create-category-company">Company</Label>
<Select
value={companyId}
onValueChange={(value) => {
setCompanyId(value);
setLineId("");
}}
>
<SelectTrigger id="create-category-company">
<SelectValue placeholder="Select a company" />
</SelectTrigger>
<SelectContent>
{companyOptions.map((company) => (
<SelectItem key={company.value} value={company.value}>
{company.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Label>Company</Label>
<Popover open={companyOpen} onOpenChange={setCompanyOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={companyOpen}
className="w-full justify-between font-normal"
>
{selectedCompanyLabel || "Select a company"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search companies..." />
<CommandList>
<CommandEmpty>No company found.</CommandEmpty>
<CommandGroup>
{companyOptions.map((company) => (
<CommandItem
key={company.value}
value={company.label}
onSelect={() => {
setCompanyId(company.value);
setLineId("");
setCompanyOpen(false);
}}
>
{company.label}
{company.value === companyId && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* Line Select - Searchable */}
<div className="space-y-2">
<Label htmlFor="create-category-line">
<Label>
Parent Line <span className="text-muted-foreground">(optional)</span>
</Label>
<Select
value={lineId}
onValueChange={setLineId}
disabled={!companyId || isLoadingLines || !lines.length}
>
<SelectTrigger id="create-category-line">
<SelectValue
placeholder={
!companyId
? "Select a company first"
: isLoadingLines
? "Loading product lines..."
: "Leave empty to create a new line"
}
/>
</SelectTrigger>
<SelectContent>
{lines.map((line) => (
<SelectItem key={line.value} value={line.value}>
{line.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Popover open={lineOpen} onOpenChange={setLineOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={lineOpen}
className="w-full justify-between font-normal"
disabled={!companyId || isLoadingLines}
>
{!companyId
? "Select a company first"
: isLoadingLines
? "Loading product lines..."
: selectedLineLabel || "Leave empty to create a new line"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search lines..." />
<CommandList>
<CommandEmpty>No line found.</CommandEmpty>
<CommandGroup>
{/* Option to clear selection */}
<CommandItem
value="none"
onSelect={() => {
setLineId("");
setLineOpen(false);
}}
>
<span className="text-muted-foreground">None (create new line)</span>
{lineId === "" && <Check className="ml-auto h-4 w-4" />}
</CommandItem>
{lines.map((line) => (
<CommandItem
key={line.value}
value={line.label}
onSelect={() => {
setLineId(line.value);
setLineOpen(false);
}}
>
{line.label}
{line.value === lineId && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{companyId && !isLoadingLines && !lines.length && (
<p className="text-xs text-muted-foreground">
No existing lines found for this company. A new line will be created.

View File

@@ -1288,7 +1288,7 @@ const MatchColumnsStepComponent = <T extends string>({
}, [fields]);
// Fix handleOnContinue - it should be useCallback, not useEffect
const handleOnContinue = useCallback(async () => {
const handleOnContinue = useCallback(async (useNewValidation: boolean = false) => {
setIsLoading(true)
try {
@@ -1325,7 +1325,7 @@ const MatchColumnsStepComponent = <T extends string>({
})
: normalizedData
await onContinue(enhancedData, data, columns, globalSelections)
await onContinue(enhancedData, data, columns, globalSelections, useNewValidation)
} finally {
setIsLoading(false)
}
@@ -1909,20 +1909,28 @@ const MatchColumnsStepComponent = <T extends string>({
<div className="border-t bg-muted px-6 py-4">
<div className="flex items-center justify-between">
{onBack && (
<Button variant="outline" onClick={onBack}>
{translations.matchColumnsStep.backButtonTitle}
</Button>
)}
<Button
className="ml-auto"
disabled={isLoading}
onClick={handleOnContinue}
>
{translations.matchColumnsStep.nextButtonTitle}
</Button>
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
disabled={isLoading}
onClick={() => handleOnContinue(false)}
>
{translations.matchColumnsStep.nextButtonTitle}
</Button>
<Button
disabled={isLoading}
onClick={() => handleOnContinue(true)}
>
{translations.matchColumnsStep.nextButtonTitle} (New Validation)
</Button>
</div>
</div>
</div>
</div>

View File

@@ -3,7 +3,7 @@ import type { RawData } from "../../types"
export type MatchColumnsProps<T extends string> = {
data: RawData[]
headerValues: RawData
onContinue: (data: any[], rawData: RawData[], columns: Columns<T>, globalSelections?: GlobalSelections) => void
onContinue: (data: any[], rawData: RawData[], columns: Columns<T>, globalSelections?: GlobalSelections, useNewValidation?: boolean) => void
onBack?: () => void
initialGlobalSelections?: GlobalSelections
}

View File

@@ -5,6 +5,7 @@ import { SelectHeaderStep } from "./SelectHeaderStep/SelectHeaderStep"
import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
import { mapWorkbook } from "../utils/mapWorkbook"
import { ValidationStepNew } from "./ValidationStepNew"
import { ValidationStep } from "./ValidationStep"
import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep"
import { MatchColumnsStep } from "./MatchColumnsStep/MatchColumnsStep"
import type { GlobalSelections } from "./MatchColumnsStep/types"
@@ -21,6 +22,7 @@ export enum StepType {
selectHeader = "selectHeader",
matchColumns = "matchColumns",
validateData = "validateData",
validateDataNew = "validateDataNew",
imageUpload = "imageUpload",
}
@@ -48,6 +50,12 @@ export type StepState =
globalSelections?: GlobalSelections
isFromScratch?: boolean
}
| {
type: StepType.validateDataNew
data: any[]
globalSelections?: GlobalSelections
isFromScratch?: boolean
}
| {
type: StepType.imageUpload
data: any[]
@@ -87,7 +95,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
// Keep track of global selections across steps
const [persistedGlobalSelections, setPersistedGlobalSelections] = useState<GlobalSelections | undefined>(
state.type === StepType.validateData || state.type === StepType.matchColumns
state.type === StepType.validateData || state.type === StepType.validateDataNew || state.type === StepType.matchColumns
? state.globalSelections
: undefined
)
@@ -179,13 +187,13 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
data={state.data}
headerValues={state.headerValues}
initialGlobalSelections={persistedGlobalSelections}
onContinue={async (values, rawData, columns, globalSelections) => {
onContinue={async (values, rawData, columns, globalSelections, useNewValidation) => {
try {
const data = await matchColumnsStepHook(values, rawData, columns)
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook)
// Apply global selections to each row of data if they exist
const dataWithGlobalSelections = globalSelections
const dataWithGlobalSelections = globalSelections
? dataWithMeta.map((row: Data<string> & { __index?: string }) => {
const newRow = { ...row } as any;
if (globalSelections.supplier) newRow.supplier = globalSelections.supplier;
@@ -195,10 +203,12 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
return newRow;
})
: dataWithMeta;
setPersistedGlobalSelections(globalSelections)
// Route to new or old validation step based on user choice
onNext({
type: StepType.validateData,
type: useNewValidation ? StepType.validateDataNew : StepType.validateData,
data: dataWithGlobalSelections,
globalSelections,
})
@@ -238,6 +248,35 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
isFromScratch={state.isFromScratch}
/>
)
case StepType.validateDataNew:
// New Zustand-based ValidationStep component
return (
<ValidationStep
initialData={state.data}
file={uploadedFile || new File([], "empty.xlsx")}
onBack={() => {
// If we started from scratch, we need to go back to the upload step
if (state.isFromScratch) {
onNext({
type: StepType.upload
});
} else if (onBack) {
// Use the provided onBack function
onBack();
}
}}
onNext={(validatedData: any[]) => {
// Go to image upload step with the validated data
onNext({
type: StepType.imageUpload,
data: validatedData,
file: uploadedFile || new File([], "empty.xlsx"),
globalSelections: state.globalSelections
});
}}
isFromScratch={state.isFromScratch}
/>
)
case StepType.imageUpload:
return (
<ImageUploadStep

View File

@@ -0,0 +1,51 @@
/**
* CopyDownBanner Component
*
* Shows instruction banner when copy-down mode is active.
* Memoized with minimal subscriptions for performance.
*/
import { memo } from 'react';
import { Button } from '@/components/ui/button';
import { X } from 'lucide-react';
import { useValidationStore } from '../store/validationStore';
import { useIsCopyDownActive } from '../store/selectors';
/**
* Copy-down instruction banner
*
* PERFORMANCE: Only subscribes to copyDownMode.isActive boolean.
* Uses getState() for the cancel action to avoid additional subscriptions.
*/
export const CopyDownBanner = memo(() => {
const isActive = useIsCopyDownActive();
if (!isActive) return null;
const handleCancel = () => {
useValidationStore.getState().cancelCopyDown();
};
return (
<div className="sticky top-0 z-30 h-0 overflow-visible pointer-events-none">
<div className="absolute left-1/2 -translate-x-1/2 top-2 pointer-events-auto">
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-xl shadow-lg px-4 py-2.5 flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
Click on the last row you want to copy to
</span>
<Button
variant="ghost"
size="sm"
onClick={handleCancel}
className="h-7 w-7 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
});
CopyDownBanner.displayName = 'CopyDownBanner';

View File

@@ -0,0 +1,197 @@
/**
* FloatingSelectionBar Component
*
* Fixed bottom action bar that appears when rows are selected.
* Provides bulk actions: apply template, save as template, delete.
*
* PERFORMANCE CRITICAL:
* - Only subscribes to selectedRows.size via useSelectedRowCount()
* - Never subscribes to the full rows array or selectedRows Set
* - Uses getState() for action-time data access
*/
import { memo, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { X, Trash2, Save, FileDown } from 'lucide-react';
import { useValidationStore } from '../store/validationStore';
import {
useSelectedRowCount,
useHasSingleRowSelected,
useTemplates,
useTemplatesLoading,
} from '../store/selectors';
import { useTemplateManagement } from '../hooks/useTemplateManagement';
import SearchableTemplateSelect from './SearchableTemplateSelect';
import { toast } from 'sonner';
/**
* Floating selection bar - appears when rows are selected
*
* PERFORMANCE: Only subscribes to:
* - selectedRowCount (number) - minimal subscription
* - hasSingleRowSelected (boolean) - for save template button
* - templates/templatesLoading - rarely changes
*/
export const FloatingSelectionBar = memo(() => {
const selectedCount = useSelectedRowCount();
const hasSingleRow = useHasSingleRowSelected();
const templates = useTemplates();
const templatesLoading = useTemplatesLoading();
const { applyTemplateToSelected, getTemplateDisplayText } = useTemplateManagement();
// Clear selection - uses getState() to avoid subscription
const handleClearSelection = useCallback(() => {
useValidationStore.getState().clearSelection();
}, []);
// Delete selected rows - uses getState() at action time
const handleDeleteSelected = useCallback(() => {
const { rows, selectedRows, deleteRows } = useValidationStore.getState();
// Map selected UUIDs to indices
const indicesToDelete: number[] = [];
rows.forEach((row, index) => {
if (selectedRows.has(row.__index)) {
indicesToDelete.push(index);
}
});
if (indicesToDelete.length === 0) {
toast.error('No rows selected');
return;
}
// Confirm deletion for multiple rows
if (indicesToDelete.length > 1) {
const confirmed = window.confirm(
`Are you sure you want to delete ${indicesToDelete.length} rows?`
);
if (!confirmed) return;
}
deleteRows(indicesToDelete);
toast.success(
indicesToDelete.length === 1
? 'Row deleted'
: `${indicesToDelete.length} rows deleted`
);
}, []);
// Save as template - opens dialog with row data
const handleSaveAsTemplate = useCallback(() => {
const { rows, selectedRows, openTemplateForm } = useValidationStore.getState();
// Find the single selected row
const selectedRow = rows.find((row) => selectedRows.has(row.__index));
if (!selectedRow) {
toast.error('No row selected');
return;
}
// Prepare row data for template form (exclude metadata fields)
const templateData: Record<string, unknown> = {};
const excludeFields = [
'id',
'__index',
'__meta',
'__template',
'__original',
'__corrected',
'__changes',
'__aiSupplemental',
];
for (const [key, value] of Object.entries(selectedRow)) {
if (excludeFields.includes(key)) continue;
templateData[key] = value;
}
openTemplateForm(templateData);
}, []);
// Handle template selection for bulk apply
const handleTemplateChange = useCallback(
(templateId: string) => {
if (!templateId) return;
applyTemplateToSelected(templateId);
},
[applyTemplateToSelected]
);
// Don't render if nothing selected
if (selectedCount === 0) return null;
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-200">
<div className="bg-background border border-border rounded-xl shadow-xl px-4 py-3 flex items-center gap-4">
{/* Selection count badge */}
<div className="flex items-center gap-2">
<div className="bg-primary text-primary-foreground text-sm font-medium px-2.5 py-1 rounded-md">
{selectedCount} selected
</div>
<Button
variant="ghost"
size="sm"
onClick={handleClearSelection}
className="h-8 w-8 p-0"
title="Clear selection"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Divider */}
<div className="h-8 w-px bg-border" />
{/* Apply template to selected */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Apply template:</span>
<SearchableTemplateSelect
templates={templates}
value=""
onValueChange={handleTemplateChange}
getTemplateDisplayText={getTemplateDisplayText}
placeholder="Select template"
triggerClassName="w-[200px]"
disabled={templatesLoading}
/>
</div>
{/* Divider */}
<div className="h-8 w-px bg-border" />
{/* Save as template - only when single row selected */}
{hasSingleRow && (
<>
<Button
variant="outline"
size="sm"
onClick={handleSaveAsTemplate}
className="gap-2"
>
<Save className="h-4 w-4" />
Save as Template
</Button>
{/* Divider */}
<div className="h-8 w-px bg-border" />
</>
)}
{/* Delete selected */}
<Button
variant="destructive"
size="sm"
onClick={handleDeleteSelected}
className="gap-2"
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
</div>
);
});
FloatingSelectionBar.displayName = 'FloatingSelectionBar';

View File

@@ -0,0 +1,55 @@
/**
* InitializingOverlay Component
*
* Displays a loading state while the validation step is initializing.
* Shows the current initialization phase to keep users informed.
*/
import { Loader2 } from 'lucide-react';
import { Progress } from '@/components/ui/progress';
import type { InitPhase } from '../store/types';
interface InitializingOverlayProps {
phase: InitPhase;
message?: string;
}
const phaseMessages: Record<InitPhase, string> = {
idle: 'Preparing...',
'loading-options': 'Loading field options...',
'loading-templates': 'Loading templates...',
'validating-upcs': 'Validating UPC codes...',
'validating-fields': 'Running field validation...',
ready: 'Ready',
};
const phaseProgress: Record<InitPhase, number> = {
idle: 0,
'loading-options': 20,
'loading-templates': 40,
'validating-upcs': 60,
'validating-fields': 80,
ready: 100,
};
export const InitializingOverlay = ({ phase, message }: InitializingOverlayProps) => {
const displayMessage = message || phaseMessages[phase];
const progress = phaseProgress[phase];
return (
<div className="flex flex-col items-center justify-center h-[calc(100vh-9.5rem)] gap-6">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="text-lg font-medium">{displayMessage}</div>
</div>
<div className="w-64">
<Progress value={progress} className="h-2" />
</div>
<div className="text-sm text-muted-foreground">
This may take a moment for large imports
</div>
</div>
);
};

View File

@@ -0,0 +1,337 @@
/**
* SearchableTemplateSelect Component
*
* A template dropdown with brand filtering and search.
* Ported from ValidationStepNew with updated imports for the new store types.
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { Check, ChevronsUpDown } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { Template } from '../store/types';
interface SearchableTemplateSelectProps {
templates: Template[] | undefined;
value: string;
onValueChange: (value: string) => void;
getTemplateDisplayText: (templateId: string | null) => string;
placeholder?: string;
className?: string;
triggerClassName?: string;
defaultBrand?: string;
disabled?: boolean;
}
const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
templates = [],
value,
onValueChange,
getTemplateDisplayText,
placeholder = 'Select template',
className,
triggerClassName,
defaultBrand,
disabled = false,
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
const [open, setOpen] = useState(false);
// Set default brand when component mounts or defaultBrand changes
useEffect(() => {
if (defaultBrand) {
setSelectedBrand(defaultBrand);
}
}, [defaultBrand]);
// Force a re-render when templates change from empty to non-empty
useEffect(() => {
if (templates && templates.length > 0) {
setSearchTerm('');
}
}, [templates]);
// Handle wheel events for scrolling
const handleWheel = (e: React.WheelEvent) => {
const scrollArea = e.currentTarget;
scrollArea.scrollTop += e.deltaY;
};
// Extract unique brands from templates
const brands = useMemo(() => {
try {
if (!Array.isArray(templates) || templates.length === 0) {
return [];
}
const brandSet = new Set<string>();
const brandNames: { id: string; name: string }[] = [];
templates.forEach((template) => {
if (!template?.company) return;
const companyId = template.company;
if (!brandSet.has(companyId)) {
brandSet.add(companyId);
// Try to get the company name from the template display text
try {
const displayText = getTemplateDisplayText(template.id.toString());
const companyName = displayText.split(' - ')[0];
brandNames.push({ id: companyId, name: companyName || companyId });
} catch (err) {
brandNames.push({ id: companyId, name: companyId });
}
}
});
return brandNames.sort((a, b) => a.name.localeCompare(b.name));
} catch (err) {
return [];
}
}, [templates, getTemplateDisplayText]);
// Group templates by company for better organization
const groupedTemplates = useMemo(() => {
try {
if (!Array.isArray(templates) || templates.length === 0) return {};
const groups: Record<string, Template[]> = {};
templates.forEach((template) => {
if (!template?.company) return;
const companyId = template.company;
if (!groups[companyId]) {
groups[companyId] = [];
}
groups[companyId].push(template);
});
return groups;
} catch (err) {
return {};
}
}, [templates]);
// Filter templates based on selected brand and search term
const filteredTemplates = useMemo(() => {
try {
if (!Array.isArray(templates) || templates.length === 0) return [];
// First filter by brand if selected
let brandFiltered = templates;
if (selectedBrand) {
// Check if the selected brand has any templates
const brandTemplates = templates.filter((t) => t?.company === selectedBrand);
// If the selected brand has templates, use them; otherwise, show all templates
brandFiltered = brandTemplates.length > 0 ? brandTemplates : templates;
}
// Then filter by search term if provided
if (!searchTerm.trim()) return brandFiltered;
const lowerSearchTerm = searchTerm.toLowerCase();
return brandFiltered.filter((template) => {
if (!template?.id) return false;
try {
const displayText = getTemplateDisplayText(template.id.toString());
const productType = template.product_type?.toLowerCase() || '';
return displayText.toLowerCase().includes(lowerSearchTerm) || productType.includes(lowerSearchTerm);
} catch (error) {
return false;
}
});
} catch (err) {
return [];
}
}, [templates, selectedBrand, searchTerm, getTemplateDisplayText]);
// Handle errors gracefully
const getDisplayText = useCallback(() => {
try {
if (!value) return placeholder;
const template = templates.find((t) => t.id.toString() === value);
if (!template) return placeholder;
// Get the original display text
const originalText = getTemplateDisplayText(value);
// Check if it has the expected format "Brand - Product Type"
if (originalText.includes(' - ')) {
const [brand, productType] = originalText.split(' - ', 2);
// Reverse the order to "Product Type - Brand"
return `${productType} - ${brand}`;
}
// If it doesn't match the expected format, return the original text
return originalText;
} catch (err) {
console.error('Error getting display text:', err);
return placeholder;
}
}, [getTemplateDisplayText, placeholder, value, templates]);
// Safe render function for CommandItem
const renderCommandItem = useCallback(
(template: Template) => {
if (!template?.id) return null;
try {
const displayText = getTemplateDisplayText(template.id.toString());
return (
<CommandItem
key={template.id}
value={template.id.toString()}
onSelect={(currentValue) => {
try {
onValueChange(currentValue);
setOpen(false);
setSearchTerm('');
} catch (err) {
console.error('Error selecting template:', err);
}
}}
className="flex items-center justify-between"
>
<span>{displayText}</span>
{value === template.id.toString() && <Check className="h-4 w-4 ml-2" />}
</CommandItem>
);
} catch (err) {
console.error('Error rendering template item:', err);
return null;
}
},
[onValueChange, value, getTemplateDisplayText]
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn('w-full justify-between overflow-hidden', triggerClassName)}
>
<span className="truncate overflow-hidden mr-1">{getDisplayText()}</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 flex-none" />
</Button>
</PopoverTrigger>
<PopoverContent className={cn('w-[300px] p-0', className)}>
<Command>
<div className="flex flex-col p-2 gap-2">
{brands.length > 0 && (
<div className="flex items-center gap-2">
<Select
value={selectedBrand || 'all'}
onValueChange={(value) => {
setSelectedBrand(value === 'all' ? null : value);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All Brands" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Brands</SelectItem>
{brands.map((brand) => (
<SelectItem key={brand.id} value={brand.id}>
{brand.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<CommandSeparator />
<div className="flex items-center gap-2">
<CommandInput
placeholder="Search by product type..."
value={searchTerm}
onValueChange={setSearchTerm}
className="h-8 flex-1"
/>
</div>
</div>
<CommandEmpty>
<div className="py-6 text-center">
<p className="text-sm text-muted-foreground">No templates found.</p>
</div>
</CommandEmpty>
<CommandList>
<ScrollArea className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
{!searchTerm ? (
selectedBrand ? (
groupedTemplates[selectedBrand]?.length > 0 ? (
<CommandGroup heading={brands.find((b) => b.id === selectedBrand)?.name || selectedBrand}>
{groupedTemplates[selectedBrand]?.map((template) => renderCommandItem(template))}
</CommandGroup>
) : (
// If selected brand has no templates, show all brands
Object.entries(groupedTemplates).map(([companyId, companyTemplates]) => {
const brand = brands.find((b) => b.id === companyId);
const companyName = brand ? brand.name : companyId;
return (
<CommandGroup key={companyId} heading={companyName}>
{companyTemplates.map((template) => renderCommandItem(template))}
</CommandGroup>
);
})
)
) : (
Object.entries(groupedTemplates).map(([companyId, companyTemplates]) => {
const brand = brands.find((b) => b.id === companyId);
const companyName = brand ? brand.name : companyId;
return (
<CommandGroup key={companyId} heading={companyName}>
{companyTemplates.map((template) => renderCommandItem(template))}
</CommandGroup>
);
})
)
) : (
<CommandGroup>{filteredTemplates.map((template) => renderCommandItem(template))}</CommandGroup>
)}
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
export default SearchableTemplateSelect;

View File

@@ -0,0 +1,161 @@
/**
* ValidationContainer Component
*
* Main orchestrator component for the validation step.
* Coordinates sub-components once initialization is complete.
* Note: Initialization effects are in index.tsx so they run before this mounts.
*/
import { useCallback, useMemo } from 'react';
import { useValidationStore } from '../store/validationStore';
import {
useTotalErrorCount,
useRowsWithErrorsCount,
useIsTemplateFormOpen,
useTemplateFormData,
} from '../store/selectors';
import { ValidationTable } from './ValidationTable';
import { ValidationToolbar } from './ValidationToolbar';
import { ValidationFooter } from './ValidationFooter';
import { FloatingSelectionBar } from './FloatingSelectionBar';
import { useAiValidationFlow } from '../hooks/useAiValidation';
import { useFieldOptions } from '../hooks/useFieldOptions';
import { useTemplateManagement } from '../hooks/useTemplateManagement';
import { AiValidationProgressDialog } from '../dialogs/AiValidationProgress';
import { AiValidationResultsDialog } from '../dialogs/AiValidationResults';
import { AiDebugDialog } from '../dialogs/AiDebugDialog';
import { TemplateForm } from '@/components/templates/TemplateForm';
import type { CleanRowData } from '../store/types';
interface ValidationContainerProps {
onBack?: () => void;
onNext?: (data: CleanRowData[]) => void;
isFromScratch?: boolean;
}
export const ValidationContainer = ({
onBack,
onNext,
isFromScratch: _isFromScratch,
}: ValidationContainerProps) => {
// PERFORMANCE: Only subscribe to row COUNT, not the full rows array
// Subscribing to rows causes re-render on EVERY cell change!
const rowCount = useValidationStore((state) => state.rows.length);
const totalErrorCount = useTotalErrorCount();
const rowsWithErrorsCount = useRowsWithErrorsCount();
// Template form dialog state
const isTemplateFormOpen = useIsTemplateFormOpen();
const templateFormData = useTemplateFormData();
// Store actions
const getCleanedData = useValidationStore((state) => state.getCleanedData);
const closeTemplateForm = useValidationStore((state) => state.closeTemplateForm);
// Hooks
const aiValidation = useAiValidationFlow();
const { data: fieldOptionsData } = useFieldOptions();
const { loadTemplates } = useTemplateManagement();
// Convert field options to TemplateForm format
const templateFormFieldOptions = useMemo(() => {
if (!fieldOptionsData) return null;
return {
companies: fieldOptionsData.companies || [],
artists: fieldOptionsData.artists || [],
sizes: fieldOptionsData.sizes || [], // API returns 'sizes'
themes: fieldOptionsData.themes || [],
categories: fieldOptionsData.categories || [],
colors: fieldOptionsData.colors || [],
suppliers: fieldOptionsData.suppliers || [],
taxCategories: fieldOptionsData.taxCategories || [],
shippingRestrictions: fieldOptionsData.shippingRestrictions || [], // API returns 'shippingRestrictions'
};
}, [fieldOptionsData]);
// Handle template form success - refresh templates
const handleTemplateFormSuccess = useCallback(() => {
loadTemplates();
}, [loadTemplates]);
// Handle proceeding to next step
const handleNext = useCallback(() => {
if (onNext) {
const cleanedData = getCleanedData();
onNext(cleanedData);
}
}, [onNext, getCleanedData]);
// Handle going back
const handleBack = useCallback(() => {
if (onBack) {
onBack();
}
}, [onBack]);
return (
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
{/* Toolbar */}
<ValidationToolbar
rowCount={rowCount}
errorCount={totalErrorCount}
rowsWithErrors={rowsWithErrorsCount}
/>
{/* Main table area */}
<div className="flex-1 overflow-hidden">
<ValidationTable />
</div>
{/* Footer with navigation */}
<ValidationFooter
onBack={handleBack}
onNext={handleNext}
canGoBack={!!onBack}
canProceed={totalErrorCount === 0}
errorCount={totalErrorCount}
rowCount={rowCount}
onAiValidate={aiValidation.validate}
isAiValidating={aiValidation.isValidating}
onShowDebug={aiValidation.showPromptPreview}
/>
{/* Floating selection bar - appears when rows selected */}
<FloatingSelectionBar />
{/* AI Validation dialogs */}
{aiValidation.isValidating && aiValidation.progress && (
<AiValidationProgressDialog
progress={aiValidation.progress}
onCancel={aiValidation.cancel}
/>
)}
{aiValidation.results && !aiValidation.isValidating && (
<AiValidationResultsDialog
results={aiValidation.results}
revertedChanges={aiValidation.revertedChanges}
onRevert={aiValidation.revertChange}
onDismiss={aiValidation.dismissResults}
/>
)}
{/* AI Debug Dialog - for viewing prompt */}
<AiDebugDialog
open={aiValidation.showDebugDialog}
onClose={aiValidation.closePromptPreview}
debugData={aiValidation.debugPrompt}
/>
{/* Template form dialog - for saving row as template */}
<TemplateForm
isOpen={isTemplateFormOpen}
onClose={closeTemplateForm}
onSuccess={handleTemplateFormSuccess}
initialData={templateFormData as Parameters<typeof TemplateForm>[0]['initialData']}
mode="create"
fieldOptions={templateFormFieldOptions}
/>
</div>
);
};

View File

@@ -0,0 +1,104 @@
/**
* ValidationFooter Component
*
* Navigation footer with back/next buttons, AI validate, and summary info.
*/
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight, CheckCircle, Wand2, FileText } from 'lucide-react';
import { Protected } from '@/components/auth/Protected';
interface ValidationFooterProps {
onBack?: () => void;
onNext?: () => void;
canGoBack: boolean;
canProceed: boolean;
errorCount: number;
rowCount: number;
onAiValidate?: () => void;
isAiValidating?: boolean;
onShowDebug?: () => void;
}
export const ValidationFooter = ({
onBack,
onNext,
canGoBack,
canProceed,
errorCount,
rowCount,
onAiValidate,
isAiValidating = false,
onShowDebug,
}: ValidationFooterProps) => {
return (
<div className="flex items-center justify-between border-t bg-muted/50 px-6 py-4">
{/* Back button */}
<div>
{canGoBack && onBack && (
<Button variant="outline" onClick={onBack}>
<ChevronLeft className="h-4 w-4 mr-1" />
Back
</Button>
)}
</div>
{/* Status summary - only show success message when no errors */}
<div className="flex items-center gap-4">
{errorCount === 0 && rowCount > 0 && (
<div className="flex items-center gap-2 text-green-600">
<CheckCircle className="h-4 w-4" />
<span className="text-sm font-medium">
All {rowCount} products validated
</span>
</div>
)}
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
{/* Show Prompt Debug - Admin only */}
{onShowDebug && (
<Protected permission="admin:debug">
<Button
variant="outline"
onClick={onShowDebug}
disabled={isAiValidating}
>
<FileText className="h-4 w-4 mr-1" />
Show Prompt
</Button>
</Protected>
)}
{/* AI Validate */}
{onAiValidate && (
<Button
variant="outline"
onClick={onAiValidate}
disabled={isAiValidating || rowCount === 0}
>
<Wand2 className="h-4 w-4 mr-1" />
{isAiValidating ? 'Validating...' : 'AI Validate'}
</Button>
)}
{/* Next button */}
{onNext && (
<Button
onClick={onNext}
disabled={!canProceed}
title={
!canProceed
? `Fix ${errorCount} validation errors before proceeding`
: 'Continue to image upload'
}
>
Continue to Images
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,914 @@
/**
* ValidationTable Component
*
* Renders the editable data table for product validation.
* Uses TanStack Table for column management and virtualization for performance.
*
* PERFORMANCE ARCHITECTURE:
* - Table subscribes only to: rowCount, fields, filters (NOT selectedRows!)
* - VirtualRow subscribes only to ITS OWN selection status via fine-grained selector
* - CellWrapper subscribes only to its own cell data
* - columns memo does NOT depend on selection state
* - All widths use minWidth + flexShrink:0 to enforce config.ts values
*/
import { useMemo, useRef, useCallback, memo, useState } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Checkbox } from '@/components/ui/checkbox';
import { ArrowDown, Wand2, Loader2 } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import config from '@/config';
import { useValidationStore } from '../store/validationStore';
import {
useFields,
useFilters,
} from '../store/selectors';
// NOTE: We intentionally do NOT import useValidationActions or useProductLines here!
// Those hooks subscribe to global state (rows, errors, caches) which would cause
// ALL cells to re-render when ANY cell changes. Instead, CellWrapper gets store
// actions directly and uses getState() for one-time data access.
import { ErrorSource, ErrorType, type RowData, type ValidationError } from '../store/types';
import type { Field, SelectOption, Validation } from '../../../types';
// Copy-down banner component
import { CopyDownBanner } from './CopyDownBanner';
// Template select
import SearchableTemplateSelect from './SearchableTemplateSelect';
// Cell components
import { InputCell } from './cells/InputCell';
import { SelectCell } from './cells/SelectCell';
import { ComboboxCell } from './cells/ComboboxCell';
import { MultiSelectCell } from './cells/MultiSelectCell';
import { MultilineInput } from './cells/MultilineInput';
// Threshold for switching to ComboboxCell (with search) instead of SelectCell
const COMBOBOX_OPTION_THRESHOLD = 50;
/**
* Get cell component based on field type and option count
*
* PERFORMANCE: For fields with many options (50+), use ComboboxCell which has
* built-in search filtering. This prevents rendering 100+ SelectItem components
* when the dropdown opens, dramatically improving click responsiveness.
*/
const getCellComponent = (field: Field<string>, optionCount: number = 0) => {
// Check for multiline input fields first
if (field.fieldType.type === 'input' && 'multiline' in field.fieldType && field.fieldType.multiline) {
return MultilineInput;
}
switch (field.fieldType.type) {
case 'select':
// Use ComboboxCell for large option lists
if (optionCount >= COMBOBOX_OPTION_THRESHOLD) {
return ComboboxCell;
}
return SelectCell;
case 'multi-select':
return MultiSelectCell;
default:
return InputCell;
}
};
/**
* Row height for virtualization
*/
const ROW_HEIGHT = 40;
const HEADER_HEIGHT = 40;
// Stable empty references to avoid creating new objects in selectors
// CRITICAL: Selectors must return stable references or Zustand triggers infinite re-renders
const EMPTY_ERRORS: ValidationError[] = [];
const EMPTY_OPTIONS: SelectOption[] = [];
const EMPTY_ROW_ERRORS: Record<string, ValidationError[]> = {};
/**
* CellWrapper props - receives data from parent VirtualRow
* NO STORE SUBSCRIPTIONS - purely presentational
*/
interface CellWrapperProps {
field: Field<string>;
rowIndex: number;
value: unknown;
errors: ValidationError[];
isValidating: boolean;
// For line/subline dependent dropdowns - parent values only
company?: unknown;
line?: unknown;
// For UPC generation
supplier?: unknown;
// Copy-down state (from parent to avoid subscriptions in every cell)
isCopyDownActive: boolean;
isCopyDownSource: boolean;
isInCopyDownRange: boolean;
isCopyDownTarget: boolean;
totalRowCount: number;
}
/**
* Memoized cell wrapper - PURE COMPONENT with no store subscriptions
*
* PERFORMANCE: All data comes from props (passed by VirtualRow).
* Cache data for dependent dropdowns is read via getState() to avoid
* cascading re-renders when cache updates.
*/
const CellWrapper = memo(({
field,
rowIndex,
value,
errors,
isValidating,
company,
line,
supplier,
isCopyDownActive,
isCopyDownSource,
isInCopyDownRange,
isCopyDownTarget,
totalRowCount,
}: CellWrapperProps) => {
const [isHovered, setIsHovered] = useState(false);
const [isGeneratingUpc, setIsGeneratingUpc] = useState(false);
const needsCompany = field.key === 'line';
const needsLine = field.key === 'subline';
// Check if cell has a value (for showing copy-down button)
const hasValue = value !== undefined && value !== null && value !== '';
// Show copy-down button when:
// - Cell is hovered
// - Cell has a value
// - Not already in copy-down mode
// - There are rows below this one
const showCopyDownButton = isHovered && hasValue && !isCopyDownActive && rowIndex < totalRowCount - 1;
// UPC Generation logic
const isUpcField = field.key === 'upc';
const upcIsEmpty = isUpcField && !hasValue;
const supplierIdString = supplier !== undefined && supplier !== null
? String(supplier).trim()
: '';
const hasValidSupplier = /^\d+$/.test(supplierIdString);
const showGenerateUpcButton = isHovered && upcIsEmpty && !isValidating && !isCopyDownActive && !isGeneratingUpc;
// Handle starting copy-down
const handleStartCopyDown = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
useValidationStore.getState().startCopyDown(rowIndex, field.key);
}, [rowIndex, field.key]);
// Handle clicking on a target cell to complete copy-down
const handleTargetClick = useCallback(() => {
if (isCopyDownTarget) {
useValidationStore.getState().completeCopyDown(rowIndex);
}
}, [isCopyDownTarget, rowIndex]);
// Handle UPC generation
const handleGenerateUpc = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (!hasValidSupplier) {
toast.error('Select a supplier before generating a UPC');
return;
}
if (isGeneratingUpc) return;
setIsGeneratingUpc(true);
try {
const response = await fetch(`${config.apiUrl}/import/generate-upc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplierId: supplierIdString }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.error || `Request failed (${response.status})`);
}
if (!payload?.success || !payload?.upc) {
throw new Error(payload?.error || 'Unexpected response while generating UPC');
}
// Update the cell value
useValidationStore.getState().updateCell(rowIndex, 'upc', payload.upc);
toast.success('UPC generated');
} catch (error) {
console.error('Error generating UPC:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to generate UPC';
toast.error(errorMessage);
} finally {
setIsGeneratingUpc(false);
}
}, [hasValidSupplier, isGeneratingUpc, supplierIdString, rowIndex]);
// Read cache via getState() - NOT a subscription
// This avoids cascading re-renders when cache updates
const getOptions = useCallback((): SelectOption[] => {
if (needsCompany && company) {
const cached = useValidationStore.getState().productLinesCache.get(String(company));
if (cached) return cached;
}
if (needsLine && line) {
const cached = useValidationStore.getState().sublinesCache.get(String(line));
if (cached) return cached;
}
if ('options' in field.fieldType) {
return field.fieldType.options as SelectOption[];
}
return EMPTY_OPTIONS;
}, [needsCompany, needsLine, company, line, field.fieldType]);
// Get initial options - will be refreshed when dropdown opens via onFetchOptions
const options = getOptions();
// Get cell component based on field type AND option count
// This ensures large option lists use ComboboxCell with search
const CellComponent = getCellComponent(field, options.length);
// Stable callback for onChange - not used by cells anymore, but kept for API compatibility
const handleChange = useCallback((newValue: unknown) => {
useValidationStore.getState().updateCell(rowIndex, field.key, newValue);
}, [rowIndex, field.key]);
// Stable callback for onBlur - validates field
// Uses setTimeout(0) to defer validation AFTER browser paint
const handleBlur = useCallback((newValue: unknown) => {
const { updateCell } = useValidationStore.getState();
updateCell(rowIndex, field.key, newValue);
// Defer validation to after the browser paints
setTimeout(() => {
const { setError, clearFieldError, fields } = useValidationStore.getState();
const fieldDef = fields.find((f) => f.key === field.key);
if (!fieldDef?.validations) return;
for (const validation of fieldDef.validations) {
if (validation.rule === 'required') {
const isEmpty = newValue === undefined || newValue === null ||
(typeof newValue === 'string' && newValue.trim() === '') ||
(Array.isArray(newValue) && newValue.length === 0);
if (isEmpty) {
setError(rowIndex, field.key, {
message: validation.errorMessage || 'This field is required',
level: validation.level || 'error',
source: ErrorSource.Row,
type: ErrorType.Required,
});
return;
}
}
}
clearFieldError(rowIndex, field.key);
}, 0);
}, [rowIndex, field.key]);
// Stable callback for fetching options (for line/subline dropdowns)
const handleFetchOptions = useCallback(async () => {
const state = useValidationStore.getState();
if (needsCompany && company) {
const companyId = String(company);
const cached = state.productLinesCache.get(companyId);
if (cached) return cached;
state.setLoadingProductLines(companyId, true);
try {
const response = await fetch(`/api/import/product-lines/${companyId}`);
const lines = await response.json();
const opts = lines.map((ln: { name?: string; label?: string; value?: string | number; id?: string | number }) => ({
label: ln.name || ln.label || String(ln.value || ln.id),
value: String(ln.value || ln.id),
}));
state.setProductLines(companyId, opts);
return opts;
} catch (error) {
console.error('Error fetching product lines:', error);
return [];
} finally {
state.setLoadingProductLines(companyId, false);
}
}
if (needsLine && line) {
const lineId = String(line);
const cached = state.sublinesCache.get(lineId);
if (cached) return cached;
state.setLoadingSublines(lineId, true);
try {
const response = await fetch(`/api/import/sublines/${lineId}`);
const sublines = await response.json();
const opts = sublines.map((subline: { name?: string; label?: string; value?: string | number; id?: string | number }) => ({
label: subline.name || subline.label || String(subline.value || subline.id),
value: String(subline.value || subline.id),
}));
state.setSublines(lineId, opts);
return opts;
} catch (error) {
console.error('Error fetching sublines:', error);
return [];
} finally {
state.setLoadingSublines(lineId, false);
}
}
return [];
}, [needsCompany, needsLine, company, line]);
// Determine cell highlighting classes
const cellHighlightClass = cn(
'relative w-full group',
isCopyDownSource && 'ring-2 ring-blue-500 ring-inset rounded',
isInCopyDownRange && 'bg-blue-100 dark:bg-blue-900/30',
isCopyDownTarget && !isInCopyDownRange && 'hover:bg-blue-50 dark:hover:bg-blue-900/20 cursor-pointer'
);
// When in copy-down mode for this field, make cell non-interactive so clicks go to parent
const isCopyDownModeForThisField = isCopyDownActive && (isCopyDownSource || isCopyDownTarget);
return (
<div
className={cellHighlightClass}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={isCopyDownTarget ? handleTargetClick : undefined}
>
{/* Wrap cell in a div that blocks pointer events during copy-down for this field */}
<div className={isCopyDownModeForThisField ? 'pointer-events-none' : ''}>
<CellComponent
value={value}
field={field}
options={options}
rowIndex={rowIndex}
isValidating={isValidating}
errors={errors}
onChange={handleChange}
onBlur={handleBlur}
onFetchOptions={needsCompany || needsLine ? handleFetchOptions : undefined}
/>
</div>
{/* Copy-down button - appears on hover */}
{showCopyDownButton && (
<button
type="button"
onClick={handleStartCopyDown}
className="absolute right-0.5 top-1/2 -translate-y-1/2 z-10 p-1 rounded-full
bg-blue-50 hover:bg-blue-100 text-blue-500 hover:text-blue-600
dark:bg-blue-900/50 dark:hover:bg-blue-900 dark:text-blue-400
opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
title="Copy value to rows below"
>
<ArrowDown className="h-3.5 w-3.5" />
</button>
)}
{/* UPC Generate button - appears on hover for empty UPC cells */}
{showGenerateUpcButton && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleGenerateUpc}
disabled={!hasValidSupplier}
className={cn(
'absolute right-1 top-1/2 -translate-y-1/2 z-10 flex items-center gap-1',
'rounded-md border border-input bg-background px-2 py-1 text-xs shadow-sm',
'opacity-0 group-hover:opacity-100 transition-opacity',
hasValidSupplier
? 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
: 'text-muted-foreground/50 cursor-not-allowed'
)}
>
<Wand2 className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{hasValidSupplier ? 'Generate UPC' : 'Select a supplier first'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* UPC generating spinner */}
{isGeneratingUpc && (
<div className="absolute right-1 top-1/2 -translate-y-1/2 z-10">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
);
});
CellWrapper.displayName = 'CellWrapper';
/**
* Template column width
*/
const TEMPLATE_COLUMN_WIDTH = 200;
/**
* TemplateCell Component
*
* Per-row template dropdown that subscribes to templates from store.
* PERFORMANCE: Templates rarely change, so this subscription is acceptable.
*/
interface TemplateCellProps {
rowIndex: number;
currentTemplateId: string | undefined;
defaultBrand: string | undefined;
}
const TemplateCell = memo(({ rowIndex, currentTemplateId, defaultBrand }: TemplateCellProps) => {
// Subscribe to templates - these rarely change
const templates = useValidationStore((state) => state.templates);
const templatesLoading = useValidationStore((state) => state.templatesLoading);
const fields = useValidationStore((state) => state.fields);
// Get company name lookup from field options
const companyLookup = useMemo(() => {
const companyField = fields.find((f) => f.key === 'company');
if (!companyField || companyField.fieldType.type !== 'select') return new Map<string, string>();
const opts = companyField.fieldType.options || [];
return new Map(opts.map((opt) => [opt.value, opt.label]));
}, [fields]);
// Helper to get display text - looks up company name from field options
const getTemplateDisplayText = useCallback(
(templateId: string | null): string => {
if (!templateId) return '';
const template = templates.find((t) => t.id.toString() === templateId);
if (!template) return '';
const companyName = companyLookup.get(template.company) || template.company || 'Unknown';
return `${companyName} - ${template.product_type || 'Unknown'}`;
},
[templates, companyLookup]
);
// Handle template selection - apply template to this row
const handleTemplateChange = useCallback(
(templateId: string) => {
if (!templateId) return;
const template = templates.find((t) => t.id.toString() === templateId);
if (!template) return;
const { updateRow, clearRowErrors, setRowValidationStatus } = useValidationStore.getState();
// Build updates from template
const updates: Partial<RowData> = {
__template: templateId,
};
// Copy template fields to row (excluding metadata)
const excludeFields = ['id', '__index', '__meta', '__template', '__original', '__corrected', '__changes'];
Object.entries(template).forEach(([key, value]) => {
if (!excludeFields.includes(key)) {
updates[key] = value;
}
});
// Apply updates
updateRow(rowIndex, updates);
// Clear errors and mark as validated
clearRowErrors(rowIndex);
setRowValidationStatus(rowIndex, 'validated');
toast.success('Template applied');
},
[templates, rowIndex]
);
if (templatesLoading) {
return (
<div className="w-full h-8 flex items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
);
}
if (!templates || templates.length === 0) {
return (
<div className="w-full h-8 flex items-center justify-center text-xs text-muted-foreground">
No templates
</div>
);
}
return (
<SearchableTemplateSelect
templates={templates}
value={currentTemplateId || ''}
onValueChange={handleTemplateChange}
getTemplateDisplayText={getTemplateDisplayText}
placeholder="Select template"
defaultBrand={defaultBrand}
triggerClassName="h-8 text-xs"
/>
);
});
TemplateCell.displayName = 'TemplateCell';
/**
* Row wrapper that subscribes to ALL row-level state
*
* PERFORMANCE: This is now the ONLY component that subscribes to store state
* for a given row. It passes all data down to CellWrapper as props.
* This reduces subscriptions from ~2100 (7 per cell) to ~150 (5 per row).
*/
interface VirtualRowProps {
rowIndex: number;
rowId: string;
virtualStart: number;
columns: ColumnDef<RowData>[];
fields: Field<string>[];
totalRowCount: number;
}
const VirtualRow = memo(({
rowIndex,
rowId,
virtualStart,
columns,
fields,
totalRowCount,
}: VirtualRowProps) => {
// Subscribe to row data - this is THE subscription for all cell values in this row
const rowData = useValidationStore(
useCallback((state) => state.rows[rowIndex], [rowIndex])
);
// Subscribe to errors for this row - MUST return stable reference!
const rowErrors = useValidationStore(
useCallback((state) => state.errors.get(rowIndex) ?? EMPTY_ROW_ERRORS, [rowIndex])
);
// DON'T subscribe to validatingCells - check it during render instead
// This avoids creating new objects in selectors which causes infinite loops
// Validation status changes are rare, so reading via getState() is fine
// Subscribe to selection status
const isSelected = useValidationStore(
useCallback((state) => state.selectedRows.has(rowId), [rowId])
);
// Subscribe to copy-down mode - returns primitives for performance
const copyDownMode = useValidationStore(
useCallback((state) => state.copyDownMode, [])
);
// DON'T subscribe to caches - read via getState() when needed
// Subscribing to caches causes ALL rows with same company to re-render when cache updates!
const company = rowData?.company;
const line = rowData?.line;
const supplier = rowData?.supplier;
// Get action via getState() - no need to subscribe
const toggleRowSelection = useValidationStore.getState().toggleRowSelection;
const hasErrors = Object.keys(rowErrors).length > 0;
// Handle mouse enter for copy-down target selection
const handleMouseEnter = useCallback(() => {
if (copyDownMode.isActive && copyDownMode.sourceRowIndex !== null && rowIndex > copyDownMode.sourceRowIndex) {
useValidationStore.getState().setTargetRowHover(rowIndex);
}
}, [copyDownMode.isActive, copyDownMode.sourceRowIndex, rowIndex]);
return (
<div
data-row-index={rowIndex}
className={cn(
'flex border-b absolute',
hasErrors && 'bg-destructive/5',
isSelected && 'bg-primary/5'
)}
style={{
height: ROW_HEIGHT,
transform: `translateY(${virtualStart}px)`,
}}
onMouseEnter={handleMouseEnter}
>
{/* Selection checkbox cell */}
<div
className="px-2 py-1 border-r flex items-center justify-center"
style={{
width: columns[0]?.size || 40,
minWidth: columns[0]?.size || 40,
flexShrink: 0,
}}
>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRowSelection(rowId)}
/>
</div>
{/* Template column */}
<div
className="px-2 py-1 border-r flex items-center overflow-hidden"
style={{
width: TEMPLATE_COLUMN_WIDTH,
minWidth: TEMPLATE_COLUMN_WIDTH,
flexShrink: 0,
}}
>
<TemplateCell
rowIndex={rowIndex}
currentTemplateId={rowData?.__template}
defaultBrand={typeof company === 'string' ? company : undefined}
/>
</div>
{/* Data cells - pass all data as props */}
{fields.map((field, fieldIndex) => {
// Account for selection (0) and template (1) columns
const columnWidth = columns[fieldIndex + 2]?.size || field.width || 150;
const fieldErrors = rowErrors[field.key] || EMPTY_ERRORS;
// Check validating status via getState() - not subscribed to avoid object creation
const isValidating = useValidationStore.getState().validatingCells.has(`${rowIndex}-${field.key}`);
// CRITICAL: Only pass company/line to cells that need them!
// Passing to all cells breaks memoization - when company changes, ALL cells re-render
const needsCompany = field.key === 'line';
const needsLine = field.key === 'subline';
const needsSupplier = field.key === 'upc';
// Calculate copy-down state for this cell
const isCopyDownActive = copyDownMode.isActive;
const isCopyDownSource = isCopyDownActive &&
copyDownMode.sourceRowIndex === rowIndex &&
copyDownMode.sourceFieldKey === field.key;
const isCopyDownTarget = isCopyDownActive &&
copyDownMode.sourceFieldKey === field.key &&
copyDownMode.sourceRowIndex !== null &&
rowIndex > copyDownMode.sourceRowIndex;
const isInCopyDownRange = isCopyDownTarget &&
copyDownMode.targetRowIndex !== null &&
rowIndex <= copyDownMode.targetRowIndex;
return (
<div
key={field.key}
className="px-2 py-1 border-r last:border-r-0 flex items-center overflow-hidden"
style={{
width: columnWidth,
minWidth: columnWidth,
flexShrink: 0,
}}
>
<CellWrapper
field={field}
rowIndex={rowIndex}
value={rowData?.[field.key]}
errors={fieldErrors}
isValidating={isValidating}
company={needsCompany ? company : undefined}
line={needsLine ? line : undefined}
supplier={needsSupplier ? supplier : undefined}
isCopyDownActive={isCopyDownActive}
isCopyDownSource={isCopyDownSource}
isInCopyDownRange={isInCopyDownRange}
isCopyDownTarget={isCopyDownTarget}
totalRowCount={totalRowCount}
/>
</div>
);
})}
</div>
);
});
VirtualRow.displayName = 'VirtualRow';
/**
* Header checkbox component - isolated to prevent re-renders of the entire table
*/
const HeaderCheckbox = memo(() => {
const rowCount = useValidationStore((state) => state.rows.length);
const selectedCount = useValidationStore((state) => state.selectedRows.size);
const allSelected = rowCount > 0 && selectedCount === rowCount;
const someSelected = selectedCount > 0 && selectedCount < rowCount;
const handleChange = useCallback((value: boolean | 'indeterminate') => {
const { setSelectedRows, rows } = useValidationStore.getState();
if (value) {
const allIds = new Set(rows.map((row) => row.__index));
setSelectedRows(allIds);
} else {
setSelectedRows(new Set());
}
}, []);
return (
<Checkbox
checked={allSelected || (someSelected && 'indeterminate')}
onCheckedChange={handleChange}
/>
);
});
HeaderCheckbox.displayName = 'HeaderCheckbox';
/**
* Main table component
*
* CRITICAL PERFORMANCE: We only subscribe to rows.length, NOT the rows array itself.
* When a cell value changes, Immer creates a new rows array reference, but the length
* stays the same. By only subscribing to length, cell edits don't cascade re-renders.
*/
export const ValidationTable = () => {
const fields = useFields();
const filters = useFilters();
// CRITICAL: Only subscribe to row COUNT, not the rows array
// This prevents re-renders when cell values change
const rowCount = useValidationStore((state) => state.rows.length);
// Refs for scroll sync
const tableContainerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
// Sync header scroll with body scroll
const handleScroll = useCallback(() => {
if (tableContainerRef.current && headerRef.current) {
headerRef.current.scrollLeft = tableContainerRef.current.scrollLeft;
}
}, []);
// Compute filtered indices AND row IDs in a single pass
// This avoids calling getState() during render for each row
const { filteredIndices, rowIdMap } = useMemo(() => {
const state = useValidationStore.getState();
const idMap = new Map<number, string>();
if (!filters.searchText && !filters.showErrorsOnly) {
// No filtering - return all indices with their row IDs
const indices = Array.from({ length: rowCount }, (_, i) => {
const rowId = state.rows[i]?.__index;
if (rowId) idMap.set(i, rowId);
return i;
});
return { filteredIndices: indices, rowIdMap: idMap };
}
const indices: number[] = [];
state.rows.forEach((row, index) => {
// Apply search filter
if (filters.searchText) {
const searchLower = filters.searchText.toLowerCase();
const matches = Object.values(row).some((value) =>
String(value ?? '').toLowerCase().includes(searchLower)
);
if (!matches) return;
}
// Apply errors-only filter
if (filters.showErrorsOnly) {
const rowErrors = state.errors.get(index);
if (!rowErrors || Object.keys(rowErrors).length === 0) return;
}
indices.push(index);
if (row.__index) idMap.set(index, row.__index);
});
return { filteredIndices: indices, rowIdMap: idMap };
}, [rowCount, filters.searchText, filters.showErrorsOnly]);
// Build columns - ONLY depends on fields, NOT selection state
// Selection state is handled by isolated HeaderCheckbox component
const columns = useMemo<ColumnDef<RowData>[]>(() => {
// Selection column - uses isolated HeaderCheckbox to prevent cascading re-renders
const selectionColumn: ColumnDef<RowData> = {
id: 'select',
header: () => <HeaderCheckbox />,
size: 40,
};
// Template column
const templateColumn: ColumnDef<RowData> = {
id: 'template',
header: () => <span className="truncate">Template</span>,
size: TEMPLATE_COLUMN_WIDTH,
};
// Data columns from fields with widths from config.ts
const dataColumns: ColumnDef<RowData>[] = fields.map((field) => ({
id: field.key,
header: () => (
<div className="flex items-center gap-1 truncate">
<span className="truncate">{field.label}</span>
{field.validations?.some((v: Validation) => v.rule === 'required') && (
<span className="text-destructive flex-shrink-0">*</span>
)}
</div>
),
size: field.width || 150,
}));
return [selectionColumn, templateColumn, ...dataColumns];
}, [fields]); // CRITICAL: No selection-related deps!
// Calculate total table width for horizontal scrolling
const totalTableWidth = useMemo(() => {
return columns.reduce((sum, col) => sum + (col.size || 150), 0);
}, [columns]);
// Virtual row renderer
// Lower overscan = faster initial load, slight flicker on fast scroll
const rowVirtualizer = useVirtualizer({
count: filteredIndices.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 3,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
return (
<div className="h-full flex flex-col border rounded-md overflow-hidden relative">
{/* Copy-down banner - shows when copy-down mode is active */}
<CopyDownBanner />
{/* Fixed header - OUTSIDE the scroll container but syncs horizontal scroll */}
<div
ref={headerRef}
className="flex-shrink-0 bg-muted/50 border-b overflow-hidden"
style={{ height: HEADER_HEIGHT }}
>
<div
className="flex h-full"
style={{ minWidth: totalTableWidth }}
>
{columns.map((column, index) => (
<div
key={column.id || index}
className="px-3 flex items-center text-left text-sm font-medium text-muted-foreground border-r last:border-r-0"
style={{
width: column.size || 150,
minWidth: column.size || 150,
flexShrink: 0,
}}
>
{typeof column.header === 'function'
? column.header({} as any)
: column.header}
</div>
))}
</div>
</div>
{/* Scrollable virtualized content */}
<div ref={tableContainerRef} className="flex-1 overflow-auto" onScroll={handleScroll}>
<div
style={{
height: totalSize,
width: totalTableWidth,
minWidth: totalTableWidth,
position: 'relative',
}}
>
{/* Virtual rows - use pre-computed rowIdMap to avoid getState() during render */}
{virtualRows.map((virtualRow) => {
const actualRowIndex = filteredIndices[virtualRow.index];
const rowId = rowIdMap.get(actualRowIndex);
if (!rowId) return null;
return (
<VirtualRow
key={rowId}
rowIndex={actualRowIndex}
rowId={rowId}
virtualStart={virtualRow.start}
columns={columns}
fields={fields}
totalRowCount={rowCount}
/>
);
})}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,198 @@
/**
* ValidationToolbar Component
*
* Provides filtering, template selection, and action buttons.
*
* PERFORMANCE NOTE: This component must NOT subscribe to the rows array!
* Using useRows() or hooks that depend on it (like useValidationActions,
* useSelectedRowIndices) causes re-render on EVERY cell change, making
* dropdowns extremely slow. Instead, use getState() for one-time reads.
*/
import { useMemo, useCallback } from 'react';
import { Search, Plus, Trash2, FolderPlus, FilePlus2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { useValidationStore } from '../store/validationStore';
import {
useFilters,
useSelectedRowCount,
useFields,
} from '../store/selectors';
import { CreateProductCategoryDialog, type CreatedCategoryInfo } from '../../../CreateProductCategoryDialog';
interface ValidationToolbarProps {
rowCount: number;
errorCount: number;
rowsWithErrors: number;
}
export const ValidationToolbar = ({
rowCount,
errorCount,
rowsWithErrors,
}: ValidationToolbarProps) => {
const filters = useFilters();
const selectedRowCount = useSelectedRowCount();
const fields = useFields();
// Store actions - get directly, no subscription needed
const setSearchText = useValidationStore((state) => state.setSearchText);
const setShowErrorsOnly = useValidationStore((state) => state.setShowErrorsOnly);
const setProductLines = useValidationStore((state) => state.setProductLines);
const setSublines = useValidationStore((state) => state.setSublines);
// Extract companies from field options
const companyOptions = useMemo(() => {
const companyField = fields.find((f) => f.key === 'company');
if (!companyField || companyField.fieldType.type !== 'select') return [];
const opts = companyField.fieldType.options || [];
return opts.map((opt) => ({
label: opt.label,
value: opt.value,
}));
}, [fields]);
// PERFORMANCE: Get row indices at action time, not via subscription
// This avoids re-rendering when rows change
const handleDeleteSelected = useCallback(() => {
const { rows, selectedRows, deleteRows } = useValidationStore.getState();
const indices: number[] = [];
rows.forEach((row, index) => {
if (selectedRows.has(row.__index)) {
indices.push(index);
}
});
if (indices.length > 0) {
deleteRows(indices);
}
}, []);
// PERFORMANCE: Get addRow at action time
const handleAddRow = useCallback(() => {
useValidationStore.getState().addRow();
}, []);
// Handle category creation - refresh the cache
const handleCategoryCreated = useCallback(
async (info: CreatedCategoryInfo) => {
if (info.type === 'line') {
// A new product line was created under a company
// Refresh the productLinesCache for that company
const companyId = info.parentId;
const { productLinesCache } = useValidationStore.getState();
const existingLines = productLinesCache.get(companyId) || [];
// Add the new line to the cache
const newOption = {
label: info.name,
value: info.id || info.name,
};
setProductLines(companyId, [...existingLines, newOption]);
} else {
// A new subline was created under a line
// Refresh the sublinesCache for that line
const lineId = info.parentId;
const { sublinesCache } = useValidationStore.getState();
const existingSublines = sublinesCache.get(lineId) || [];
// Add the new subline to the cache
const newOption = {
label: info.name,
value: info.id || info.name,
};
setSublines(lineId, [...existingSublines, newOption]);
}
},
[setProductLines, setSublines]
);
return (
<div className="border-b bg-background px-4 py-3">
{/* Top row: Search and stats */}
<div className="flex items-center gap-4 mb-3">
{/* Search */}
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search products..."
value={filters.searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-9"
/>
</div>
{/* Error filter toggle */}
<div className="flex items-center gap-2">
<Switch
id="show-errors"
checked={filters.showErrorsOnly}
onCheckedChange={setShowErrorsOnly}
/>
<Label htmlFor="show-errors" className="text-sm cursor-pointer">
Show errors only
</Label>
</div>
{/* Stats */}
<div className="flex items-center gap-3 text-sm text-muted-foreground ml-auto">
<span>{rowCount} products</span>
{errorCount > 0 && (
<Badge variant="destructive">
{errorCount} errors in {rowsWithErrors} rows
</Badge>
)}
{selectedRowCount > 0 && (
<Badge variant="secondary">{selectedRowCount} selected</Badge>
)}
</div>
</div>
{/* Bottom row: Actions */}
<div className="flex items-center gap-2">
{/* Add row */}
<Button variant="outline" size="sm" onClick={() => handleAddRow()}>
<Plus className="h-4 w-4 mr-1" />
Add Row
</Button>
{/* Create template */}
<Button
variant="outline"
size="sm"
onClick={() => useValidationStore.getState().openTemplateForm({})}
>
<FilePlus2 className="h-4 w-4 mr-1" />
New Template
</Button>
{/* Create product line/subline */}
<CreateProductCategoryDialog
trigger={
<Button variant="outline" size="sm">
<FolderPlus className="h-4 w-4 mr-1" />
New Line/Subline
</Button>
}
companies={companyOptions}
onCreated={handleCategoryCreated}
/>
{/* Delete selected */}
<Button
variant="outline"
size="sm"
onClick={handleDeleteSelected}
disabled={selectedRowCount === 0}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,119 @@
/**
* CheckboxCell Component
*
* Checkbox cell for boolean values.
* Memoized to prevent unnecessary re-renders when parent table updates.
*/
import { useState, useEffect, useCallback, memo } from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
interface CheckboxCellProps {
value: unknown;
field: Field<string>;
options?: SelectOption[];
rowIndex: number;
isValidating: boolean;
errors: ValidationError[];
onChange: (value: unknown) => void;
onBlur: (value: unknown) => void;
onFetchOptions?: () => void;
booleanMatches?: Record<string, boolean>;
}
const CheckboxCellComponent = ({
field,
value,
isValidating,
errors,
onChange,
onBlur,
booleanMatches = {},
}: CheckboxCellProps) => {
const [checked, setChecked] = useState(false);
// Initialize checkbox state
useEffect(() => {
if (value === undefined || value === null) {
setChecked(false);
return;
}
if (typeof value === 'boolean') {
setChecked(value);
return;
}
// Handle string values using booleanMatches
if (typeof value === 'string') {
// First try the field's booleanMatches
const fieldBooleanMatches =
field.fieldType.type === 'checkbox' ? field.fieldType.booleanMatches || {} : {};
// Merge with the provided booleanMatches, with the provided ones taking precedence
const allMatches = { ...fieldBooleanMatches, ...booleanMatches };
// Try to find the value in the matches
const matchEntry = Object.entries(allMatches).find(
([k]) => k.toLowerCase() === value.toLowerCase()
);
if (matchEntry) {
setChecked(matchEntry[1]);
return;
}
// If no match found, use common true/false strings
const trueStrings = ['yes', 'true', '1', 'y'];
const falseStrings = ['no', 'false', '0', 'n'];
if (trueStrings.includes(value.toLowerCase())) {
setChecked(true);
return;
}
if (falseStrings.includes(value.toLowerCase())) {
setChecked(false);
return;
}
}
// For any other values, try to convert to boolean
setChecked(!!value);
}, [value, field.fieldType, booleanMatches]);
// Handle checkbox change
const handleChange = useCallback(
(checked: boolean) => {
setChecked(checked);
onChange(checked);
onBlur(checked);
},
[onChange, onBlur]
);
const hasError = errors.length > 0;
return (
<div
className={cn(
'flex items-center justify-center h-8 w-full rounded-md border',
hasError ? 'bg-red-50 border-destructive' : 'border-input',
isValidating && 'opacity-50'
)}
>
<Checkbox
checked={checked}
onCheckedChange={handleChange}
disabled={isValidating}
className={cn(hasError ? 'border-destructive' : '')}
/>
</div>
);
};
// Memoize to prevent re-renders when parent table state changes
export const CheckboxCell = memo(CheckboxCellComponent);

View File

@@ -0,0 +1,158 @@
/**
* ComboboxCell Component
*
* Combobox-style dropdown for fields with many options (50+).
* Uses Command (cmdk) with built-in fuzzy search filtering.
*
* PERFORMANCE: Unlike SelectCell which renders ALL options upfront,
* ComboboxCell only renders filtered results. When a user types,
* cmdk filters the list and only matching items are rendered.
* This dramatically improves performance for 100+ option lists.
*/
import { useState, useCallback, useRef, memo } from 'react';
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
interface ComboboxCellProps {
value: unknown;
field: Field<string>;
options?: SelectOption[];
rowIndex: number;
isValidating: boolean;
errors: ValidationError[];
onChange: (value: unknown) => void;
onBlur: (value: unknown) => void;
onFetchOptions?: () => Promise<SelectOption[]>;
}
const ComboboxCellComponent = ({
value,
field,
options = [],
isValidating,
errors,
onChange: _onChange, // Unused - onBlur handles both update and validation
onBlur,
onFetchOptions,
}: ComboboxCellProps) => {
const [open, setOpen] = useState(false);
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const hasFetchedRef = useRef(false);
const stringValue = String(value ?? '');
const hasError = errors.length > 0;
const errorMessage = errors[0]?.message;
// Find display label for current value
const selectedOption = options.find((opt) => opt.value === stringValue);
const displayLabel = selectedOption?.label || stringValue;
// Handle popover open - trigger fetch if needed
const handleOpenChange = useCallback(
(isOpen: boolean) => {
setOpen(isOpen);
if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) {
hasFetchedRef.current = true;
setIsLoadingOptions(true);
onFetchOptions().finally(() => {
setIsLoadingOptions(false);
});
}
},
[onFetchOptions, options.length]
);
// Handle selection
const handleSelect = useCallback(
(selectedValue: string) => {
onBlur(selectedValue);
setOpen(false);
},
[onBlur]
);
return (
<div className="relative w-full">
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
'w-full h-8 justify-between text-sm font-normal',
hasError && 'border-destructive focus:ring-destructive',
isValidating && 'opacity-50',
!stringValue && 'text-muted-foreground'
)}
disabled={isValidating}
title={errorMessage}
>
<span className="truncate">
{displayLabel || field.label}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder={`Search ${field.label.toLowerCase()}...`} />
<CommandList>
{isLoadingOptions ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<CommandEmpty>No {field.label.toLowerCase()} found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label} // cmdk filters by this value
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
stringValue === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{isValidating && (
<div className="absolute right-8 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
);
};
// Memoize to prevent re-renders when parent table state changes
export const ComboboxCell = memo(ComboboxCellComponent);

View File

@@ -0,0 +1,116 @@
/**
* InputCell Component
*
* Editable input cell for text, numbers, and price values.
* Memoized to prevent unnecessary re-renders when parent table updates.
*/
import { useState, useCallback, useEffect, useRef, memo } from 'react';
import { Input } from '@/components/ui/input';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
interface InputCellProps {
value: unknown;
field: Field<string>;
options?: SelectOption[];
rowIndex: number;
isValidating: boolean;
errors: ValidationError[];
onChange: (value: unknown) => void;
onBlur: (value: unknown) => void;
onFetchOptions?: () => void;
}
const InputCellComponent = ({
value,
field,
isValidating,
errors,
onBlur,
}: InputCellProps) => {
const [localValue, setLocalValue] = useState(String(value ?? ''));
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Sync local value with prop value when not focused
useEffect(() => {
if (!isFocused) {
setLocalValue(String(value ?? ''));
}
}, [value, isFocused]);
// PERFORMANCE: Only update local state while typing, NOT the store
// The store is updated on blur, which prevents thousands of subscription
// checks per keystroke
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
let newValue = e.target.value;
// Handle price formatting
if ('price' in field.fieldType && field.fieldType.price) {
// Allow only numbers and decimal point
newValue = newValue.replace(/[^0-9.]/g, '');
}
// Only update local state - store will be updated on blur
setLocalValue(newValue);
},
[field]
);
const handleFocus = useCallback(() => {
setIsFocused(true);
}, []);
// Update store only on blur - this is when validation runs too
// Round price fields to 2 decimal places
const handleBlur = useCallback(() => {
setIsFocused(false);
let valueToSave = localValue;
// Round price fields to 2 decimal places
if ('price' in field.fieldType && field.fieldType.price && localValue) {
const numValue = parseFloat(localValue);
if (!isNaN(numValue)) {
valueToSave = numValue.toFixed(2);
setLocalValue(valueToSave);
}
}
onBlur(valueToSave);
}, [localValue, onBlur, field.fieldType]);
const hasError = errors.length > 0;
const errorMessage = errors[0]?.message;
return (
<div className="relative w-full">
<Input
ref={inputRef}
value={localValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={isValidating}
className={cn(
'h-8 text-sm',
hasError && 'border-destructive focus-visible:ring-destructive',
isValidating && 'opacity-50'
)}
title={errorMessage}
/>
{isValidating && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
);
};
// Memoize to prevent re-renders when parent table state changes
export const InputCell = memo(InputCellComponent);

View File

@@ -0,0 +1,159 @@
/**
* MultiSelectCell Component
*
* Multi-select cell for choosing multiple values.
* Memoized to prevent unnecessary re-renders when parent table updates.
*
* PERFORMANCE: Uses uncontrolled open state for Popover.
* Controlled open state can cause delays due to React state processing.
*/
import { useCallback, useMemo, memo } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
interface MultiSelectCellProps {
value: unknown;
field: Field<string>;
options?: SelectOption[];
rowIndex: number;
isValidating: boolean;
errors: ValidationError[];
onChange: (value: unknown) => void;
onBlur: (value: unknown) => void;
onFetchOptions?: () => void;
}
const MultiSelectCellComponent = ({
value,
field,
options = [],
isValidating,
errors,
onChange: _onChange, // Unused - onBlur handles both update and validation
onBlur,
}: MultiSelectCellProps) => {
// PERFORMANCE: Don't use controlled open state
// Parse value to array
const selectedValues = useMemo(() => {
if (Array.isArray(value)) return value.map(String);
if (typeof value === 'string' && value) {
const separator = 'separator' in field.fieldType ? field.fieldType.separator : ',';
return value.split(separator || ',').map((v) => v.trim()).filter(Boolean);
}
return [];
}, [value, field.fieldType]);
const hasError = errors.length > 0;
const errorMessage = errors[0]?.message;
// Only call onBlur - it handles both the cell update AND validation
// Calling onChange separately would cause a redundant store update
const handleSelect = useCallback(
(optionValue: string) => {
let newValues: string[];
if (selectedValues.includes(optionValue)) {
newValues = selectedValues.filter((v) => v !== optionValue);
} else {
newValues = [...selectedValues, optionValue];
}
onBlur(newValues);
},
[selectedValues, onBlur]
);
// Get labels for selected values
const selectedLabels = useMemo(() => {
return selectedValues.map((val) => {
const option = options.find((opt) => opt.value === val);
return { value: val, label: option?.label || val };
});
}, [selectedValues, options]);
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={isValidating}
className={cn(
'h-8 w-full justify-between text-sm font-normal',
hasError && 'border-destructive focus:ring-destructive',
isValidating && 'opacity-50',
selectedValues.length === 0 && 'text-muted-foreground'
)}
title={errorMessage}
>
<div className="flex items-center w-full justify-between overflow-hidden">
<div className="flex items-center gap-2 overflow-hidden">
{selectedLabels.length === 0 ? (
<span className="text-muted-foreground truncate">Select...</span>
) : selectedLabels.length === 1 ? (
<span className="truncate">{selectedLabels[0].label}</span>
) : (
<>
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
{selectedLabels.length} selected
</Badge>
<span className="truncate">
{selectedLabels.map((v) => v.label).join(', ')}
</span>
</>
)}
</div>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder={`Search ${field.label}...`} />
<CommandList>
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(option.value)
? 'opacity-100'
: 'opacity-0'
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
// Memoize to prevent re-renders when parent table state changes
export const MultiSelectCell = memo(MultiSelectCellComponent);

View File

@@ -0,0 +1,174 @@
/**
* MultilineInput Component
*
* Expandable textarea cell for long text content.
* Memoized to prevent unnecessary re-renders when parent table updates.
*/
import { useState, useCallback, useRef, useEffect, memo } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
import { X, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
interface MultilineInputProps {
value: unknown;
field: Field<string>;
options?: SelectOption[];
rowIndex: number;
isValidating: boolean;
errors: ValidationError[];
onChange: (value: unknown) => void;
onBlur: (value: unknown) => void;
onFetchOptions?: () => void;
}
const MultilineInputComponent = ({
field,
value,
isValidating,
errors,
onChange,
onBlur,
}: MultilineInputProps) => {
const [popoverOpen, setPopoverOpen] = useState(false);
const [editValue, setEditValue] = useState('');
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
const cellRef = useRef<HTMLDivElement>(null);
const preventReopenRef = useRef(false);
const hasError = errors.length > 0;
const errorMessage = errors[0]?.message;
// Initialize localDisplayValue on mount and when value changes externally
useEffect(() => {
const strValue = String(value ?? '');
if (localDisplayValue === null || strValue !== localDisplayValue) {
setLocalDisplayValue(strValue);
}
}, [value, localDisplayValue]);
// Handle trigger click to toggle the popover
const handleTriggerClick = useCallback(
(e: React.MouseEvent) => {
if (preventReopenRef.current) {
e.preventDefault();
e.stopPropagation();
preventReopenRef.current = false;
return;
}
// Only process if not already open
if (!popoverOpen) {
setPopoverOpen(true);
// Initialize edit value from the current display
setEditValue(localDisplayValue || String(value ?? ''));
}
},
[popoverOpen, value, localDisplayValue]
);
// Handle immediate close of popover
const handleClosePopover = useCallback(() => {
// Only process if we have changes
if (editValue !== value || editValue !== localDisplayValue) {
// Update local display immediately
setLocalDisplayValue(editValue);
// Queue up the change
onChange(editValue);
onBlur(editValue);
}
// Immediately close popover
setPopoverOpen(false);
// Prevent reopening
preventReopenRef.current = true;
setTimeout(() => {
preventReopenRef.current = false;
}, 100);
}, [editValue, value, localDisplayValue, onChange, onBlur]);
// Handle popover open/close
const handlePopoverOpenChange = useCallback(
(open: boolean) => {
if (!open && popoverOpen) {
handleClosePopover();
} else if (open && !popoverOpen) {
setEditValue(localDisplayValue || String(value ?? ''));
setPopoverOpen(true);
}
},
[value, popoverOpen, handleClosePopover, localDisplayValue]
);
// Handle direct input change
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setEditValue(e.target.value);
}, []);
// Calculate display value
const displayValue = localDisplayValue !== null ? localDisplayValue : String(value ?? '');
return (
<div className="w-full relative" ref={cellRef}>
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange}>
<PopoverTrigger asChild>
<div
onClick={handleTriggerClick}
className={cn(
'px-2 py-1 h-8 rounded-md text-sm w-full cursor-pointer',
'overflow-hidden whitespace-nowrap text-ellipsis',
'border',
hasError ? 'border-destructive bg-destructive/5' : 'border-input',
isValidating && 'opacity-50'
)}
title={errorMessage || displayValue}
>
{displayValue}
</div>
</PopoverTrigger>
<PopoverContent
className="p-0 shadow-lg rounded-md"
style={{ width: Math.max(cellRef.current?.offsetWidth || 300, 300) }}
align="start"
side="bottom"
alignOffset={0}
sideOffset={4}
onInteractOutside={handleClosePopover}
>
<div className="flex flex-col relative">
<Button
size="icon"
variant="ghost"
onClick={handleClosePopover}
className="h-6 w-6 text-muted-foreground absolute top-0.5 right-0.5 z-10"
>
<X className="h-3 w-3" />
</Button>
<Textarea
value={editValue}
onChange={handleChange}
className="min-h-[150px] border-none focus-visible:ring-0 rounded-md p-2 pr-8"
placeholder={`Enter ${field.label || 'text'}...`}
autoFocus
/>
</div>
</PopoverContent>
</Popover>
{isValidating && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
);
};
// Memoize to prevent re-renders when parent table state changes
export const MultilineInput = memo(MultilineInputComponent);

View File

@@ -0,0 +1,174 @@
/**
* SelectCell Component
*
* Searchable dropdown select cell for single-value selection.
* Uses Command component for built-in search filtering.
* Memoized to prevent unnecessary re-renders when parent table updates.
*/
import { useState, useCallback, useRef, useMemo, memo } from 'react';
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
interface SelectCellProps {
value: unknown;
field: Field<string>;
options?: SelectOption[];
rowIndex: number;
isValidating: boolean;
errors: ValidationError[];
onChange: (value: unknown) => void;
onBlur: (value: unknown) => void;
onFetchOptions?: () => Promise<SelectOption[]>;
}
const SelectCellComponent = ({
value,
field,
options = [],
isValidating,
errors,
onChange: _onChange,
onBlur,
onFetchOptions,
}: SelectCellProps) => {
const [open, setOpen] = useState(false);
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const hasFetchedRef = useRef(false);
const commandListRef = useRef<HTMLDivElement>(null);
const stringValue = String(value ?? '');
const hasError = errors.length > 0;
const errorMessage = errors[0]?.message;
// Handle opening the dropdown - fetch options if needed
const handleOpenChange = useCallback(
async (isOpen: boolean) => {
if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) {
hasFetchedRef.current = true;
setIsLoadingOptions(true);
try {
await onFetchOptions();
} finally {
setIsLoadingOptions(false);
}
}
setOpen(isOpen);
},
[onFetchOptions, options.length]
);
// Handle selection
const handleSelect = useCallback(
(selectedValue: string) => {
onBlur(selectedValue);
setOpen(false);
},
[onBlur]
);
// Handle wheel scroll in dropdown
const handleWheel = useCallback((e: React.WheelEvent) => {
if (commandListRef.current) {
e.stopPropagation();
commandListRef.current.scrollTop += e.deltaY;
}
}, []);
// Find display label for current value
const displayLabel = useMemo(() => {
if (!stringValue) return '';
const found = options.find((opt) => String(opt.value) === stringValue);
return found?.label || stringValue;
}, [options, stringValue]);
return (
<div className="relative w-full">
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={isValidating}
className={cn(
'h-8 w-full justify-between text-sm font-normal',
hasError && 'border-destructive focus:ring-destructive',
isValidating && 'opacity-50',
!stringValue && 'text-muted-foreground'
)}
title={errorMessage}
>
<span className="truncate">
{displayLabel || field.label}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[var(--radix-popover-trigger-width)]"
align="start"
sideOffset={4}
>
<Command shouldFilter={true}>
<CommandInput placeholder="Search..." className="h-9" />
<CommandList
ref={commandListRef}
onWheel={handleWheel}
className="max-h-[200px]"
>
{isLoadingOptions ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
className="cursor-pointer"
>
{option.label}
{String(option.value) === stringValue && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{isValidating && (
<div className="absolute right-8 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
);
};
// Memoize to prevent re-renders when parent table state changes
export const SelectCell = memo(SelectCellComponent);

View File

@@ -0,0 +1,125 @@
/**
* AiDebugDialog Component
*
* Shows the AI validation prompt for debugging purposes.
* Only visible to users with admin:debug permission.
*/
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Loader2 } from 'lucide-react';
import type { AiDebugPromptResponse } from '../hooks/useAiValidation';
interface AiDebugDialogProps {
open: boolean;
onClose: () => void;
debugData: AiDebugPromptResponse | null;
isLoading?: boolean;
}
export const AiDebugDialog = ({
open,
onClose,
debugData,
isLoading = false,
}: AiDebugDialogProps) => {
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
<DialogHeader>
<DialogTitle>AI Validation Prompt</DialogTitle>
<DialogDescription>
Debug view of the prompt that will be sent to the AI for validation
</DialogDescription>
</DialogHeader>
<div className="flex flex-col h-[calc(90vh-120px)] overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : debugData ? (
<ScrollArea className="flex-1 rounded-md border">
<div className="p-4 space-y-4">
{/* Token/Character Stats */}
{debugData.promptLength && (
<div className="grid grid-cols-2 gap-4 p-3 bg-muted/50 rounded-md text-sm">
<div>
<span className="text-muted-foreground">Prompt Length:</span>
<span className="ml-2 font-medium">
{debugData.promptLength.toLocaleString()} chars
</span>
</div>
<div>
<span className="text-muted-foreground">Est. Tokens:</span>
<span className="ml-2 font-medium">
~{Math.round(debugData.promptLength / 4).toLocaleString()}
</span>
</div>
</div>
)}
{/* Base Prompt */}
{debugData.basePrompt && (
<div>
<h4 className="font-medium mb-2">Base Prompt</h4>
<pre className="p-3 bg-muted rounded-md text-xs overflow-x-auto whitespace-pre-wrap">
{debugData.basePrompt}
</pre>
</div>
)}
{/* Sample Full Prompt */}
{debugData.sampleFullPrompt && (
<div>
<h4 className="font-medium mb-2">Sample Full Prompt (First 5 Products)</h4>
<pre className="p-3 bg-muted rounded-md text-xs overflow-x-auto whitespace-pre-wrap">
{debugData.sampleFullPrompt}
</pre>
</div>
)}
{/* Taxonomy Stats */}
{debugData.taxonomyStats && (
<div>
<h4 className="font-medium mb-2">Taxonomy Stats</h4>
<div className="grid grid-cols-4 gap-2 p-3 bg-muted/50 rounded-md text-sm">
{Object.entries(debugData.taxonomyStats).map(([key, value]) => (
<div key={key}>
<span className="text-muted-foreground capitalize">
{key.replace(/([A-Z])/g, ' $1').trim()}:
</span>
<span className="ml-1 font-medium">{value}</span>
</div>
))}
</div>
</div>
)}
{/* API Format */}
{debugData.apiFormat && (
<div>
<h4 className="font-medium mb-2">API Format</h4>
<pre className="p-3 bg-muted rounded-md text-xs overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(debugData.apiFormat, null, 2)}
</pre>
</div>
)}
</div>
</ScrollArea>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
No debug data available
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,74 @@
/**
* AiValidationProgressDialog Component
*
* Shows progress while AI validation is running.
*/
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Loader2 } from 'lucide-react';
import type { AiValidationProgress } from '../store/types';
interface AiValidationProgressDialogProps {
progress: AiValidationProgress;
onCancel: () => void;
}
const formatTime = (ms: number): string => {
if (ms < 1000) return 'less than a second';
const seconds = Math.ceil(ms / 1000);
if (seconds < 60) return `${seconds} second${seconds === 1 ? '' : 's'}`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
};
export const AiValidationProgressDialog = ({
progress,
onCancel,
}: AiValidationProgressDialogProps) => {
const progressPercent = progress.total > 0
? Math.round((progress.current / progress.total) * 100)
: 0;
return (
<Dialog open={true}>
<DialogContent className="sm:max-w-md" onPointerDownOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
AI Validation in Progress
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{progress.message}</span>
<span className="font-medium">{progressPercent}%</span>
</div>
<Progress value={progressPercent} className="h-2" />
</div>
{progress.estimatedTimeRemaining !== undefined && progress.estimatedTimeRemaining > 0 && (
<p className="text-sm text-muted-foreground text-center">
Estimated time remaining: {formatTime(progress.estimatedTimeRemaining)}
</p>
)}
</div>
<div className="flex justify-end">
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,151 @@
/**
* AiValidationResultsDialog Component
*
* Shows AI validation results and allows reverting changes.
*/
import { useMemo } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Check, Undo2, Sparkles } from 'lucide-react';
import type { AiValidationResults } from '../store/types';
interface AiValidationResultsDialogProps {
results: AiValidationResults;
revertedChanges: Set<string>;
onRevert: (productIndex: number, fieldKey: string) => void;
onDismiss: () => void;
}
export const AiValidationResultsDialog = ({
results,
revertedChanges,
onRevert,
onDismiss,
}: AiValidationResultsDialogProps) => {
// Group changes by product
const changesByProduct = useMemo(() => {
const grouped = new Map<number, typeof results.changes>();
results.changes.forEach((change) => {
const existing = grouped.get(change.productIndex) || [];
existing.push(change);
grouped.set(change.productIndex, existing);
});
return grouped;
}, [results.changes]);
const activeChangesCount = results.changes.filter(
(c) => !revertedChanges.has(`${c.productIndex}:${c.fieldKey}`)
).length;
return (
<Dialog open={true} onOpenChange={() => onDismiss()}>
<DialogContent className="sm:max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
AI Validation Complete
</DialogTitle>
</DialogHeader>
<div className="py-4">
{/* Summary */}
<div className="flex items-center gap-4 mb-4 p-4 bg-muted rounded-lg">
<div className="text-center">
<div className="text-2xl font-bold">{results.totalProducts}</div>
<div className="text-xs text-muted-foreground">Products</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-primary">{results.productsWithChanges}</div>
<div className="text-xs text-muted-foreground">With Changes</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">{activeChangesCount}</div>
<div className="text-xs text-muted-foreground">Active Changes</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">{(results.processingTime / 1000).toFixed(1)}s</div>
<div className="text-xs text-muted-foreground">Processing Time</div>
</div>
</div>
{/* Changes list */}
{results.changes.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Check className="h-12 w-12 mx-auto mb-2 text-green-500" />
<p>No corrections needed - all data looks good!</p>
</div>
) : (
<ScrollArea className="h-[300px]">
<div className="space-y-4">
{Array.from(changesByProduct.entries()).map(([productIndex, changes]) => (
<div key={productIndex} className="border rounded-lg p-3">
<div className="font-medium mb-2">Product {productIndex + 1}</div>
<div className="space-y-2">
{changes.map((change) => {
const isReverted = revertedChanges.has(
`${change.productIndex}:${change.fieldKey}`
);
return (
<div
key={`${change.productIndex}-${change.fieldKey}`}
className={`flex items-center justify-between p-2 rounded ${
isReverted ? 'bg-muted opacity-50' : 'bg-primary/5'
}`}
>
<div className="flex-1">
<div className="font-medium text-sm">{change.fieldKey}</div>
<div className="text-xs text-muted-foreground">
<span className="line-through">
{String(change.originalValue || '(empty)')}
</span>
<span className="mx-2"></span>
<span className="text-primary font-medium">
{String(change.correctedValue)}
</span>
</div>
</div>
{isReverted ? (
<Badge variant="outline">Reverted</Badge>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => onRevert(change.productIndex, change.fieldKey)}
>
<Undo2 className="h-4 w-4 mr-1" />
Revert
</Button>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</ScrollArea>
)}
{results.tokenUsage && (
<div className="mt-4 text-xs text-muted-foreground text-center">
Token usage: {results.tokenUsage.input} input, {results.tokenUsage.output} output
</div>
)}
</div>
<DialogFooter>
<Button onClick={onDismiss}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,204 @@
/**
* useAiValidation - Main AI Validation Orchestrator
*
* Coordinates AI validation flow using sub-hooks for API, progress, and transformation.
* This is the public interface for AI validation functionality.
*
* PERFORMANCE NOTE: This hook must NOT subscribe to the rows array!
* Subscribing to rows causes re-render on EVERY cell change.
* Instead, use getState() to read rows at action time.
*/
import { useCallback, useState } from 'react';
import { useValidationStore } from '../../store/validationStore';
import { useAiValidation, useIsAiValidating } from '../../store/selectors';
import { useAiProgress } from './useAiProgress';
import { useAiTransform } from './useAiTransform';
import {
runAiValidation,
getAiDebugPrompt,
prepareProductsForAi,
extractAiSupplementalColumns,
type AiDebugPromptResponse,
} from './useAiApi';
import { toast } from 'sonner';
import type { AiValidationChange } from '../../store/types';
export { useAiProgress } from './useAiProgress';
export { useAiTransform } from './useAiTransform';
export * from './useAiApi';
/**
* Main hook for AI validation operations
*/
export const useAiValidationFlow = () => {
// PERFORMANCE: Only subscribe to AI-specific state, NOT rows or fields
// Rows and fields are read via getState() at action time
const aiValidation = useAiValidation();
const isValidating = useIsAiValidating();
// Sub-hooks
const { startProgress, updateProgress, completeProgress, setError, clearProgress } = useAiProgress();
const { applyAiChanges, buildResults, saveResults } = useAiTransform();
// Store actions
const revertAiChange = useValidationStore((state) => state.revertAiChange);
const clearAiValidation = useValidationStore((state) => state.clearAiValidation);
// Local state for debug prompt preview
const [debugPrompt, setDebugPrompt] = useState<AiDebugPromptResponse | null>(null);
const [showDebugDialog, setShowDebugDialog] = useState(false);
/**
* Run AI validation on all rows
* PERFORMANCE: Uses getState() to read rows/fields at action time
*/
const validate = useCallback(async () => {
if (isValidating) {
toast.error('AI validation is already running');
return;
}
// Get rows and fields at action time
const { rows, fields } = useValidationStore.getState();
if (rows.length === 0) {
toast.error('No products to validate');
return;
}
const startTime = Date.now();
try {
// Start progress tracking
startProgress(rows.length);
// Prepare data for API
updateProgress(0, rows.length, 'preparing', 'Preparing data...');
const products = prepareProductsForAi(rows, fields);
const aiSupplementalColumns = extractAiSupplementalColumns(rows);
// Call AI validation API
updateProgress(0, rows.length, 'validating', 'Running AI validation...');
const response = await runAiValidation({
products,
aiSupplementalColumns,
});
if (!response.success || !response.results) {
throw new Error(response.error || 'AI validation failed');
}
// Process results
updateProgress(rows.length, rows.length, 'processing', 'Processing results...');
const changes: AiValidationChange[] = response.results.changes || [];
// Apply changes to rows
if (changes.length > 0) {
applyAiChanges(response.results.products, changes);
}
// Build and save results
const processingTime = Date.now() - startTime;
const results = buildResults(changes, response.results.tokenUsage, processingTime);
saveResults(results);
// Complete progress
completeProgress();
// Show summary toast
if (changes.length > 0) {
toast.success(`AI made ${changes.length} corrections across ${results.productsWithChanges} products`);
} else {
toast.info('AI validation complete - no corrections needed');
}
} catch (error) {
console.error('AI validation error:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
setError(message);
toast.error(`AI validation failed: ${message}`);
}
}, [
isValidating,
startProgress,
updateProgress,
completeProgress,
setError,
applyAiChanges,
buildResults,
saveResults,
]);
/**
* Revert a specific AI change
*/
const revertChange = useCallback(
(productIndex: number, fieldKey: string) => {
revertAiChange(productIndex, fieldKey);
toast.success('Change reverted');
},
[revertAiChange]
);
/**
* Dismiss AI validation results
*/
const dismissResults = useCallback(() => {
clearAiValidation();
}, [clearAiValidation]);
/**
* Show debug prompt dialog
* PERFORMANCE: Uses getState() to read rows/fields at action time
*/
const showPromptPreview = useCallback(async () => {
const { rows, fields } = useValidationStore.getState();
const products = prepareProductsForAi(rows, fields);
const aiSupplementalColumns = extractAiSupplementalColumns(rows);
const prompt = await getAiDebugPrompt(products, aiSupplementalColumns);
if (prompt) {
setDebugPrompt(prompt);
setShowDebugDialog(true);
} else {
toast.error('Failed to load prompt preview');
}
}, []);
/**
* Close debug prompt dialog
*/
const closePromptPreview = useCallback(() => {
setShowDebugDialog(false);
setDebugPrompt(null);
}, []);
/**
* Cancel ongoing validation
*/
const cancel = useCallback(() => {
clearProgress();
toast.info('AI validation cancelled');
}, [clearProgress]);
return {
// State
isValidating,
progress: aiValidation.progress,
results: aiValidation.results,
revertedChanges: aiValidation.revertedChanges,
// Debug dialog
debugPrompt,
showDebugDialog,
// Actions
validate,
revertChange,
dismissResults,
cancel,
showPromptPreview,
closePromptPreview,
};
};

View File

@@ -0,0 +1,147 @@
/**
* useAiApi - AI Validation API Calls
*
* Handles all API communication for AI validation.
*/
import config from '@/config';
import type { RowData } from '../../store/types';
import type { Field } from '../../../../types';
export interface AiValidationRequest {
products: Record<string, unknown>[];
aiSupplementalColumns: string[];
options?: {
batchSize?: number;
temperature?: number;
};
}
export interface AiValidationResponse {
success: boolean;
results?: {
products: Record<string, unknown>[];
changes: Array<{
productIndex: number;
fieldKey: string;
originalValue: unknown;
correctedValue: unknown;
confidence?: number;
}>;
tokenUsage?: {
input: number;
output: number;
};
};
error?: string;
}
export interface AiDebugPromptResponse {
prompt: string;
systemPrompt: string;
estimatedTokens: number;
}
/**
* Prepare products data for AI validation
*/
export const prepareProductsForAi = (
rows: RowData[],
fields: Field<string>[]
): Record<string, unknown>[] => {
return rows.map((row, index) => {
const product: Record<string, unknown> = {
_index: index, // Track original index for applying changes
};
// Include all field values
fields.forEach((field) => {
const value = row[field.key];
if (value !== undefined && value !== null && value !== '') {
product[field.key] = value;
}
});
// Include AI supplemental columns if present
if (row.__aiSupplemental && Array.isArray(row.__aiSupplemental)) {
row.__aiSupplemental.forEach((col) => {
if (row[col] !== undefined) {
product[`_supplemental_${col}`] = row[col];
}
});
}
return product;
});
};
/**
* Extract AI supplemental columns from rows
*/
export const extractAiSupplementalColumns = (rows: RowData[]): string[] => {
const columns = new Set<string>();
rows.forEach((row) => {
if (row.__aiSupplemental && Array.isArray(row.__aiSupplemental)) {
row.__aiSupplemental.forEach((col) => columns.add(col));
}
});
return Array.from(columns);
};
/**
* Run AI validation on products
*/
export const runAiValidation = async (
request: AiValidationRequest
): Promise<AiValidationResponse> => {
try {
const response = await fetch(`${config.apiUrl}/ai-validation/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `API error (${response.status})`);
}
return await response.json();
} catch (error) {
console.error('AI validation API error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
};
/**
* Get AI debug prompt (for preview)
*/
export const getAiDebugPrompt = async (
products: Record<string, unknown>[],
aiSupplementalColumns: string[]
): Promise<AiDebugPromptResponse | null> => {
try {
const response = await fetch(`${config.apiUrl}/ai-validation/debug`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
products: products.slice(0, 5), // Only send first 5 for preview
aiSupplementalColumns,
}),
});
if (!response.ok) {
throw new Error(`API error (${response.status})`);
}
return await response.json();
} catch (error) {
console.error('AI debug prompt error:', error);
return null;
}
};

View File

@@ -0,0 +1,132 @@
/**
* useAiProgress - AI Validation Progress Tracking
*
* Manages progress state and time estimation for AI validation.
*/
import { useCallback, useRef, useEffect } from 'react';
import { useValidationStore } from '../../store/validationStore';
import type { AiValidationProgress } from '../../store/types';
// Average time per product (based on historical data)
const AVG_MS_PER_PRODUCT = 150;
const MIN_ESTIMATED_TIME = 2000; // Minimum 2 seconds
/**
* Hook for managing AI validation progress
*/
export const useAiProgress = () => {
const setAiValidationProgress = useValidationStore((state) => state.setAiValidationProgress);
const setAiValidationRunning = useValidationStore((state) => state.setAiValidationRunning);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const startTimeRef = useRef<number>(0);
/**
* Start progress tracking
*/
const startProgress = useCallback(
(totalProducts: number) => {
startTimeRef.current = Date.now();
const initialProgress: AiValidationProgress = {
current: 0,
total: totalProducts,
status: 'preparing',
message: 'Preparing data for AI validation...',
startTime: startTimeRef.current,
estimatedTimeRemaining: Math.max(
totalProducts * AVG_MS_PER_PRODUCT,
MIN_ESTIMATED_TIME
),
};
setAiValidationRunning(true);
setAiValidationProgress(initialProgress);
},
[setAiValidationProgress, setAiValidationRunning]
);
/**
* Update progress
*/
const updateProgress = useCallback(
(current: number, total: number, status: AiValidationProgress['status'], message?: string) => {
const elapsed = Date.now() - startTimeRef.current;
const rate = current > 0 ? elapsed / current : AVG_MS_PER_PRODUCT;
const remaining = (total - current) * rate;
setAiValidationProgress({
current,
total,
status,
message,
startTime: startTimeRef.current,
estimatedTimeRemaining: Math.max(remaining, 0),
});
},
[setAiValidationProgress]
);
/**
* Complete progress
*/
const completeProgress = useCallback(() => {
const elapsed = Date.now() - startTimeRef.current;
setAiValidationProgress({
current: 1,
total: 1,
status: 'complete',
message: `Validation complete in ${(elapsed / 1000).toFixed(1)}s`,
startTime: startTimeRef.current,
estimatedTimeRemaining: 0,
});
}, [setAiValidationProgress]);
/**
* Set error state
*/
const setError = useCallback(
(message: string) => {
setAiValidationProgress({
current: 0,
total: 0,
status: 'error',
message,
startTime: startTimeRef.current,
estimatedTimeRemaining: 0,
});
},
[setAiValidationProgress]
);
/**
* Clear progress and stop tracking
*/
const clearProgress = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setAiValidationProgress(null);
setAiValidationRunning(false);
}, [setAiValidationProgress, setAiValidationRunning]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, []);
return {
startProgress,
updateProgress,
completeProgress,
setError,
clearProgress,
};
};

View File

@@ -0,0 +1,166 @@
/**
* useAiTransform - AI Validation Data Transformation
*
* Handles applying AI changes to row data with type coercion.
*
* PERFORMANCE NOTE: This hook must NOT subscribe to the rows array!
* Subscribing to rows causes re-render on EVERY cell change.
* Instead, use getState() to read rows at action time.
*/
import { useCallback } from 'react';
import { useValidationStore } from '../../store/validationStore';
import type { AiValidationChange, AiValidationResults } from '../../store/types';
import type { Field, SelectOption } from '../../../../types';
/**
* Coerce a value to match the expected field type
*/
const coerceValue = (value: unknown, field: Field<string>): unknown => {
if (value === null || value === undefined) return value;
const fieldType = field.fieldType.type;
switch (fieldType) {
case 'checkbox':
// Convert to boolean
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const lower = value.toLowerCase();
if (['true', 'yes', '1', 'on'].includes(lower)) return true;
if (['false', 'no', '0', 'off'].includes(lower)) return false;
}
return Boolean(value);
case 'multi-select':
case 'multi-input':
// Convert to array
if (Array.isArray(value)) return value;
if (typeof value === 'string') {
const separator = field.fieldType.separator || ',';
return value.split(separator).map((v) => v.trim()).filter(Boolean);
}
return [value];
case 'select':
// Ensure it's a valid option value
if ('options' in field.fieldType) {
const strValue = String(value);
const validOption = field.fieldType.options.find(
(opt: SelectOption) => opt.value === strValue || opt.label === strValue
);
return validOption?.value || strValue;
}
return String(value);
case 'input':
// Handle price fields
if ('price' in field.fieldType && field.fieldType.price) {
const strValue = String(value).replace(/[$,\s]/g, '');
const num = parseFloat(strValue);
return isNaN(num) ? value : num.toFixed(2);
}
return String(value);
default:
return value;
}
};
/**
* Hook for transforming and applying AI validation results
* PERFORMANCE: Uses getState() to read rows/fields at action time
*/
export const useAiTransform = () => {
// PERFORMANCE: Only get store actions, no state subscriptions
const storeOriginalValues = useValidationStore((state) => state.storeOriginalValues);
const setAiValidationResults = useValidationStore((state) => state.setAiValidationResults);
/**
* Apply AI changes to rows
* PERFORMANCE: Uses getState() to read rows/fields at action time
*/
const applyAiChanges = useCallback(
(_aiProducts: Record<string, unknown>[], changes: AiValidationChange[]) => {
// Get current state at action time
const { rows, fields, updateRow } = useValidationStore.getState();
// Store original values for revert functionality
storeOriginalValues();
// Group changes by product index for batch updates
const changesByProduct = new Map<number, Record<string, unknown>>();
changes.forEach((change) => {
const productChanges = changesByProduct.get(change.productIndex) || {};
const field = fields.find((f) => f.key === change.fieldKey);
if (field) {
productChanges[change.fieldKey] = coerceValue(change.correctedValue, field);
} else {
productChanges[change.fieldKey] = change.correctedValue;
}
changesByProduct.set(change.productIndex, productChanges);
});
// Apply changes to rows
changesByProduct.forEach((productChanges, productIndex) => {
if (productIndex >= 0 && productIndex < rows.length) {
// Mark which fields were changed by AI
const __changes: Record<string, boolean> = {};
Object.keys(productChanges).forEach((key) => {
__changes[key] = true;
});
updateRow(productIndex, {
...productChanges,
__changes,
__corrected: productChanges,
});
}
});
},
[storeOriginalValues]
);
/**
* Build results summary from AI response
* PERFORMANCE: Uses getState() to read row count at action time
*/
const buildResults = useCallback(
(
changes: AiValidationChange[],
tokenUsage: { input: number; output: number } | undefined,
processingTime: number
): AiValidationResults => {
const { rows } = useValidationStore.getState();
const affectedProducts = new Set(changes.map((c) => c.productIndex));
return {
totalProducts: rows.length,
productsWithChanges: affectedProducts.size,
changes,
tokenUsage,
processingTime,
};
},
[]
);
/**
* Set validation results in store
*/
const saveResults = useCallback(
(results: AiValidationResults) => {
setAiValidationResults(results);
},
[setAiValidationResults]
);
return {
applyAiChanges,
buildResults,
saveResults,
};
};

View File

@@ -0,0 +1,76 @@
/**
* useFieldOptions Hook
*
* Manages fetching and caching of field options from the API.
* Options are used to populate select dropdowns in the validation table.
*/
import { useQuery } from '@tanstack/react-query';
import config from '@/config';
import type { SelectOption } from '../../../types';
export interface FieldOptionsResponse {
suppliers: SelectOption[];
companies: SelectOption[];
taxCategories: SelectOption[];
artists: SelectOption[];
shippingRestrictions: SelectOption[]; // API returns 'shippingRestrictions'
sizes: SelectOption[]; // API returns 'sizes'
categories: SelectOption[];
themes: SelectOption[];
colors: SelectOption[];
}
/**
* Fetch field options from the API
*/
const fetchFieldOptions = async (): Promise<FieldOptionsResponse> => {
const response = await fetch(`${config.apiUrl}/import/field-options`);
if (!response.ok) {
throw new Error('Failed to fetch field options');
}
return response.json();
};
/**
* Hook to fetch and cache field options
*/
export const useFieldOptions = () => {
return useQuery({
queryKey: ['field-options'],
queryFn: fetchFieldOptions,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
gcTime: 10 * 60 * 1000, // Keep in garbage collection for 10 minutes
retry: 2,
refetchOnWindowFocus: false,
});
};
/**
* Get options for a specific field key
*/
export const getOptionsForField = (
fieldOptions: FieldOptionsResponse | undefined,
fieldKey: string
): SelectOption[] => {
if (!fieldOptions) return [];
const fieldToOptionsMap: Record<string, keyof FieldOptionsResponse> = {
supplier: 'suppliers',
company: 'companies',
tax_cat: 'taxCategories',
artist: 'artists',
ship_restrictions: 'shippingRestrictions', // API returns 'shippingRestrictions'
size_cat: 'sizes', // API returns 'sizes'
categories: 'categories',
themes: 'themes',
colors: 'colors',
};
const optionsKey = fieldToOptionsMap[fieldKey];
if (optionsKey && fieldOptions[optionsKey]) {
return fieldOptions[optionsKey];
}
return [];
};

View File

@@ -0,0 +1,238 @@
/**
* useProductLines Hook
*
* Manages fetching and caching of product lines and sublines.
* Lines are hierarchical: Company -> Line -> Subline
*
* PERFORMANCE NOTE: This hook has NO subscriptions to avoid re-renders.
* All state is read via getState() at call-time inside callbacks.
* This prevents components using this hook from re-rendering when cache updates.
*/
import { useCallback, useRef } from 'react';
import { useValidationStore } from '../store/validationStore';
import type { SelectOption } from '../../../types';
import axios from 'axios';
/**
* Fetch product lines for a company
*/
const fetchProductLinesApi = async (companyId: string): Promise<SelectOption[]> => {
const response = await axios.get(`/api/import/product-lines/${companyId}`);
const lines = response.data;
return lines.map((line: { name?: string; label?: string; value?: string | number; id?: string | number }) => ({
label: line.name || line.label || String(line.value || line.id),
value: String(line.value || line.id),
}));
};
/**
* Fetch sublines for a product line
*/
const fetchSublinesApi = async (lineId: string): Promise<SelectOption[]> => {
const response = await axios.get(`/api/import/sublines/${lineId}`);
const sublines = response.data;
return sublines.map((subline: { name?: string; label?: string; value?: string | number; id?: string | number }) => ({
label: subline.name || subline.label || String(subline.value || subline.id),
value: String(subline.value || subline.id),
}));
};
/**
* Hook for product lines operations
*
* PERFORMANCE: This hook has NO subscriptions to cache state.
* All cache reads use getState() at call-time inside callbacks.
* This prevents re-renders when cache updates.
*/
export const useProductLines = () => {
// Store actions (these are stable from Zustand)
const setProductLines = useValidationStore((state) => state.setProductLines);
const setSublines = useValidationStore((state) => state.setSublines);
const setLoadingProductLines = useValidationStore((state) => state.setLoadingProductLines);
const setLoadingSublines = useValidationStore((state) => state.setLoadingSublines);
// Track pending requests to prevent duplicates
const pendingCompanyRequests = useRef(new Set<string>());
const pendingLineRequests = useRef(new Set<string>());
/**
* Fetch product lines for a company
* IMPORTANT: This callback is stable - no subscriptions, only store actions
*/
const fetchProductLines = useCallback(
async (companyId: string): Promise<SelectOption[]> => {
if (!companyId) return [];
// Check cache first via getState()
const cached = useValidationStore.getState().productLinesCache.get(companyId);
if (cached) return cached;
// Check if already fetching
if (pendingCompanyRequests.current.has(companyId)) {
// Wait for the pending request
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
const result = useValidationStore.getState().productLinesCache.get(companyId);
if (result !== undefined) {
clearInterval(checkInterval);
resolve(result);
}
}, 100);
// Timeout after 10 seconds
setTimeout(() => {
clearInterval(checkInterval);
resolve([]);
}, 10000);
});
}
pendingCompanyRequests.current.add(companyId);
setLoadingProductLines(companyId, true);
try {
const lines = await fetchProductLinesApi(companyId);
setProductLines(companyId, lines);
return lines;
} catch (error) {
console.error(`Error fetching product lines for company ${companyId}:`, error);
setProductLines(companyId, []); // Cache empty to prevent retries
return [];
} finally {
pendingCompanyRequests.current.delete(companyId);
setLoadingProductLines(companyId, false);
}
},
[setProductLines, setLoadingProductLines] // Only stable store actions as deps
);
/**
* Fetch sublines for a product line
* IMPORTANT: This callback is stable - no subscriptions, only store actions
*/
const fetchSublines = useCallback(
async (lineId: string): Promise<SelectOption[]> => {
if (!lineId) return [];
// Check cache first via getState()
const cached = useValidationStore.getState().sublinesCache.get(lineId);
if (cached) return cached;
// Check if already fetching
if (pendingLineRequests.current.has(lineId)) {
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
const result = useValidationStore.getState().sublinesCache.get(lineId);
if (result !== undefined) {
clearInterval(checkInterval);
resolve(result);
}
}, 100);
setTimeout(() => {
clearInterval(checkInterval);
resolve([]);
}, 10000);
});
}
pendingLineRequests.current.add(lineId);
setLoadingSublines(lineId, true);
try {
const sublines = await fetchSublinesApi(lineId);
setSublines(lineId, sublines);
return sublines;
} catch (error) {
console.error(`Error fetching sublines for line ${lineId}:`, error);
setSublines(lineId, []); // Cache empty to prevent retries
return [];
} finally {
pendingLineRequests.current.delete(lineId);
setLoadingSublines(lineId, false);
}
},
[setSublines, setLoadingSublines] // Only stable store actions as deps
);
/**
* Prefetch product lines and sublines for all rows
* PERFORMANCE: Uses getState() to read rows at action time
*/
const prefetchAllLines = useCallback(async () => {
// Get rows at action time via getState()
const currentRows = useValidationStore.getState().rows;
// Collect unique company IDs
const companyIds = new Set<string>();
const lineIds = new Set<string>();
currentRows.forEach((row) => {
if (row.company) companyIds.add(String(row.company));
if (row.line) lineIds.add(String(row.line));
});
// Fetch all product lines in parallel
await Promise.all(
Array.from(companyIds).map((companyId) => fetchProductLines(companyId))
);
// Fetch all sublines in parallel
await Promise.all(
Array.from(lineIds).map((lineId) => fetchSublines(lineId))
);
}, [fetchProductLines, fetchSublines]);
/**
* Get product lines for a company (from cache)
*/
const getProductLines = useCallback(
(companyId: string): SelectOption[] => {
return useValidationStore.getState().productLinesCache.get(companyId) || [];
},
[]
);
/**
* Get sublines for a line (from cache)
*/
const getSublines = useCallback(
(lineId: string): SelectOption[] => {
return useValidationStore.getState().sublinesCache.get(lineId) || [];
},
[]
);
/**
* Check if product lines are loading for a company
*/
const isLoadingProductLines = useCallback(
(companyId: string): boolean => {
return pendingCompanyRequests.current.has(companyId);
},
[]
);
/**
* Check if sublines are loading for a line
*/
const isLoadingSublines = useCallback(
(lineId: string): boolean => {
return pendingLineRequests.current.has(lineId);
},
[]
);
return {
fetchProductLines,
fetchSublines,
prefetchAllLines,
getProductLines,
getSublines,
isLoadingProductLines,
isLoadingSublines,
};
};

View File

@@ -0,0 +1,334 @@
/**
* useTemplateManagement Hook
*
* Manages template loading, applying, and saving.
* Templates provide pre-filled values that can be applied to rows.
*
* PERFORMANCE NOTE: This hook must NOT subscribe to the rows array!
* Using useRows() or useSelectedRowIndices() causes re-render on EVERY cell
* change. Instead, use getState() to read rows at action time.
*/
import { useCallback } from 'react';
import { useValidationStore } from '../store/validationStore';
import {
useTemplates,
useTemplatesLoading,
useTemplateState,
} from '../store/selectors';
import { toast } from 'sonner';
import config from '@/config';
import type { Template, RowData } from '../store/types';
// Fields to exclude from template data
const TEMPLATE_EXCLUDE_FIELDS = [
'id',
'__index',
'__meta',
'__template',
'__original',
'__corrected',
'__changes',
'__aiSupplemental',
];
/**
* Hook for template management operations
*/
export const useTemplateManagement = () => {
// PERFORMANCE: Only subscribe to templates state, NOT rows!
// Rows are read via getState() at action time
const templates = useTemplates();
const templatesLoading = useTemplatesLoading();
const templateState = useTemplateState();
// Store actions
const setTemplates = useValidationStore((state) => state.setTemplates);
const setTemplatesLoading = useValidationStore((state) => state.setTemplatesLoading);
const setTemplateState = useValidationStore((state) => state.setTemplateState);
/**
* Load templates from API
*/
const loadTemplates = useCallback(async () => {
setTemplatesLoading(true);
try {
const response = await fetch(`${config.apiUrl}/templates`);
if (!response.ok) throw new Error('Failed to fetch templates');
const data = await response.json();
const validTemplates = data.filter(
(t: Template) => t && typeof t === 'object' && t.id && t.company && t.product_type
);
setTemplates(validTemplates);
} catch (error) {
console.error('Error fetching templates:', error);
toast.error('Failed to load templates');
} finally {
setTemplatesLoading(false);
}
}, [setTemplates, setTemplatesLoading]);
/**
* Apply template to specific rows
* PERFORMANCE: Uses getState() to read rows at action time, avoiding subscriptions
*/
const applyTemplate = useCallback(
async (templateId: string, rowIndexes: number[]) => {
const template = templates.find((t) => t.id.toString() === templateId);
if (!template) {
toast.error('Template not found');
return;
}
// Get current state at action time
const {
rows,
updateRow,
clearRowErrors,
setRowValidationStatus,
} = useValidationStore.getState();
// Validate row indexes
const validIndexes = rowIndexes.filter(
(index) => index >= 0 && index < rows.length && Number.isInteger(index)
);
if (validIndexes.length === 0) {
toast.error('No valid rows to update');
return;
}
// Extract template fields
const templateFields = Object.entries(template).filter(
([key]) => !TEMPLATE_EXCLUDE_FIELDS.includes(key)
);
// Apply template to each row
const rowsNeedingUpcValidation: { rowIndex: number; supplierId: string; upcValue: string }[] = [];
for (const rowIndex of validIndexes) {
// Build updates object
const updates: Partial<RowData> = {
__template: templateId,
};
for (const [key, value] of templateFields) {
updates[key] = value;
}
// Apply updates
updateRow(rowIndex, updates);
// Clear validation errors (template values are presumed valid)
clearRowErrors(rowIndex);
setRowValidationStatus(rowIndex, 'validated');
// Check if row needs UPC validation
const row = rows[rowIndex];
const hasUpc = updates.upc || row?.upc;
const hasSupplier = updates.supplier || row?.supplier;
if (hasUpc && hasSupplier) {
rowsNeedingUpcValidation.push({
rowIndex,
supplierId: String(updates.supplier || row?.supplier || ''),
upcValue: String(updates.upc || row?.upc || ''),
});
}
}
// Show success toast
if (validIndexes.length === 1) {
toast.success('Template applied');
} else {
toast.success(`Template applied to ${validIndexes.length} rows`);
}
// Trigger UPC validation for affected rows in background
// Note: We captured the values above to avoid re-reading stale state
for (const { rowIndex, supplierId, upcValue } of rowsNeedingUpcValidation) {
if (supplierId && upcValue) {
// Don't await - let it run in background
// UPC validation uses its own hooks which will update the store
fetch(`/api/import/validate-upc/${supplierId}/${encodeURIComponent(upcValue)}`)
.then((res) => res.json())
.then((result) => {
if (result.itemNumber) {
const { updateCell, setUpcStatus } = useValidationStore.getState();
updateCell(rowIndex, 'item_number', result.itemNumber);
setUpcStatus(rowIndex, 'done');
}
})
.catch(console.error);
}
}
},
[templates]
);
/**
* Apply template to selected rows
* PERFORMANCE: Gets selected row indices at action time via getState()
*/
const applyTemplateToSelected = useCallback(
(templateId: string) => {
if (!templateId) return;
setTemplateState({ selectedTemplateId: templateId });
// Get selected row indices at action time
const { rows, selectedRows } = useValidationStore.getState();
const selectedRowIndices: number[] = [];
rows.forEach((row, index) => {
if (selectedRows.has(row.__index)) {
selectedRowIndices.push(index);
}
});
if (selectedRowIndices.length === 0) {
toast.error('No rows selected');
return;
}
applyTemplate(templateId, selectedRowIndices);
},
[applyTemplate, setTemplateState]
);
/**
* Save a new template from a row
* PERFORMANCE: Uses getState() to read rows at action time
*/
const saveTemplate = useCallback(
async (name: string, type: string, sourceRowIndex: number) => {
// Get row data at action time
const { rows, updateRow } = useValidationStore.getState();
const row = rows[sourceRowIndex];
if (!row) {
toast.error('Invalid row selected');
return;
}
// Extract data for template, excluding metadata
const templateData: Record<string, unknown> = {};
for (const [key, value] of Object.entries(row)) {
if (TEMPLATE_EXCLUDE_FIELDS.includes(key)) continue;
// Clean price values
if (typeof value === 'string' && value.includes('$')) {
templateData[key] = value.replace(/[$,\s]/g, '').trim();
} else {
templateData[key] = value;
}
}
try {
const response = await fetch(`${config.apiUrl}/templates`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...templateData,
company: name,
product_type: type,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || errorData.details || 'Failed to save template');
}
const newTemplate = await response.json();
// Add to templates list
setTemplates([...templates, newTemplate]);
// Mark row as using this template
updateRow(sourceRowIndex, { __template: newTemplate.id.toString() });
toast.success(`Template "${name}" saved successfully`);
// Reset dialog state
setTemplateState({
showSaveDialog: false,
newTemplateName: '',
newTemplateType: '',
});
} catch (error) {
console.error('Error saving template:', error);
toast.error(error instanceof Error ? error.message : 'Failed to save template');
}
},
[templates, setTemplates, setTemplateState]
);
/**
* Open save template dialog
*/
const openSaveDialog = useCallback(() => {
setTemplateState({ showSaveDialog: true });
}, [setTemplateState]);
/**
* Close save template dialog
*/
const closeSaveDialog = useCallback(() => {
setTemplateState({
showSaveDialog: false,
newTemplateName: '',
newTemplateType: '',
});
}, [setTemplateState]);
/**
* Update dialog form fields
*/
const updateDialogField = useCallback(
(field: 'newTemplateName' | 'newTemplateType', value: string) => {
setTemplateState({ [field]: value });
},
[setTemplateState]
);
/**
* Get display text for a template (e.g., "Brand - Product Type")
*/
const getTemplateDisplayText = useCallback(
(templateId: string | null): string => {
if (!templateId) return '';
const template = templates.find((t) => t.id.toString() === templateId);
if (!template) return '';
// Return "Brand - Product Type" format
const company = template.company || 'Unknown';
const productType = template.product_type || 'Unknown';
return `${company} - ${productType}`;
},
[templates]
);
return {
// State
templates,
templatesLoading,
templateState,
// Actions
loadTemplates,
applyTemplate,
applyTemplateToSelected,
saveTemplate,
// Helpers
getTemplateDisplayText,
// Dialog
openSaveDialog,
closeSaveDialog,
updateDialogField,
};
};

View File

@@ -0,0 +1,290 @@
/**
* useUpcValidation Hook
*
* Handles UPC validation and item number generation.
* Validates UPCs against the API and caches results for efficiency.
*
* PERFORMANCE NOTE: This hook must NOT subscribe to the rows array!
* Subscribing to rows causes re-render on EVERY cell change.
* Uses getState() to read rows at action time instead.
*/
import { useCallback, useRef } from 'react';
import { useValidationStore } from '../store/validationStore';
import { useInitialUpcValidationDone } from '../store/selectors';
import { ErrorSource, ErrorType, type UpcValidationResult } from '../store/types';
import config from '@/config';
const BATCH_SIZE = 50;
const BATCH_DELAY = 100; // ms between batches
/**
* Fetch product by UPC from API
*/
const fetchProductByUpc = async (
supplierId: string,
upcValue: string
): Promise<UpcValidationResult> => {
try {
const response = await fetch(
`${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upcValue)}&supplierId=${encodeURIComponent(supplierId)}`
);
let payload: Record<string, unknown> | null = null;
try {
payload = await response.json();
} catch {
// Non-JSON responses handled below
}
if (response.status === 409) {
return {
success: false,
error: (payload?.error as string) || 'UPC already exists',
code: 'conflict',
};
}
if (!response.ok) {
return {
success: false,
error: (payload?.error as string) || `API error (${response.status})`,
code: 'http_error',
};
}
if (!payload?.success) {
return {
success: false,
error: (payload?.message as string) || 'Unknown error',
code: 'invalid_response',
};
}
return {
success: true,
itemNumber: (payload.itemNumber as string) || '',
};
} catch (error) {
console.error('Network error validating UPC:', error);
return {
success: false,
error: 'Network error',
code: 'network_error',
};
}
};
/**
* Hook for UPC validation operations
* PERFORMANCE: Does NOT subscribe to rows - uses getState() at action time
*/
export const useUpcValidation = () => {
// PERFORMANCE: Only subscribe to initialValidationDone, NOT rows
const initialValidationDone = useInitialUpcValidationDone();
// Store actions (these are stable from Zustand)
const setUpcStatus = useValidationStore((state) => state.setUpcStatus);
const setGeneratedItemNumber = useValidationStore((state) => state.setGeneratedItemNumber);
const cacheUpcResult = useValidationStore((state) => state.cacheUpcResult);
const getCachedItemNumber = useValidationStore((state) => state.getCachedItemNumber);
const setInitialUpcValidationDone = useValidationStore((state) => state.setInitialUpcValidationDone);
const updateCell = useValidationStore((state) => state.updateCell);
const setError = useValidationStore((state) => state.setError);
const clearFieldError = useValidationStore((state) => state.clearFieldError);
const startValidatingCell = useValidationStore((state) => state.startValidatingCell);
const stopValidatingCell = useValidationStore((state) => state.stopValidatingCell);
const setInitPhase = useValidationStore((state) => state.setInitPhase);
// Track active validations to prevent duplicates
const activeValidationsRef = useRef(new Set<string>());
const initialValidationStartedRef = useRef(false);
/**
* Validate a single UPC for a row
* IMPORTANT: This callback is stable due to only using store actions as deps
*/
const validateUpc = useCallback(
async (rowIndex: number, supplierId: string, upcValue: string) => {
const validationKey = `${rowIndex}-${supplierId}-${upcValue}`;
// Cancel any previous validation for this row
const prevKeys = Array.from(activeValidationsRef.current).filter((k) =>
k.startsWith(`${rowIndex}-`)
);
prevKeys.forEach((k) => activeValidationsRef.current.delete(k));
activeValidationsRef.current.add(validationKey);
// Set loading state
setUpcStatus(rowIndex, 'validating');
startValidatingCell(rowIndex, 'item_number');
try {
// Check cache first
const cached = getCachedItemNumber(supplierId, upcValue);
if (cached) {
setGeneratedItemNumber(rowIndex, cached);
clearFieldError(rowIndex, 'upc');
setUpcStatus(rowIndex, 'done');
return { success: true, itemNumber: cached };
}
// Make API call
const result = await fetchProductByUpc(supplierId, upcValue);
// Check if this validation is still relevant
if (!activeValidationsRef.current.has(validationKey)) {
return { success: false };
}
if (result.success && result.itemNumber) {
// Cache and apply result
cacheUpcResult(supplierId, upcValue, result.itemNumber);
setGeneratedItemNumber(rowIndex, result.itemNumber);
clearFieldError(rowIndex, 'upc');
setUpcStatus(rowIndex, 'done');
return result;
} else {
// Clear item number on error
updateCell(rowIndex, 'item_number', '');
setUpcStatus(rowIndex, 'error');
// Set specific error for conflicts
if (result.code === 'conflict') {
setError(rowIndex, 'upc', {
message: 'UPC already exists in database',
level: 'error',
source: ErrorSource.Upc,
type: ErrorType.Unique,
});
}
return result;
}
} catch (error) {
console.error('Error validating UPC:', error);
setUpcStatus(rowIndex, 'error');
return { success: false, error: 'Validation error' };
} finally {
stopValidatingCell(rowIndex, 'item_number');
activeValidationsRef.current.delete(validationKey);
}
},
[
getCachedItemNumber,
setUpcStatus,
setGeneratedItemNumber,
cacheUpcResult,
updateCell,
setError,
clearFieldError,
startValidatingCell,
stopValidatingCell,
]
);
/**
* Validate all UPCs in batch during initialization
* PERFORMANCE: Uses getState() to read rows at action time
*/
const validateAllUpcs = useCallback(async () => {
if (initialValidationStartedRef.current) return;
initialValidationStartedRef.current = true;
// Get current rows at action time via getState()
const currentRows = useValidationStore.getState().rows;
// Find rows that need UPC validation
const rowsToValidate = currentRows
.map((row, index) => ({ row, index }))
.filter(({ row }) => row.supplier && (row.upc || row.barcode));
if (rowsToValidate.length === 0) {
setInitialUpcValidationDone(true);
setInitPhase('validating-fields');
return;
}
// Process in batches
for (let i = 0; i < rowsToValidate.length; i += BATCH_SIZE) {
const batch = rowsToValidate.slice(i, i + BATCH_SIZE);
// Process batch in parallel
await Promise.all(
batch.map(async ({ row, index }) => {
const supplierId = String(row.supplier);
const upcValue = String(row.upc || row.barcode);
// Mark as validating
setUpcStatus(index, 'validating');
startValidatingCell(index, 'item_number');
try {
// Check cache first
const cached = getCachedItemNumber(supplierId, upcValue);
if (cached) {
setGeneratedItemNumber(index, cached);
setUpcStatus(index, 'done');
return;
}
// Make API call
const result = await fetchProductByUpc(supplierId, upcValue);
if (result.success && result.itemNumber) {
cacheUpcResult(supplierId, upcValue, result.itemNumber);
setGeneratedItemNumber(index, result.itemNumber);
clearFieldError(index, 'upc');
setUpcStatus(index, 'done');
} else {
updateCell(index, 'item_number', '');
setUpcStatus(index, 'error');
if (result.code === 'conflict') {
setError(index, 'upc', {
message: 'UPC already exists in database',
level: 'error',
source: ErrorSource.Upc,
type: ErrorType.Unique,
});
}
}
} catch (error) {
console.error(`Error validating UPC for row ${index}:`, error);
setUpcStatus(index, 'error');
} finally {
stopValidatingCell(index, 'item_number');
}
})
);
// Small delay between batches for UI responsiveness
if (i + BATCH_SIZE < rowsToValidate.length) {
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY));
}
}
setInitialUpcValidationDone(true);
setInitPhase('validating-fields');
}, [
// Only stable store actions as dependencies
getCachedItemNumber,
setUpcStatus,
setGeneratedItemNumber,
cacheUpcResult,
updateCell,
setError,
clearFieldError,
startValidatingCell,
stopValidatingCell,
setInitialUpcValidationDone,
setInitPhase,
]);
return {
validateUpc,
validateAllUpcs,
initialValidationDone,
};
};

View File

@@ -0,0 +1,460 @@
/**
* useValidationActions Hook
*
* Provides validation logic and wraps store actions.
* Handles field validation, unique validation, and row operations.
*
* PERFORMANCE NOTE: Uses getState() inside callbacks instead of subscriptions.
* This is critical because subscribing to rows/fields/errors would cause the
* parent component to re-render on EVERY cell change, cascading to all children.
* By using getState(), we read fresh values at call-time without subscriptions.
*/
import { useCallback } from 'react';
import { useValidationStore } from '../store/validationStore';
import { ErrorSource, ErrorType, type ValidationError, type RowData } from '../store/types';
import type { Field, Validation } from '../../../types';
// Debounce cache for validation
const validationCache = new Map<string, NodeJS.Timeout>();
/**
* Check if a value is empty
*/
const isEmpty = (value: unknown): boolean => {
if (value === undefined || value === null) return true;
if (typeof value === 'string' && value.trim() === '') return true;
if (Array.isArray(value) && value.length === 0) return true;
return false;
};
/**
* Validate a single field value against its validation rules
*/
const validateFieldValue = (
value: unknown,
field: Field<string>,
allRows: RowData[],
currentRowIndex: number
): ValidationError | null => {
const validations = field.validations || [];
for (const validation of validations) {
// Required validation
if (validation.rule === 'required') {
if (isEmpty(value)) {
return {
message: validation.errorMessage || 'This field is required',
level: validation.level || 'error',
source: ErrorSource.Row,
type: ErrorType.Required,
};
}
}
// Regex validation
if (validation.rule === 'regex' && !isEmpty(value)) {
const regex = new RegExp(validation.value, validation.flags);
if (!regex.test(String(value))) {
return {
message: validation.errorMessage || 'Invalid format',
level: validation.level || 'error',
source: ErrorSource.Row,
type: ErrorType.Regex,
};
}
}
// Unique validation
if (validation.rule === 'unique') {
// Skip if empty and allowEmpty is true
if (isEmpty(value) && validation.allowEmpty) continue;
// Check for duplicates in other rows
const stringValue = String(value ?? '').toLowerCase().trim();
const isDuplicate = allRows.some((row, index) => {
if (index === currentRowIndex) return false;
const otherValue = String(row[field.key] ?? '').toLowerCase().trim();
return otherValue === stringValue && stringValue !== '';
});
if (isDuplicate) {
return {
message: validation.errorMessage || 'Must be unique',
level: validation.level || 'error',
source: ErrorSource.Table,
type: ErrorType.Unique,
};
}
}
}
return null;
};
/**
* Hook providing validation actions
*
* PERFORMANCE: This hook has NO subscriptions to rows/fields/errors.
* All state is read via getState() at call-time inside callbacks.
* This prevents re-renders of components using this hook when cells change.
*/
export const useValidationActions = () => {
// Store actions (these are stable from Zustand)
const updateCell = useValidationStore((state) => state.updateCell);
const updateRow = useValidationStore((state) => state.updateRow);
const deleteRows = useValidationStore((state) => state.deleteRows);
const addRow = useValidationStore((state) => state.addRow);
const copyDown = useValidationStore((state) => state.copyDown);
const setError = useValidationStore((state) => state.setError);
const clearFieldError = useValidationStore((state) => state.clearFieldError);
const setRowValidationStatus = useValidationStore((state) => state.setRowValidationStatus);
const startValidatingCell = useValidationStore((state) => state.startValidatingCell);
const stopValidatingCell = useValidationStore((state) => state.stopValidatingCell);
const startEditingCell = useValidationStore((state) => state.startEditingCell);
const stopEditingCell = useValidationStore((state) => state.stopEditingCell);
/**
* Validate a specific field for a row
* Uses getState() to access current state at call-time
*/
const validateField = useCallback(
async (rowIndex: number, fieldKey: string) => {
const { rows: currentRows, fields: currentFields } = useValidationStore.getState();
const field = currentFields.find((f: Field<string>) => f.key === fieldKey);
if (!field) return;
const row = currentRows[rowIndex];
if (!row) return;
const value = row[fieldKey];
const error = validateFieldValue(value, field, currentRows, rowIndex);
if (error) {
setError(rowIndex, fieldKey, error);
} else {
clearFieldError(rowIndex, fieldKey);
}
},
[setError, clearFieldError] // Only stable store actions as deps
);
/**
* Validate all fields for a row
*/
const validateRow = useCallback(
async (rowIndex: number, specificFields?: string[]) => {
const { rows: currentRows, fields: currentFields } = useValidationStore.getState();
const row = currentRows[rowIndex];
if (!row) return;
setRowValidationStatus(rowIndex, 'validating');
const fieldsToValidate = specificFields
? currentFields.filter((f: Field<string>) => specificFields.includes(f.key))
: currentFields;
for (const field of fieldsToValidate) {
const value = row[field.key];
const error = validateFieldValue(value, field, currentRows, rowIndex);
if (error) {
setError(rowIndex, field.key, error);
} else {
clearFieldError(rowIndex, field.key);
}
}
// Determine final status based on errors - re-read to get latest
const latestErrors = useValidationStore.getState().errors;
const rowErrors = latestErrors.get(rowIndex);
const hasErrors = rowErrors && Object.keys(rowErrors).length > 0;
setRowValidationStatus(rowIndex, hasErrors ? 'error' : 'validated');
},
[setError, clearFieldError, setRowValidationStatus]
);
/**
* Validate all rows in a SINGLE BATCH operation.
*
* PERFORMANCE CRITICAL: This collects ALL errors first, then sets them in ONE
* store update. This avoids the catastrophic performance of Immer cloning
* Maps/Sets on every individual setError() call.
*
* With 100 rows × 30 fields, the old approach would trigger ~3000 individual
* set() calls, each cloning the entire errors Map. This approach triggers ONE.
*/
const validateAllRows = useCallback(async () => {
const { rows: currentRows, fields: currentFields, setBulkValidationResults } = useValidationStore.getState();
// Collect ALL errors in plain JS Maps (no Immer overhead)
const allErrors = new Map<number, Record<string, ValidationError[]>>();
const allStatuses = new Map<number, 'pending' | 'validating' | 'validated' | 'error'>();
// Process all rows - collect errors without touching the store
for (let rowIndex = 0; rowIndex < currentRows.length; rowIndex++) {
const row = currentRows[rowIndex];
if (!row) continue;
const rowErrors: Record<string, ValidationError[]> = {};
// Validate each field
for (const field of currentFields) {
const value = row[field.key];
const error = validateFieldValue(value, field, currentRows, rowIndex);
if (error) {
rowErrors[field.key] = [error];
}
}
// Store results
if (Object.keys(rowErrors).length > 0) {
allErrors.set(rowIndex, rowErrors);
allStatuses.set(rowIndex, 'error');
} else {
allStatuses.set(rowIndex, 'validated');
}
}
// Also handle unique field validation in the same batch
const uniqueFields = currentFields.filter((f: Field<string>) =>
f.validations?.some((v: Validation) => v.rule === 'unique')
);
for (const field of uniqueFields) {
const valueMap = new Map<string, number[]>();
// Build map of values to row indices
currentRows.forEach((row: RowData, index: number) => {
const value = String(row[field.key] ?? '').toLowerCase().trim();
if (value === '') return;
const indices = valueMap.get(value) || [];
indices.push(index);
valueMap.set(value, indices);
});
// Mark duplicates
valueMap.forEach((indices) => {
if (indices.length > 1) {
const validation = field.validations?.find((v: Validation) => v.rule === 'unique') as
| { rule: 'unique'; errorMessage?: string; level?: 'error' | 'warning' | 'info' }
| undefined;
indices.forEach((rowIndex) => {
// Get or create row errors
let rowErrors = allErrors.get(rowIndex);
if (!rowErrors) {
rowErrors = {};
allErrors.set(rowIndex, rowErrors);
}
rowErrors[field.key] = [{
message: validation?.errorMessage || 'Must be unique',
level: validation?.level || 'error',
source: ErrorSource.Table,
type: ErrorType.Unique,
}];
allStatuses.set(rowIndex, 'error');
});
}
});
}
// SINGLE store update with all validation results
setBulkValidationResults(allErrors, allStatuses);
}, []); // No dependencies - reads everything from getState()
/**
* Validate unique fields across all rows (for table-level validation)
* Used for incremental updates after individual cell changes
*/
const validateUniqueFields = useCallback(() => {
const { rows: currentRows, fields: currentFields, errors: currentErrors } = useValidationStore.getState();
const uniqueFields = currentFields.filter((f: Field<string>) =>
f.validations?.some((v: Validation) => v.rule === 'unique')
);
uniqueFields.forEach((field: Field<string>) => {
const valueMap = new Map<string, number[]>();
// Build map of values to row indices
currentRows.forEach((row: RowData, index: number) => {
const value = String(row[field.key] ?? '').toLowerCase().trim();
if (value === '') return;
const indices = valueMap.get(value) || [];
indices.push(index);
valueMap.set(value, indices);
});
// Mark duplicates
valueMap.forEach((indices) => {
if (indices.length > 1) {
// Multiple rows have the same value - mark all as duplicates
indices.forEach((rowIndex) => {
const validation = field.validations?.find((v: Validation) => v.rule === 'unique') as
| { rule: 'unique'; errorMessage?: string; level?: 'error' | 'warning' | 'info' }
| undefined;
setError(rowIndex, field.key, {
message: validation?.errorMessage || 'Must be unique',
level: validation?.level || 'error',
source: ErrorSource.Table,
type: ErrorType.Unique,
});
});
} else {
// Only one row has this value - clear any unique error
const rowIndex = indices[0];
const rowErrors = currentErrors.get(rowIndex);
const fieldErrors = rowErrors?.[field.key];
if (fieldErrors?.some((e: ValidationError) => e.type === ErrorType.Unique)) {
// Only clear if it was a unique error
clearFieldError(rowIndex, field.key);
}
}
});
});
}, [setError, clearFieldError]);
/**
* Handle cell value change with debounced validation
* IMPORTANT: This callback is stable - no subscriptions, only store actions
*/
const handleCellChange = useCallback(
(rowIndex: number, fieldKey: string, value: unknown) => {
// Update the cell value immediately
updateCell(rowIndex, fieldKey, value);
// Cancel any pending validation for this cell
const cacheKey = `${rowIndex}-${fieldKey}`;
const existingTimeout = validationCache.get(cacheKey);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
// Debounce validation
const timeout = setTimeout(() => {
validateField(rowIndex, fieldKey);
validationCache.delete(cacheKey);
}, 300);
validationCache.set(cacheKey, timeout);
},
[updateCell, validateField]
);
/**
* Handle cell edit start
*/
const handleCellEditStart = useCallback(
(rowIndex: number, fieldKey: string) => {
startEditingCell(rowIndex, fieldKey);
},
[startEditingCell]
);
/**
* Handle cell edit end with immediate validation
* IMPORTANT: This callback is stable - no subscriptions, only store actions
*/
const handleCellEditEnd = useCallback(
(rowIndex: number, fieldKey: string, value: unknown) => {
const { fields: currentFields } = useValidationStore.getState();
stopEditingCell(rowIndex, fieldKey);
// Update value and validate immediately
updateCell(rowIndex, fieldKey, value);
validateField(rowIndex, fieldKey);
// Check unique validation for this field
const field = currentFields.find((f: Field<string>) => f.key === fieldKey);
if (field?.validations?.some((v: Validation) => v.rule === 'unique')) {
validateUniqueFields();
}
},
[stopEditingCell, updateCell, validateField, validateUniqueFields]
);
/**
* Handle row deletion with confirmation
*/
const handleDeleteRows = useCallback(
(rowIndexes: number[]) => {
deleteRows(rowIndexes);
// Re-validate unique fields after deletion
setTimeout(() => validateUniqueFields(), 0);
},
[deleteRows, validateUniqueFields]
);
/**
* Handle adding a new row
*/
const handleAddRow = useCallback(
(rowData?: Partial<RowData>) => {
addRow(rowData);
},
[addRow]
);
/**
* Handle copy down operation
*/
const handleCopyDown = useCallback(
(fromRowIndex: number, fieldKey: string, toRowIndex?: number) => {
const { rows: currentRows, fields: currentFields } = useValidationStore.getState();
copyDown(fromRowIndex, fieldKey, toRowIndex);
// Validate affected rows
const endIndex = toRowIndex ?? currentRows.length - 1;
for (let i = fromRowIndex + 1; i <= endIndex; i++) {
validateField(i, fieldKey);
}
// Check unique validation if applicable
const field = currentFields.find((f: Field<string>) => f.key === fieldKey);
if (field?.validations?.some((v: Validation) => v.rule === 'unique')) {
validateUniqueFields();
}
},
[copyDown, validateField, validateUniqueFields]
);
return {
// Basic operations
updateCell,
updateRow,
handleCellChange,
handleCellEditStart,
handleCellEditEnd,
// Row operations
handleDeleteRows,
handleAddRow,
handleCopyDown,
// Validation
validateField,
validateRow,
validateAllRows,
validateUniqueFields,
// Cell state
startValidatingCell,
stopValidatingCell,
startEditingCell,
stopEditingCell,
// Error management
setError,
clearFieldError,
};
};

View File

@@ -0,0 +1,265 @@
/**
* ValidationStep Entry Point
*
* The main entry component for the validation step. This component:
* 1. Initializes the Zustand store with incoming data
* 2. Loads field options from the API
* 3. Orchestrates the initialization phases
* 4. Renders the ValidationContainer once initialized
*/
import { useEffect, useRef, useDeferredValue } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useValidationStore } from './store/validationStore';
import { useInitPhase, useIsReady } from './store/selectors';
import { ValidationContainer } from './components/ValidationContainer';
import { InitializingOverlay } from './components/InitializingOverlay';
import { useTemplateManagement } from './hooks/useTemplateManagement';
import { useUpcValidation } from './hooks/useUpcValidation';
import { useValidationActions } from './hooks/useValidationActions';
import { useProductLines } from './hooks/useProductLines';
import { BASE_IMPORT_FIELDS } from '../../config';
import config from '@/config';
import type { ValidationStepProps } from './store/types';
import type { Field, SelectOption } from '../../types';
/**
* Fetch field options from the API
*/
const fetchFieldOptions = async () => {
const response = await fetch(`${config.apiUrl}/import/field-options`);
if (!response.ok) {
throw new Error('Failed to fetch field options');
}
return response.json();
};
/**
* Merge API options into base field definitions
*/
const mergeFieldOptions = (
baseFields: typeof BASE_IMPORT_FIELDS,
options: Record<string, SelectOption[]>
): Field<string>[] => {
return baseFields.map((field) => {
// Map field keys to option keys in the API response
// Note: API returns 'sizes' and 'shippingRestrictions' not 'sizeCategories' and 'shipRestrictions'
const optionKeyMap: Record<string, string> = {
supplier: 'suppliers',
company: 'companies',
tax_cat: 'taxCategories',
artist: 'artists',
ship_restrictions: 'shippingRestrictions', // API returns 'shippingRestrictions'
size_cat: 'sizes', // API returns 'sizes'
categories: 'categories',
themes: 'themes',
colors: 'colors',
};
const optionKey = optionKeyMap[field.key];
if (optionKey && options[optionKey]) {
return {
...field,
fieldType: {
...field.fieldType,
options: options[optionKey],
},
} as Field<string>;
}
return field as Field<string>;
});
};
export const ValidationStep = ({
initialData,
file,
onBack,
onNext,
isFromScratch,
}: ValidationStepProps) => {
const initPhase = useInitPhase();
const isReady = useIsReady();
// PERFORMANCE: Defer the ready state to allow React to render heavy content
// in the background while keeping the loading overlay visible.
// This prevents the UI from freezing when ValidationContainer first mounts.
const deferredIsReady = useDeferredValue(isReady);
const isTransitioning = isReady && !deferredIsReady;
const initStartedRef = useRef(false);
const templatesLoadedRef = useRef(false);
const upcValidationStartedRef = useRef(false);
const fieldValidationStartedRef = useRef(false);
// Debug logging
console.log('[ValidationStep] Render - initPhase:', initPhase, 'isReady:', isReady);
// Store actions
const initialize = useValidationStore((state) => state.initialize);
const setFields = useValidationStore((state) => state.setFields);
const setFieldOptionsLoaded = useValidationStore((state) => state.setFieldOptionsLoaded);
const setInitPhase = useValidationStore((state) => state.setInitPhase);
// Initialization hooks
const { loadTemplates } = useTemplateManagement();
const { validateAllUpcs } = useUpcValidation();
const { validateAllRows } = useValidationActions();
const { prefetchAllLines } = useProductLines();
// Fetch field options
const { data: fieldOptions, isLoading: optionsLoading, error: optionsError } = useQuery({
queryKey: ['field-options'],
queryFn: fetchFieldOptions,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: 2,
});
// Initialize store with data
useEffect(() => {
console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase);
// Skip if already initialized (check both ref AND store state)
// The ref prevents double-init within the same mount cycle
// Checking initPhase handles StrictMode remounts where store was initialized but ref persisted
if (initStartedRef.current && initPhase !== 'idle') {
console.log('[ValidationStep] Skipping init - already initialized');
return;
}
initStartedRef.current = true;
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
// Convert initialData to RowData format
const rowData = initialData.map((row, index) => ({
...row,
__index: row.__index || `row-${index}-${Date.now()}`,
}));
// Start with base fields
console.log('[ValidationStep] Calling initialize()');
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
console.log('[ValidationStep] initialize() called');
}, [initialData, file, initialize, initPhase]);
// Update fields when options are loaded
// CRITICAL: Check store state (not ref) because initialize() resets the store
// In StrictMode, effects double-run. If we used a ref:
// 1. First pass: merge runs, ref = true, phase advances
// 2. Second pass: initialize() resets store (phase back to loading-options), but ref is still true, so merge SKIPS
// 3. Phase stuck at loading-options!
// By checking fieldOptionsLoaded from store, we re-run merge after store reset.
useEffect(() => {
const { fieldOptionsLoaded } = useValidationStore.getState();
console.log('[ValidationStep] Field options effect - fieldOptions:', !!fieldOptions, 'initPhase:', initPhase, 'fieldOptionsLoaded:', fieldOptionsLoaded);
// Skip if no options or already loaded in store
if (!fieldOptions || fieldOptionsLoaded) return;
console.log('[ValidationStep] Merging field options');
const mergedFields = mergeFieldOptions(BASE_IMPORT_FIELDS, fieldOptions);
setFields(mergedFields);
setFieldOptionsLoaded(true);
// Move to next phase - use current store state to avoid race condition
// The initPhase variable may be stale if initialization ran in the same cycle
// CRITICAL: Also handle 'idle' phase for when React Query returns cached data
// before the initialization effect has a chance to run
const currentPhase = useValidationStore.getState().initPhase;
console.log('[ValidationStep] Checking phase transition - currentPhase:', currentPhase);
if (currentPhase === 'loading-options' || currentPhase === 'idle') {
console.log('[ValidationStep] Transitioning to loading-templates');
setInitPhase('loading-templates');
}
}, [fieldOptions, initPhase, setFields, setFieldOptionsLoaded, setInitPhase]);
// Note: We intentionally do NOT reset the store on unmount.
// React StrictMode double-mounts components, and resetting on unmount
// causes the store to be cleared before the second mount, while refs
// persist, causing initialization to be skipped.
// The store will be reset when initialize() is called on the next import.
// Load templates when entering loading-templates phase
useEffect(() => {
if (initPhase === 'loading-templates' && !templatesLoadedRef.current) {
templatesLoadedRef.current = true;
loadTemplates().then(() => {
setInitPhase('validating-upcs');
});
}
}, [initPhase, loadTemplates, setInitPhase]);
// Run UPC validation when entering validating-upcs phase
useEffect(() => {
if (initPhase === 'validating-upcs' && !upcValidationStartedRef.current) {
upcValidationStartedRef.current = true;
validateAllUpcs();
}
}, [initPhase, validateAllUpcs]);
// Run field validation when entering validating-fields phase
useEffect(() => {
if (initPhase === 'validating-fields' && !fieldValidationStartedRef.current) {
fieldValidationStartedRef.current = true;
// Run field validation (includes unique validation in batch) and prefetch lines
// NOTE: validateAllRows now handles unique field validation in its batch,
// so we don't call validateUniqueFields() separately here.
Promise.all([
validateAllRows(),
prefetchAllLines(),
]).then(() => {
setInitPhase('ready');
});
}
}, [initPhase, validateAllRows, prefetchAllLines, setInitPhase]);
// Show error state if options failed to load
if (optionsError) {
return (
<div className="flex flex-col items-center justify-center h-[calc(100vh-9.5rem)] gap-4">
<div className="text-destructive text-lg">Failed to load field options</div>
<div className="text-muted-foreground text-sm">
{optionsError instanceof Error ? optionsError.message : 'Unknown error'}
</div>
{onBack && (
<button
onClick={onBack}
className="px-4 py-2 text-sm bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/80"
>
Go Back
</button>
)}
</div>
);
}
// Show loading overlay while initializing
// Use deferredIsReady to keep showing loading state while React processes
// the heavy ValidationContainer in the background
if (!deferredIsReady || optionsLoading) {
return (
<InitializingOverlay
phase={initPhase}
message={
optionsLoading
? 'Loading field options...'
: isTransitioning
? 'Preparing table...'
: undefined
}
/>
);
}
return (
<ValidationContainer
onBack={onBack}
onNext={onNext}
isFromScratch={isFromScratch}
/>
);
};
export default ValidationStep;

View File

@@ -0,0 +1,484 @@
/**
* ValidationStep Store Selectors
*
* Memoized selectors for derived state. Components use these to subscribe
* only to the state they need, preventing unnecessary re-renders.
*/
import { useMemo, useCallback } from 'react';
import { useValidationStore } from './validationStore';
import type { RowData, ValidationError } from './types';
// =============================================================================
// Basic State Selectors
// =============================================================================
export const useRows = () => useValidationStore((state) => state.rows);
export const useFields = () => useValidationStore((state) => state.fields);
export const useErrors = () => useValidationStore((state) => state.errors);
export const useFilters = () => useValidationStore((state) => state.filters);
export const useTemplates = () => useValidationStore((state) => state.templates);
export const useTemplatesLoading = () => useValidationStore((state) => state.templatesLoading);
export const useTemplateState = () => useValidationStore((state) => state.templateState);
export const useSelectedRows = () => useValidationStore((state) => state.selectedRows);
export const useInitPhase = () => useValidationStore((state) => state.initPhase);
export const useAiValidation = () => useValidationStore((state) => state.aiValidation);
export const useFieldOptionsLoaded = () => useValidationStore((state) => state.fieldOptionsLoaded);
// =============================================================================
// Cell State Selectors
// =============================================================================
export const useValidatingCells = () => useValidationStore((state) => state.validatingCells);
export const useEditingCells = () => useValidationStore((state) => state.editingCells);
export const useIsCellValidating = (rowIndex: number, field: string) =>
useValidationStore((state) => state.validatingCells.has(`${rowIndex}-${field}`));
export const useIsCellEditing = (rowIndex: number, field: string) =>
useValidationStore((state) => state.editingCells.has(`${rowIndex}-${field}`));
// =============================================================================
// UPC Selectors
// =============================================================================
export const useUpcStatus = () => useValidationStore((state) => state.upcStatus);
export const useGeneratedItemNumbers = () => useValidationStore((state) => state.generatedItemNumbers);
export const useInitialUpcValidationDone = () => useValidationStore((state) => state.initialUpcValidationDone);
export const useRowUpcStatus = (rowIndex: number) =>
useValidationStore((state) => state.upcStatus.get(rowIndex) || 'idle');
export const useRowItemNumber = (rowIndex: number) =>
useValidationStore((state) => state.generatedItemNumbers.get(rowIndex));
// =============================================================================
// Product Lines Selectors
// =============================================================================
export const useProductLinesCache = () => useValidationStore((state) => state.productLinesCache);
export const useSublinesCache = () => useValidationStore((state) => state.sublinesCache);
export const useProductLinesForCompany = (companyId: string) =>
useValidationStore((state) => state.productLinesCache.get(companyId) || []);
export const useSublinesForLine = (lineId: string) =>
useValidationStore((state) => state.sublinesCache.get(lineId) || []);
export const useIsLoadingProductLines = (companyId: string) =>
useValidationStore((state) => state.loadingProductLines.has(companyId));
export const useIsLoadingSublines = (lineId: string) =>
useValidationStore((state) => state.loadingSublines.has(lineId));
// =============================================================================
// Derived State Selectors (Memoized)
// =============================================================================
/**
* Get filtered rows based on current filter state
*
* PERFORMANCE NOTE: Only subscribes to errors when showErrorsOnly filter is active.
* This prevents re-renders when errors change but we're not filtering by them.
*/
export const useFilteredRows = () => {
const rows = useRows();
const filters = useFilters();
// Only subscribe to errors when we need to filter by them
const showErrorsOnly = filters.showErrorsOnly;
const errors = useValidationStore((state) =>
showErrorsOnly ? state.errors : null
);
return useMemo(() => {
// No filtering needed if no filters active
if (!filters.searchText && !showErrorsOnly) {
return rows;
}
// Filter with index tracking to correctly apply errors filter
const result: RowData[] = [];
rows.forEach((row, originalIndex) => {
// Apply search filter
if (filters.searchText) {
const searchLower = filters.searchText.toLowerCase();
const matches = Object.values(row).some((value) =>
String(value ?? '').toLowerCase().includes(searchLower)
);
if (!matches) return;
}
// Apply errors-only filter using the ORIGINAL index (not filtered position)
if (showErrorsOnly && errors) {
const rowErrors = errors.get(originalIndex);
if (!rowErrors || Object.keys(rowErrors).length === 0) return;
}
result.push(row);
});
return result;
}, [rows, filters, showErrorsOnly, errors]);
};
/**
* Get filtered row indices (useful for mapping back to original data)
*
* PERFORMANCE NOTE: Only subscribes to errors when showErrorsOnly filter is active.
*/
export const useFilteredRowIndices = () => {
const rows = useRows();
const filters = useFilters();
// Only subscribe to errors when we need to filter by them
const showErrorsOnly = filters.showErrorsOnly;
const errors = useValidationStore((state) =>
showErrorsOnly ? state.errors : null
);
return useMemo(() => {
// No filtering needed if no filters active - return all indices
if (!filters.searchText && !showErrorsOnly) {
return rows.map((_, index) => index);
}
const indices: number[] = [];
rows.forEach((row, index) => {
// Apply search filter
if (filters.searchText) {
const searchLower = filters.searchText.toLowerCase();
const matches = Object.values(row).some((value) =>
String(value ?? '').toLowerCase().includes(searchLower)
);
if (!matches) return;
}
// Apply errors-only filter
if (showErrorsOnly && errors) {
const rowErrors = errors.get(index);
if (!rowErrors || Object.keys(rowErrors).length === 0) return;
}
indices.push(index);
});
return indices;
}, [rows, filters, showErrorsOnly, errors]);
};
/**
* Get total error count across all rows
*/
export const useTotalErrorCount = () => {
const errors = useErrors();
return useMemo(() => {
let count = 0;
errors.forEach((rowErrors) => {
count += Object.keys(rowErrors).length;
});
return count;
}, [errors]);
};
/**
* Get rows with errors count
*/
export const useRowsWithErrorsCount = () => {
const errors = useErrors();
return useMemo(() => {
let count = 0;
errors.forEach((rowErrors) => {
if (Object.keys(rowErrors).length > 0) {
count++;
}
});
return count;
}, [errors]);
};
// Stable empty array for cells with no errors
const EMPTY_CELL_ERRORS: ValidationError[] = [];
// Stable empty object for rows with no errors
const EMPTY_ROW_ERRORS: Record<string, ValidationError[]> = {};
/**
* Get errors for a specific row
* Uses stable empty object to prevent re-renders
*/
export const useRowErrors = (rowIndex: number) =>
useValidationStore((state) => state.errors.get(rowIndex) || EMPTY_ROW_ERRORS);
/**
* Get errors for a specific cell
* Uses stable empty array to prevent re-renders
*/
export const useCellErrors = (rowIndex: number, field: string): ValidationError[] => {
return useValidationStore((state) => {
const rowErrors = state.errors.get(rowIndex);
if (!rowErrors) return EMPTY_CELL_ERRORS;
return rowErrors[field] || EMPTY_CELL_ERRORS;
});
};
/**
* Check if a row has any errors
*/
export const useRowHasErrors = (rowIndex: number) => {
const rowErrors = useRowErrors(rowIndex);
return Object.keys(rowErrors).length > 0;
};
/**
* Get row data by index
*/
export const useRowData = (rowIndex: number): RowData | undefined =>
useValidationStore((state) => state.rows[rowIndex]);
/**
* Get row data by __index (UUID)
*/
export const useRowByIndex = (index: string): RowData | undefined =>
useValidationStore((state) => state.rows.find((row) => row.__index === index));
/**
* Check if a row is selected
*/
export const useIsRowSelected = (rowId: string) =>
useValidationStore((state) => state.selectedRows.has(rowId));
/**
* Get selected row count
*/
export const useSelectedRowCount = () =>
useValidationStore((state) => state.selectedRows.size);
/**
* Get selected row data
*/
export const useSelectedRowData = () => {
const rows = useRows();
const selectedRows = useSelectedRows();
return useMemo(() => {
return rows.filter((row) => selectedRows.has(row.__index));
}, [rows, selectedRows]);
};
/**
* Get selected row indices (numeric)
*/
export const useSelectedRowIndices = () => {
const rows = useRows();
const selectedRows = useSelectedRows();
return useMemo(() => {
const indices: number[] = [];
rows.forEach((row, index) => {
if (selectedRows.has(row.__index)) {
indices.push(index);
}
});
return indices;
}, [rows, selectedRows]);
};
/**
* Check if all rows are selected
*/
export const useAllRowsSelected = () => {
const rows = useRows();
const selectedRows = useSelectedRows();
return useMemo(() => {
if (rows.length === 0) return false;
return selectedRows.size === rows.length;
}, [rows, selectedRows]);
};
/**
* Check if some (but not all) rows are selected
*/
export const useSomeRowsSelected = () => {
const rows = useRows();
const selectedRows = useSelectedRows();
return useMemo(() => {
return selectedRows.size > 0 && selectedRows.size < rows.length;
}, [rows, selectedRows]);
};
// =============================================================================
// Initialization Selectors
// =============================================================================
export const useIsInitializing = () =>
useValidationStore((state) => state.initPhase !== 'ready' && state.initPhase !== 'idle');
export const useIsReady = () =>
useValidationStore((state) => state.initPhase === 'ready');
// =============================================================================
// AI Validation Selectors
// =============================================================================
export const useIsAiValidating = () =>
useValidationStore((state) => state.aiValidation.isRunning);
export const useAiValidationProgress = () =>
useValidationStore((state) => state.aiValidation.progress);
export const useAiValidationResults = () =>
useValidationStore((state) => state.aiValidation.results);
export const useIsAiChangeReverted = (productIndex: number, fieldKey: string) =>
useValidationStore((state) =>
state.aiValidation.revertedChanges.has(`${productIndex}:${fieldKey}`)
);
/**
* Get count of AI changes (not reverted)
*/
export const useActiveAiChangesCount = () => {
const aiValidation = useAiValidation();
return useMemo(() => {
if (!aiValidation.results) return 0;
return aiValidation.results.changes.filter(
(change) => !aiValidation.revertedChanges.has(`${change.productIndex}:${change.fieldKey}`)
).length;
}, [aiValidation]);
};
// =============================================================================
// Field Selectors
// =============================================================================
/**
* Get field definition by key
*/
export const useFieldByKey = (key: string) => {
const fields = useFields();
return useMemo(() => fields.find((f) => f.key === key), [fields, key]);
};
/**
* Get field options for a select field
*/
export const useFieldOptions = (fieldKey: string) => {
const field = useFieldByKey(fieldKey);
return useMemo(() => {
if (!field) return [];
if (field.fieldType.type === 'select' || field.fieldType.type === 'multi-select') {
return field.fieldType.options || [];
}
return [];
}, [field]);
};
// =============================================================================
// Copy-Down Mode Selectors
// =============================================================================
/**
* Get the full copy-down mode state
*/
export const useCopyDownMode = () =>
useValidationStore((state) => state.copyDownMode);
/**
* Check if copy-down mode is active (returns boolean for minimal subscription)
*/
export const useIsCopyDownActive = () =>
useValidationStore((state) => state.copyDownMode.isActive);
/**
* Check if a specific cell is the copy-down source
* Returns boolean - minimal subscription, only re-renders when this specific cell's status changes
*/
export const useIsCopyDownSource = (rowIndex: number, fieldKey: string) =>
useValidationStore((state) =>
state.copyDownMode.isActive &&
state.copyDownMode.sourceRowIndex === rowIndex &&
state.copyDownMode.sourceFieldKey === fieldKey
);
/**
* Check if a cell is a valid copy-down target (below source row, same field)
* Returns boolean - minimal subscription
*/
export const useIsCopyDownTarget = (rowIndex: number, fieldKey: string) =>
useValidationStore((state) => {
if (!state.copyDownMode.isActive) return false;
if (state.copyDownMode.sourceFieldKey !== fieldKey) return false;
const sourceIndex = state.copyDownMode.sourceRowIndex;
if (sourceIndex === null) return false;
return rowIndex > sourceIndex;
});
/**
* Check if a cell is within the current copy-down hover range
* Returns boolean - for highlighting rows that will be affected
*/
export const useIsInCopyDownRange = (rowIndex: number, fieldKey: string) =>
useValidationStore((state) => {
if (!state.copyDownMode.isActive) return false;
if (state.copyDownMode.sourceFieldKey !== fieldKey) return false;
const sourceIndex = state.copyDownMode.sourceRowIndex;
const targetIndex = state.copyDownMode.targetRowIndex;
if (sourceIndex === null || targetIndex === null) return false;
return rowIndex > sourceIndex && rowIndex <= targetIndex;
});
// =============================================================================
// Dialog Selectors
// =============================================================================
/**
* Get full dialog state
*/
export const useDialogs = () =>
useValidationStore((state) => state.dialogs);
/**
* Check if template form dialog is open
*/
export const useIsTemplateFormOpen = () =>
useValidationStore((state) => state.dialogs.templateFormOpen);
/**
* Get template form initial data
*/
export const useTemplateFormData = () =>
useValidationStore((state) => state.dialogs.templateFormData);
/**
* Check if create category dialog is open
*/
export const useIsCreateCategoryOpen = () =>
useValidationStore((state) => state.dialogs.createCategoryOpen);
// =============================================================================
// Selection Helper Selectors
// =============================================================================
/**
* Check if there is any selection (returns boolean)
*/
export const useHasSelection = () =>
useValidationStore((state) => state.selectedRows.size > 0);
/**
* Check if exactly one row is selected (for "Save as Template")
*/
export const useHasSingleRowSelected = () =>
useValidationStore((state) => state.selectedRows.size === 1);
/**
* Get row count (useful for copy-down to check if there are rows below)
*/
export const useRowCount = () =>
useValidationStore((state) => state.rows.length);

View File

@@ -0,0 +1,401 @@
/**
* ValidationStep Store Types
*
* Comprehensive type definitions for the Zustand-based validation store.
* These types define the shape of all state and actions in the validation flow.
*/
import type { Field, SelectOption, ErrorLevel } from '../../../types';
// =============================================================================
// Core Data Types
// =============================================================================
/**
* Extended row data with metadata fields used during validation.
* The __ prefixed fields are internal and stripped before output.
*/
export interface RowData {
__index: string; // Unique row identifier (UUID)
__template?: string; // Applied template ID
__original?: Record<string, unknown>; // Original values before AI changes
__corrected?: Record<string, unknown>; // AI-corrected values
__changes?: Record<string, boolean>; // Fields changed by AI
__aiSupplemental?: string[]; // AI supplemental columns from MatchColumnsStep
// Standard fields (from config.ts)
supplier?: string;
company?: string;
line?: string;
subline?: string;
upc?: string;
item_number?: string;
supplier_no?: string;
notions_no?: string;
name?: string;
msrp?: string;
qty_per_unit?: string;
cost_each?: string;
case_qty?: string;
tax_cat?: string;
artist?: string;
eta?: string;
weight?: string;
length?: string;
width?: string;
height?: string;
ship_restrictions?: string;
coo?: string;
hts_code?: string;
size_cat?: string;
description?: string;
priv_notes?: string;
categories?: string | string[];
themes?: string | string[];
colors?: string | string[];
product_images?: string;
// Allow dynamic field access
[key: string]: unknown;
}
/**
* Clean row data without metadata (output format for ImageUploadStep)
*/
export type CleanRowData = Omit<RowData, '__index' | '__template' | '__original' | '__corrected' | '__changes' | '__aiSupplemental'>;
// =============================================================================
// Validation Types
// =============================================================================
export enum ErrorSource {
Row = 'row',
Table = 'table',
Api = 'api',
Upc = 'upc'
}
export enum ErrorType {
Required = 'required',
Regex = 'regex',
Unique = 'unique',
Custom = 'custom',
Api = 'api'
}
export interface ValidationError {
message: string;
level: ErrorLevel;
source: ErrorSource;
type: ErrorType;
}
/**
* Validation errors organized by row and field
* Structure: Map<rowIndex, Record<fieldKey, ValidationError[]>>
*/
export type ValidationErrors = Map<number, Record<string, ValidationError[]>>;
export type RowValidationStatus = 'pending' | 'validating' | 'validated' | 'error';
// =============================================================================
// UPC Validation Types
// =============================================================================
export type UpcValidationStatus = 'idle' | 'validating' | 'done' | 'error';
export interface UpcValidationResult {
success: boolean;
itemNumber?: string;
error?: string;
code?: 'conflict' | 'http_error' | 'invalid_response' | 'network_error';
}
// =============================================================================
// Template Types
// =============================================================================
export interface Template {
id: number;
company: string;
product_type: string;
[key: string]: string | number | boolean | undefined;
}
export interface TemplateState {
selectedTemplateId: string | null;
showSaveDialog: boolean;
newTemplateName: string;
newTemplateType: string;
}
// =============================================================================
// Filter Types
// =============================================================================
export interface FilterState {
searchText: string;
showErrorsOnly: boolean;
filterField: string | null;
filterValue: string | null;
}
// =============================================================================
// Copy-Down Mode Types
// =============================================================================
export interface CopyDownState {
isActive: boolean;
sourceRowIndex: number | null;
sourceFieldKey: string | null;
targetRowIndex: number | null; // Hover preview - which row the user is hovering on
}
// =============================================================================
// Dialog State Types
// =============================================================================
export interface DialogState {
templateFormOpen: boolean;
templateFormData: Record<string, unknown> | null;
createCategoryOpen: boolean;
}
// =============================================================================
// AI Validation Types
// =============================================================================
export interface AiValidationProgress {
current: number;
total: number;
status: 'preparing' | 'validating' | 'processing' | 'complete' | 'error';
message?: string;
startTime: number;
estimatedTimeRemaining?: number;
}
export interface AiValidationChange {
productIndex: number;
fieldKey: string;
originalValue: unknown;
correctedValue: unknown;
confidence?: number;
}
export interface AiValidationResults {
totalProducts: number;
productsWithChanges: number;
changes: AiValidationChange[];
tokenUsage?: {
input: number;
output: number;
};
processingTime: number;
}
export interface AiValidationState {
isRunning: boolean;
progress: AiValidationProgress | null;
results: AiValidationResults | null;
revertedChanges: Set<string>; // Format: "productIndex:fieldKey"
}
// =============================================================================
// Initialization Types
// =============================================================================
export type InitPhase =
| 'idle'
| 'loading-options'
| 'loading-templates'
| 'validating-upcs'
| 'validating-fields'
| 'ready';
// =============================================================================
// Field Options Types (from API)
// =============================================================================
export interface FieldOptionsData {
suppliers: SelectOption[];
companies: SelectOption[];
taxCategories: SelectOption[];
artists: SelectOption[];
shipRestrictions: SelectOption[];
sizeCategories: SelectOption[];
categories: SelectOption[];
themes: SelectOption[];
colors: SelectOption[];
}
// =============================================================================
// Store State Interface
// =============================================================================
export interface ValidationState {
// === Core Data ===
rows: RowData[];
originalRows: RowData[]; // For AI revert functionality
// === Field Configuration ===
fields: Field<string>[];
fieldOptionsLoaded: boolean;
// === Validation ===
errors: ValidationErrors;
rowValidationStatus: Map<number, RowValidationStatus>;
// === Cell States ===
validatingCells: Set<string>; // Format: "rowIndex-fieldKey"
editingCells: Set<string>;
// === UPC Validation ===
upcStatus: Map<number, UpcValidationStatus>;
generatedItemNumbers: Map<number, string>;
upcCache: Map<string, string>; // Format: "supplierId-upc" -> itemNumber
initialUpcValidationDone: boolean;
// === Product Lines (hierarchical) ===
productLinesCache: Map<string, SelectOption[]>; // companyId -> lines
sublinesCache: Map<string, SelectOption[]>; // lineId -> sublines
loadingProductLines: Set<string>; // companyIds being loaded
loadingSublines: Set<string>; // lineIds being loaded
// === Templates ===
templates: Template[];
templatesLoading: boolean;
templateState: TemplateState;
// === Filters ===
filters: FilterState;
// === Row Selection ===
selectedRows: Set<string>; // Uses __index as key
// === Copy-Down Mode ===
copyDownMode: CopyDownState;
// === Dialogs ===
dialogs: DialogState;
// === Initialization ===
initPhase: InitPhase;
// === AI Validation ===
aiValidation: AiValidationState;
// === File (for output) ===
file: File | null;
}
// =============================================================================
// Store Actions Interface
// =============================================================================
export interface ValidationActions {
// === Initialization ===
initialize: (data: RowData[], fields: Field<string>[], file?: File) => Promise<void>;
setFields: (fields: Field<string>[]) => void;
setFieldOptionsLoaded: (loaded: boolean) => void;
// === Row Operations ===
updateCell: (rowIndex: number, field: string, value: unknown) => void;
updateRow: (rowIndex: number, updates: Partial<RowData>) => void;
deleteRows: (rowIndexes: number[]) => void;
addRow: (rowData?: Partial<RowData>) => void;
copyDown: (fromRowIndex: number, fieldKey: string, toRowIndex?: number) => void;
setRows: (rows: RowData[]) => void;
// === Validation ===
setError: (rowIndex: number, field: string, error: ValidationError | null) => void;
setErrors: (rowIndex: number, errors: Record<string, ValidationError[]>) => void;
setBulkValidationResults: (
allErrors: Map<number, Record<string, ValidationError[]>>,
allStatuses: Map<number, RowValidationStatus>
) => void;
clearRowErrors: (rowIndex: number) => void;
clearFieldError: (rowIndex: number, field: string) => void;
setRowValidationStatus: (rowIndex: number, status: RowValidationStatus) => void;
// === Cell States ===
startValidatingCell: (rowIndex: number, field: string) => void;
stopValidatingCell: (rowIndex: number, field: string) => void;
startEditingCell: (rowIndex: number, field: string) => void;
stopEditingCell: (rowIndex: number, field: string) => void;
// === UPC ===
setUpcStatus: (rowIndex: number, status: UpcValidationStatus) => void;
setGeneratedItemNumber: (rowIndex: number, itemNumber: string) => void;
cacheUpcResult: (supplierId: string, upc: string, itemNumber: string) => void;
getCachedItemNumber: (supplierId: string, upc: string) => string | undefined;
setInitialUpcValidationDone: (done: boolean) => void;
// === Product Lines ===
setProductLines: (companyId: string, lines: SelectOption[]) => void;
setSublines: (lineId: string, sublines: SelectOption[]) => void;
setLoadingProductLines: (companyId: string, loading: boolean) => void;
setLoadingSublines: (lineId: string, loading: boolean) => void;
// === Templates ===
setTemplates: (templates: Template[]) => void;
setTemplatesLoading: (loading: boolean) => void;
setTemplateState: (state: Partial<TemplateState>) => void;
// === Filters ===
setSearchText: (text: string) => void;
setShowErrorsOnly: (value: boolean) => void;
setFilters: (filters: Partial<FilterState>) => void;
// === Row Selection ===
setSelectedRows: (rows: Set<string>) => void;
toggleRowSelection: (rowId: string) => void;
selectAllRows: () => void;
clearSelection: () => void;
// === Copy-Down Mode ===
setCopyDownMode: (mode: Partial<CopyDownState>) => void;
startCopyDown: (rowIndex: number, fieldKey: string) => void;
cancelCopyDown: () => void;
completeCopyDown: (targetRowIndex: number) => void;
setTargetRowHover: (rowIndex: number | null) => void;
// === Dialogs ===
setDialogs: (updates: Partial<DialogState>) => void;
openTemplateForm: (initialData: Record<string, unknown>) => void;
closeTemplateForm: () => void;
// === Initialization Phase ===
setInitPhase: (phase: InitPhase) => void;
// === AI Validation ===
setAiValidationRunning: (running: boolean) => void;
setAiValidationProgress: (progress: AiValidationProgress | null) => void;
setAiValidationResults: (results: AiValidationResults | null) => void;
revertAiChange: (productIndex: number, fieldKey: string) => void;
clearAiValidation: () => void;
storeOriginalValues: () => void;
// === Output ===
getCleanedData: () => CleanRowData[];
// === Reset ===
reset: () => void;
}
// =============================================================================
// Combined Store Type
// =============================================================================
export type ValidationStore = ValidationState & ValidationActions;
// =============================================================================
// Component Props Types
// =============================================================================
export interface ValidationStepProps {
initialData: RowData[];
file?: File;
onBack?: () => void;
onNext?: (data: CleanRowData[]) => void;
isFromScratch?: boolean;
}

View File

@@ -0,0 +1,704 @@
/**
* ValidationStep Zustand Store
*
* Single source of truth for all validation state.
* Uses immer for immutable updates and subscribeWithSelector for efficient subscriptions.
*/
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { subscribeWithSelector } from 'zustand/middleware';
import { enableMapSet } from 'immer';
import { v4 as uuidv4 } from 'uuid';
// Enable Map and Set support in Immer
enableMapSet();
import type {
ValidationStore,
ValidationState,
RowData,
CleanRowData,
ValidationError,
RowValidationStatus,
UpcValidationStatus,
Template,
TemplateState,
FilterState,
InitPhase,
AiValidationProgress,
AiValidationResults,
CopyDownState,
DialogState,
} from './types';
import type { Field, SelectOption } from '../../../types';
// =============================================================================
// Initial State
// =============================================================================
const initialTemplateState: TemplateState = {
selectedTemplateId: null,
showSaveDialog: false,
newTemplateName: '',
newTemplateType: '',
};
const initialFilterState: FilterState = {
searchText: '',
showErrorsOnly: false,
filterField: null,
filterValue: null,
};
const initialCopyDownState: CopyDownState = {
isActive: false,
sourceRowIndex: null,
sourceFieldKey: null,
targetRowIndex: null,
};
const initialDialogState: DialogState = {
templateFormOpen: false,
templateFormData: null,
createCategoryOpen: false,
};
const getInitialState = (): ValidationState => ({
// Core Data
rows: [],
originalRows: [],
// Field Configuration
fields: [],
fieldOptionsLoaded: false,
// Validation
errors: new Map(),
rowValidationStatus: new Map(),
// Cell States
validatingCells: new Set(),
editingCells: new Set(),
// UPC Validation
upcStatus: new Map(),
generatedItemNumbers: new Map(),
upcCache: new Map(),
initialUpcValidationDone: false,
// Product Lines
productLinesCache: new Map(),
sublinesCache: new Map(),
loadingProductLines: new Set(),
loadingSublines: new Set(),
// Templates
templates: [],
templatesLoading: false,
templateState: { ...initialTemplateState },
// Filters
filters: { ...initialFilterState },
// Row Selection
selectedRows: new Set(),
// Copy-Down Mode
copyDownMode: { ...initialCopyDownState },
// Dialogs
dialogs: { ...initialDialogState },
// Initialization
initPhase: 'idle',
// AI Validation
aiValidation: {
isRunning: false,
progress: null,
results: null,
revertedChanges: new Set(),
},
// File
file: null,
});
// =============================================================================
// Store Creation
// =============================================================================
export const useValidationStore = create<ValidationStore>()(
subscribeWithSelector(
immer((set, get) => ({
...getInitialState(),
// =========================================================================
// Initialization
// =========================================================================
initialize: async (data: RowData[], fields: Field<string>[], file?: File) => {
console.log('[ValidationStore] initialize() called with', data.length, 'rows');
// First, get a fresh initial state to ensure clean slate
const freshState = getInitialState();
set((state) => {
console.log('[ValidationStore] Inside set callback, current initPhase:', state.initPhase);
// Apply fresh state first (clean slate)
Object.assign(state, freshState);
// Then set up with new data
state.rows = data.map((row) => ({
...row,
__index: row.__index || uuidv4(),
}));
state.originalRows = JSON.parse(JSON.stringify(state.rows));
// Cast to bypass immer's strict readonly type checking
state.fields = fields as unknown as typeof state.fields;
state.file = file || null;
state.initPhase = 'loading-options';
console.log('[ValidationStore] Set initPhase to loading-options');
});
console.log('[ValidationStore] initialize() complete, new initPhase:', get().initPhase);
},
setFields: (fields: Field<string>[]) => {
set((state) => {
// Cast to bypass immer's strict readonly type checking
state.fields = fields as unknown as typeof state.fields;
});
},
setFieldOptionsLoaded: (loaded: boolean) => {
set((state) => {
state.fieldOptionsLoaded = loaded;
});
},
// =========================================================================
// Row Operations
// =========================================================================
updateCell: (rowIndex: number, field: string, value: unknown) => {
set((state) => {
if (state.rows[rowIndex]) {
state.rows[rowIndex][field] = value;
}
});
},
updateRow: (rowIndex: number, updates: Partial<RowData>) => {
set((state) => {
if (state.rows[rowIndex]) {
Object.assign(state.rows[rowIndex], updates);
}
});
},
deleteRows: (rowIndexes: number[]) => {
set((state) => {
// Sort descending to delete from end first (preserves indices)
const sorted = [...rowIndexes].sort((a, b) => b - a);
sorted.forEach((index) => {
if (index >= 0 && index < state.rows.length) {
state.rows.splice(index, 1);
state.errors.delete(index);
state.rowValidationStatus.delete(index);
state.upcStatus.delete(index);
state.generatedItemNumbers.delete(index);
}
});
// Reindex remaining Maps after deletion
// This is necessary because we use numeric indices as keys
const reindexMap = <T>(map: Map<number, T>): Map<number, T> => {
const entries = Array.from(map.entries())
.filter(([idx]) => !rowIndexes.includes(idx))
.sort(([a], [b]) => a - b);
const newMap = new Map<number, T>();
let newIndex = 0;
entries.forEach(([, value]) => {
newMap.set(newIndex++, value);
});
return newMap;
};
state.errors = reindexMap(state.errors) as typeof state.errors;
state.rowValidationStatus = reindexMap(state.rowValidationStatus);
state.upcStatus = reindexMap(state.upcStatus);
state.generatedItemNumbers = reindexMap(state.generatedItemNumbers);
});
},
addRow: (rowData?: Partial<RowData>) => {
set((state) => {
const newRow: RowData = {
__index: uuidv4(),
...rowData,
};
state.rows.push(newRow);
});
},
copyDown: (fromRowIndex: number, fieldKey: string, toRowIndex?: number) => {
set((state) => {
const sourceValue = state.rows[fromRowIndex]?.[fieldKey];
if (sourceValue === undefined) return;
const endIndex = toRowIndex ?? state.rows.length - 1;
for (let i = fromRowIndex + 1; i <= endIndex; i++) {
if (state.rows[i]) {
state.rows[i][fieldKey] = sourceValue;
}
}
});
},
setRows: (rows: RowData[]) => {
set((state) => {
state.rows = rows;
});
},
// =========================================================================
// Validation
// =========================================================================
setError: (rowIndex: number, field: string, error: ValidationError | null) => {
set((state) => {
if (!state.errors.has(rowIndex)) {
state.errors.set(rowIndex, {});
}
const rowErrors = state.errors.get(rowIndex)!;
if (error === null) {
delete rowErrors[field];
if (Object.keys(rowErrors).length === 0) {
state.errors.delete(rowIndex);
}
} else {
rowErrors[field] = [error];
}
});
},
setErrors: (rowIndex: number, errors: Record<string, ValidationError[]>) => {
set((state) => {
if (Object.keys(errors).length === 0) {
state.errors.delete(rowIndex);
} else {
state.errors.set(rowIndex, errors);
}
});
},
/**
* PERFORMANCE: Batch set all errors and row statuses in a single store update.
* This avoids the O(n) Immer Map/Set cloning that happens on every individual set() call.
* Use this for bulk validation operations like validateAllRows.
*/
setBulkValidationResults: (
allErrors: Map<number, Record<string, ValidationError[]>>,
allStatuses: Map<number, RowValidationStatus>
) => {
set((state) => {
// Replace entire errors map
state.errors = allErrors as typeof state.errors;
// Replace entire status map
state.rowValidationStatus = allStatuses;
});
},
clearRowErrors: (rowIndex: number) => {
set((state) => {
state.errors.delete(rowIndex);
});
},
clearFieldError: (rowIndex: number, field: string) => {
set((state) => {
const rowErrors = state.errors.get(rowIndex);
if (rowErrors) {
delete rowErrors[field];
if (Object.keys(rowErrors).length === 0) {
state.errors.delete(rowIndex);
}
}
});
},
setRowValidationStatus: (rowIndex: number, status: RowValidationStatus) => {
set((state) => {
state.rowValidationStatus.set(rowIndex, status);
});
},
// =========================================================================
// Cell States
// =========================================================================
startValidatingCell: (rowIndex: number, field: string) => {
set((state) => {
state.validatingCells.add(`${rowIndex}-${field}`);
});
},
stopValidatingCell: (rowIndex: number, field: string) => {
set((state) => {
state.validatingCells.delete(`${rowIndex}-${field}`);
});
},
startEditingCell: (rowIndex: number, field: string) => {
set((state) => {
state.editingCells.add(`${rowIndex}-${field}`);
});
},
stopEditingCell: (rowIndex: number, field: string) => {
set((state) => {
state.editingCells.delete(`${rowIndex}-${field}`);
});
},
// =========================================================================
// UPC Validation
// =========================================================================
setUpcStatus: (rowIndex: number, status: UpcValidationStatus) => {
set((state) => {
state.upcStatus.set(rowIndex, status);
});
},
setGeneratedItemNumber: (rowIndex: number, itemNumber: string) => {
set((state) => {
state.generatedItemNumbers.set(rowIndex, itemNumber);
// Also update the row data
if (state.rows[rowIndex]) {
state.rows[rowIndex].item_number = itemNumber;
}
});
},
cacheUpcResult: (supplierId: string, upc: string, itemNumber: string) => {
set((state) => {
state.upcCache.set(`${supplierId}-${upc}`, itemNumber);
});
},
getCachedItemNumber: (supplierId: string, upc: string) => {
return get().upcCache.get(`${supplierId}-${upc}`);
},
setInitialUpcValidationDone: (done: boolean) => {
set((state) => {
state.initialUpcValidationDone = done;
});
},
// =========================================================================
// Product Lines
// =========================================================================
setProductLines: (companyId: string, lines: SelectOption[]) => {
set((state) => {
state.productLinesCache.set(companyId, lines);
});
},
setSublines: (lineId: string, sublines: SelectOption[]) => {
set((state) => {
state.sublinesCache.set(lineId, sublines);
});
},
setLoadingProductLines: (companyId: string, loading: boolean) => {
set((state) => {
if (loading) {
state.loadingProductLines.add(companyId);
} else {
state.loadingProductLines.delete(companyId);
}
});
},
setLoadingSublines: (lineId: string, loading: boolean) => {
set((state) => {
if (loading) {
state.loadingSublines.add(lineId);
} else {
state.loadingSublines.delete(lineId);
}
});
},
// =========================================================================
// Templates
// =========================================================================
setTemplates: (templates: Template[]) => {
set((state) => {
state.templates = templates;
});
},
setTemplatesLoading: (loading: boolean) => {
set((state) => {
state.templatesLoading = loading;
});
},
setTemplateState: (updates: Partial<TemplateState>) => {
set((state) => {
Object.assign(state.templateState, updates);
});
},
// =========================================================================
// Filters
// =========================================================================
setSearchText: (text: string) => {
set((state) => {
state.filters.searchText = text;
});
},
setShowErrorsOnly: (value: boolean) => {
set((state) => {
state.filters.showErrorsOnly = value;
});
},
setFilters: (updates: Partial<FilterState>) => {
set((state) => {
Object.assign(state.filters, updates);
});
},
// =========================================================================
// Row Selection
// =========================================================================
setSelectedRows: (rows: Set<string>) => {
set((state) => {
state.selectedRows = rows;
});
},
toggleRowSelection: (rowId: string) => {
set((state) => {
if (state.selectedRows.has(rowId)) {
state.selectedRows.delete(rowId);
} else {
state.selectedRows.add(rowId);
}
});
},
selectAllRows: () => {
set((state) => {
state.selectedRows = new Set(state.rows.map((row: RowData) => row.__index));
});
},
clearSelection: () => {
set((state) => {
state.selectedRows = new Set();
});
},
// =========================================================================
// Copy-Down Mode
// =========================================================================
setCopyDownMode: (mode: Partial<CopyDownState>) => {
set((state) => {
Object.assign(state.copyDownMode, mode);
});
},
startCopyDown: (rowIndex: number, fieldKey: string) => {
set((state) => {
state.copyDownMode = {
isActive: true,
sourceRowIndex: rowIndex,
sourceFieldKey: fieldKey,
targetRowIndex: null,
};
});
},
cancelCopyDown: () => {
set((state) => {
state.copyDownMode = { ...initialCopyDownState };
});
},
completeCopyDown: (targetRowIndex: number) => {
const { copyDownMode } = get();
if (!copyDownMode.isActive || copyDownMode.sourceRowIndex === null || !copyDownMode.sourceFieldKey) {
return;
}
// First, perform the copy operation
set((state) => {
const sourceValue = state.rows[copyDownMode.sourceRowIndex!]?.[copyDownMode.sourceFieldKey!];
if (sourceValue === undefined) return;
// Clone value for arrays/objects to prevent reference sharing
const cloneValue = (val: unknown): unknown => {
if (Array.isArray(val)) return [...val];
if (val && typeof val === 'object') return { ...val };
return val;
};
for (let i = copyDownMode.sourceRowIndex! + 1; i <= targetRowIndex; i++) {
if (state.rows[i]) {
state.rows[i][copyDownMode.sourceFieldKey!] = cloneValue(sourceValue);
}
}
// Reset copy-down mode
state.copyDownMode = { ...initialCopyDownState };
});
},
setTargetRowHover: (rowIndex: number | null) => {
set((state) => {
if (state.copyDownMode.isActive) {
state.copyDownMode.targetRowIndex = rowIndex;
}
});
},
// =========================================================================
// Dialogs
// =========================================================================
setDialogs: (updates: Partial<DialogState>) => {
set((state) => {
Object.assign(state.dialogs, updates);
});
},
openTemplateForm: (initialData: Record<string, unknown>) => {
set((state) => {
state.dialogs.templateFormOpen = true;
state.dialogs.templateFormData = initialData;
});
},
closeTemplateForm: () => {
set((state) => {
state.dialogs.templateFormOpen = false;
state.dialogs.templateFormData = null;
});
},
// =========================================================================
// Initialization Phase
// =========================================================================
setInitPhase: (phase: InitPhase) => {
set((state) => {
state.initPhase = phase;
});
},
// =========================================================================
// AI Validation
// =========================================================================
setAiValidationRunning: (running: boolean) => {
set((state) => {
state.aiValidation.isRunning = running;
});
},
setAiValidationProgress: (progress: AiValidationProgress | null) => {
set((state) => {
state.aiValidation.progress = progress;
});
},
setAiValidationResults: (results: AiValidationResults | null) => {
set((state) => {
state.aiValidation.results = results;
});
},
revertAiChange: (productIndex: number, fieldKey: string) => {
set((state) => {
const key = `${productIndex}:${fieldKey}`;
const row = state.rows[productIndex];
if (row && row.__original && fieldKey in row.__original) {
// Revert to original value
row[fieldKey] = row.__original[fieldKey];
// Mark as reverted
state.aiValidation.revertedChanges.add(key);
// Clear the change marker
if (row.__changes) {
delete row.__changes[fieldKey];
}
}
});
},
clearAiValidation: () => {
set((state) => {
state.aiValidation = {
isRunning: false,
progress: null,
results: null,
revertedChanges: new Set(),
};
});
},
storeOriginalValues: () => {
set((state) => {
state.rows.forEach((row: RowData) => {
row.__original = { ...row };
});
});
},
// =========================================================================
// Output
// =========================================================================
getCleanedData: (): CleanRowData[] => {
const { rows } = get();
return rows.map((row) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { __index, __template, __original, __corrected, __changes, __aiSupplemental, ...cleanRow } = row;
return cleanRow as CleanRowData;
});
},
// =========================================================================
// Reset
// =========================================================================
reset: () => {
console.log('[ValidationStore] reset() called');
console.trace('[ValidationStore] reset trace');
set(getInitialState());
},
}))
)
);
// =============================================================================
// Store Selectors (for use outside React components)
// =============================================================================
export const getRows = () => useValidationStore.getState().rows;
export const getErrors = () => useValidationStore.getState().errors;
export const getFields = () => useValidationStore.getState().fields;

View File

@@ -0,0 +1,111 @@
/**
* AI Validation utility functions
*
* Helper functions for processing AI validation data and managing progress
*/
import type { Fields } from '@/components/product-import/types';
/**
* Clean data for AI validation by including all fields
*
* Ensures every field is present in the data sent to the API,
* converting undefined values to empty strings
*/
export function prepareDataForAiValidation<T extends string>(
data: any[],
fields: Fields<T>
): Record<string, any>[] {
return data.map(item => {
const { __index, __aiSupplemental, ...rest } = item as Record<string, any>;
const withAllKeys: Record<string, any> = {};
fields.forEach((f) => {
const k = String(f.key);
if (Array.isArray(rest[k])) {
withAllKeys[k] = rest[k];
} else if (rest[k] === undefined) {
withAllKeys[k] = "";
} else {
withAllKeys[k] = rest[k];
}
});
if (typeof __aiSupplemental === 'object' && __aiSupplemental !== null) {
withAllKeys.aiSupplementalInfo = __aiSupplemental;
}
return withAllKeys;
});
}
/**
* Process AI-corrected data to handle multi-select and select fields
*
* Converts comma-separated strings to arrays for multi-select fields
* and handles label-to-value conversions for select fields
*/
export function processAiCorrectedData<T extends string>(
correctedData: any[],
originalData: any[],
fields: Fields<T>
): any[] {
return correctedData.map((corrected: any, index: number) => {
// Start with original data to preserve metadata like __index
const original = originalData[index] || {};
const processed = { ...original, ...corrected };
// Process each field according to its type
Object.keys(processed).forEach(key => {
if (key.startsWith('__')) return; // Skip metadata fields
const fieldConfig = fields.find(f => String(f.key) === key);
if (!fieldConfig) return;
// Handle multi-select fields (comma-separated values → array)
if (fieldConfig?.fieldType.type === 'multi-select' && typeof processed[key] === 'string') {
processed[key] = processed[key]
.split(',')
.map((v: string) => v.trim())
.filter(Boolean);
}
});
return processed;
});
}
/**
* Calculate progress percentage based on elapsed time and estimates
*
* @param step - Current step number (1-5)
* @param elapsedSeconds - Time elapsed since start
* @param estimatedSeconds - Estimated total time (optional)
* @returns Progress percentage (0-95, never reaches 100 until complete)
*/
export function calculateProgressPercent(
step: number,
elapsedSeconds: number,
estimatedSeconds?: number
): number {
if (estimatedSeconds && estimatedSeconds > 0) {
// Time-based progress
return Math.min(95, (elapsedSeconds / estimatedSeconds) * 100);
}
// Step-based progress with time adjustment
const baseProgress = (step / 5) * 100;
const timeAdjustment = step === 1 ? Math.min(20, elapsedSeconds * 0.5) : 0;
return Math.min(95, baseProgress + timeAdjustment);
}
/**
* Extract base status message by removing time information
*
* Removes patterns like "(5s remaining)" or "(1m 30s elapsed)"
*/
export function extractBaseStatus(status: string): string {
return status
.replace(/\s\(\d+[ms].+\)$/, '')
.replace(/\s\(\d+m \d+s.+\)$/, '');
}

View File

@@ -0,0 +1,66 @@
/**
* Country code normalization utilities
*
* Converts various country code formats and country names to ISO 3166-1 alpha-2 codes
*/
/**
* Normalizes country codes and names to ISO 3166-1 alpha-2 format (2-letter codes)
*
* Supports:
* - ISO 3166-1 alpha-2 codes (e.g., "US", "GB")
* - ISO 3166-1 alpha-3 codes (e.g., "USA", "GBR")
* - Common country names (e.g., "United States", "China")
*
* @param input - Country code or name to normalize
* @returns ISO 3166-1 alpha-2 code or null if not recognized
*
* @example
* normalizeCountryCode("USA") // "US"
* normalizeCountryCode("United States") // "US"
* normalizeCountryCode("US") // "US"
* normalizeCountryCode("invalid") // null
*/
export function normalizeCountryCode(input: string): string | null {
if (!input) return null;
const s = input.trim();
const upper = s.toUpperCase();
// Already in ISO 3166-1 alpha-2 format
if (/^[A-Z]{2}$/.test(upper)) return upper;
// ISO 3166-1 alpha-3 to alpha-2 mapping
const iso3to2: Record<string, string> = {
USA: "US", GBR: "GB", UK: "GB", CHN: "CN", DEU: "DE", FRA: "FR", ITA: "IT", ESP: "ES",
CAN: "CA", MEX: "MX", AUS: "AU", NZL: "NZ", JPN: "JP", KOR: "KR", PRK: "KP", TWN: "TW",
VNM: "VN", THA: "TH", IDN: "ID", IND: "IN", BRA: "BR", ARG: "AR", CHL: "CL", PER: "PE",
ZAF: "ZA", RUS: "RU", UKR: "UA", NLD: "NL", BEL: "BE", CHE: "CH", SWE: "SE", NOR: "NO",
DNK: "DK", POL: "PL", AUT: "AT", PRT: "PT", GRC: "GR", CZE: "CZ", HUN: "HU", IRL: "IE",
ISR: "IL", PAK: "PK", BGD: "BD", PHL: "PH", MYS: "MY", SGP: "SG", HKG: "HK", MAC: "MO"
};
if (iso3to2[upper]) return iso3to2[upper];
// Country name to ISO 3166-1 alpha-2 mapping
const nameMap: Record<string, string> = {
"UNITED STATES": "US", "UNITED STATES OF AMERICA": "US", "AMERICA": "US", "U.S.": "US", "U.S.A": "US", "USA": "US",
"UNITED KINGDOM": "GB", "UK": "GB", "GREAT BRITAIN": "GB", "ENGLAND": "GB",
"CHINA": "CN", "PEOPLE'S REPUBLIC OF CHINA": "CN", "PRC": "CN",
"CANADA": "CA", "MEXICO": "MX", "JAPAN": "JP", "SOUTH KOREA": "KR", "KOREA, REPUBLIC OF": "KR",
"TAIWAN": "TW", "VIETNAM": "VN", "THAILAND": "TH", "INDONESIA": "ID", "INDIA": "IN",
"GERMANY": "DE", "FRANCE": "FR", "ITALY": "IT", "SPAIN": "ES", "NETHERLANDS": "NL", "BELGIUM": "BE",
"SWITZERLAND": "CH", "SWEDEN": "SE", "NORWAY": "NO", "DENMARK": "DK", "POLAND": "PL", "AUSTRIA": "AT",
"PORTUGAL": "PT", "GREECE": "GR", "CZECH REPUBLIC": "CZ", "CZECHIA": "CZ", "HUNGARY": "HU", "IRELAND": "IE",
"RUSSIA": "RU", "UKRAINE": "UA", "AUSTRALIA": "AU", "NEW ZEALAND": "NZ",
"BRAZIL": "BR", "ARGENTINA": "AR", "CHILE": "CL", "PERU": "PE", "SOUTH AFRICA": "ZA",
"ISRAEL": "IL", "PAKISTAN": "PK", "BANGLADESH": "BD", "PHILIPPINES": "PH", "MALAYSIA": "MY", "SINGAPORE": "SG",
"HONG KONG": "HK", "MACAU": "MO"
};
// Normalize input: remove dots, trim, uppercase
const normalizedName = s.replace(/\./g, "").trim().toUpperCase();
if (nameMap[normalizedName]) return nameMap[normalizedName];
return null;
}

View File

@@ -0,0 +1,142 @@
import type { Data, Fields, Info, RowHook, TableHook } from "../../../types"
import type { Meta, Errors } from "../../ValidationStepNew/types"
import { v4 } from "uuid"
import { ErrorSources, ErrorType } from "../../../types"
type DataWithMeta<T extends string> = Data<T> & Meta & {
__index?: string;
}
export const addErrorsAndRunHooks = async <T extends string>(
data: (Data<T> & Partial<Meta>)[],
fields: Fields<T>,
rowHook?: RowHook<T>,
tableHook?: TableHook<T>,
changedRowIndexes?: number[],
): Promise<DataWithMeta<T>[]> => {
const errors: Errors = {}
const addError = (source: ErrorSources, rowIndex: number, fieldKey: string, error: Info, type: ErrorType = ErrorType.Custom) => {
errors[rowIndex] = {
...errors[rowIndex],
[fieldKey]: { ...error, source, type },
}
}
let processedData = [...data] as DataWithMeta<T>[]
if (tableHook) {
const tableResults = await tableHook(processedData)
processedData = tableResults.map((result, index) => ({
...processedData[index],
...result
}))
}
if (rowHook) {
if (changedRowIndexes) {
for (const index of changedRowIndexes) {
const rowResult = await rowHook(processedData[index], index, processedData)
processedData[index] = {
...processedData[index],
...rowResult
}
}
} else {
const rowResults = await Promise.all(
processedData.map(async (value, index) => {
const result = await rowHook(value, index, processedData)
return {
...value,
...result
}
})
)
processedData = rowResults
}
}
fields.forEach((field) => {
const fieldKey = field.key as string
field.validations?.forEach((validation) => {
switch (validation.rule) {
case "unique": {
const values = processedData.map((entry) => {
const value = entry[fieldKey as keyof typeof entry]
return value
})
const taken = new Set() // Set of items used at least once
const duplicates = new Set() // Set of items used multiple times
values.forEach((value) => {
if (validation.allowEmpty && !value) {
// If allowEmpty is set, we will not validate falsy fields such as undefined or empty string.
return
}
if (taken.has(value)) {
duplicates.add(value)
} else {
taken.add(value)
}
})
values.forEach((value, index) => {
if (duplicates.has(value)) {
addError(ErrorSources.Table, index, fieldKey, {
level: validation.level || "error",
message: validation.errorMessage || "Field must be unique",
}, ErrorType.Unique)
}
})
break
}
case "required": {
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => processedData[index]) : processedData
dataToValidate.forEach((entry, index) => {
const realIndex = changedRowIndexes ? changedRowIndexes[index] : index
const value = entry[fieldKey as keyof typeof entry]
if (value === null || value === undefined || value === "") {
addError(ErrorSources.Row, realIndex, fieldKey, {
level: validation.level || "error",
message: validation.errorMessage || "Field is required",
}, ErrorType.Required)
}
})
break
}
case "regex": {
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => processedData[index]) : processedData
const regex = new RegExp(validation.value, validation.flags)
dataToValidate.forEach((entry, index) => {
const realIndex = changedRowIndexes ? changedRowIndexes[index] : index
const value = entry[fieldKey as keyof typeof entry]
const stringValue = value?.toString() ?? ""
if (!stringValue.match(regex)) {
addError(ErrorSources.Row, realIndex, fieldKey, {
level: validation.level || "error",
message:
validation.errorMessage || `Field did not match the regex /${validation.value}/${validation.flags} `,
}, ErrorType.Regex)
}
})
break
}
}
})
})
return processedData.map((value) => {
// This is required only for table. Mutates to prevent needless rerenders
const result: DataWithMeta<T> = { ...value }
if (!result.__index) {
result.__index = v4()
}
// We no longer store errors in the row data
// The errors are now only stored in the validationErrors Map
return result
})
}

View File

@@ -0,0 +1,62 @@
/**
* Price field cleaning and formatting utilities
*/
/**
* Cleans a price field by removing currency symbols and formatting to 2 decimal places
*
* - Removes dollar signs ($) and commas (,)
* - Converts to number and formats with 2 decimal places
* - Returns original value if conversion fails
*
* @param value - Price value to clean (string or number)
* @returns Cleaned price string formatted to 2 decimals, or original value if invalid
*
* @example
* cleanPriceField("$1,234.56") // "1234.56"
* cleanPriceField("$99.9") // "99.90"
* cleanPriceField(123.456) // "123.46"
* cleanPriceField("invalid") // "invalid"
*/
export function cleanPriceField(value: string | number): string {
if (typeof value === "string") {
const cleaned = value.replace(/[$,]/g, "");
const numValue = parseFloat(cleaned);
if (!isNaN(numValue)) {
return numValue.toFixed(2);
}
return value;
}
if (typeof value === "number") {
return value.toFixed(2);
}
return String(value);
}
/**
* Cleans multiple price fields in a data object
*
* @param data - Object containing price fields
* @param priceFields - Array of field keys to clean
* @returns New object with cleaned price fields
*
* @example
* cleanPriceFields({ msrp: "$99.99", cost_each: "$50.00" }, ["msrp", "cost_each"])
* // { msrp: "99.99", cost_each: "50.00" }
*/
export function cleanPriceFields<T extends Record<string, any>>(
data: T,
priceFields: (keyof T)[]
): T {
const cleaned = { ...data };
for (const field of priceFields) {
if (cleaned[field] !== undefined && cleaned[field] !== null) {
cleaned[field] = cleanPriceField(cleaned[field]) as any;
}
}
return cleaned;
}

View File

@@ -0,0 +1,63 @@
const NUMERIC_REGEX = /^\d+$/;
export function calculateUpcCheckDigit(upcBody: string): number {
if (!NUMERIC_REGEX.test(upcBody) || upcBody.length !== 11) {
throw new Error('UPC body must be 11 numeric characters');
}
const digits = upcBody.split('').map((d) => Number.parseInt(d, 10));
let sum = 0;
for (let i = 0; i < digits.length; i += 1) {
sum += (i % 2 === 0 ? digits[i] * 3 : digits[i]);
}
const mod = sum % 10;
return mod === 0 ? 0 : 10 - mod;
}
export function calculateEanCheckDigit(eanBody: string): number {
if (!NUMERIC_REGEX.test(eanBody) || eanBody.length !== 12) {
throw new Error('EAN body must be 12 numeric characters');
}
const digits = eanBody.split('').map((d) => Number.parseInt(d, 10));
let sum = 0;
for (let i = 0; i < digits.length; i += 1) {
sum += (i % 2 === 0 ? digits[i] : digits[i] * 3);
}
const mod = sum % 10;
return mod === 0 ? 0 : 10 - mod;
}
export function correctUpcValue(rawValue: unknown): { corrected: string; changed: boolean } {
const value = rawValue ?? '';
const str = typeof value === 'string' ? value.trim() : String(value);
if (str === '' || !NUMERIC_REGEX.test(str)) {
return { corrected: str, changed: false };
}
if (str.length === 11) {
const check = calculateUpcCheckDigit(str);
return { corrected: `${str}${check}`, changed: true };
}
if (str.length === 12) {
const body = str.slice(0, 11);
const check = calculateUpcCheckDigit(body);
const corrected = `${body}${check}`;
return { corrected, changed: corrected !== str };
}
if (str.length === 13) {
const body = str.slice(0, 12);
const check = calculateEanCheckDigit(body);
const corrected = `${body}${check}`;
return { corrected, changed: corrected !== str };
}
return { corrected: str, changed: false };
}

View File

@@ -7,6 +7,7 @@ const StepTypeToStepRecord: Record<StepType, (typeof steps)[number]> = {
[StepType.selectHeader]: "selectHeaderStep",
[StepType.matchColumns]: "matchColumnsStep",
[StepType.validateData]: "validationStep",
[StepType.validateDataNew]: "validationStep",
[StepType.imageUpload]: "imageUploadStep",
}
const StepToStepTypeRecord: Record<(typeof steps)[number], StepType> = {