Rewrite validation step part 1
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -15,12 +15,19 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Select,
|
Command,
|
||||||
SelectContent,
|
CommandEmpty,
|
||||||
SelectItem,
|
CommandGroup,
|
||||||
SelectTrigger,
|
CommandInput,
|
||||||
SelectValue,
|
CommandItem,
|
||||||
} from "@/components/ui/select";
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
import { createProductCategory, type CreateProductCategoryResponse } from "@/services/apiv2";
|
import { createProductCategory, type CreateProductCategoryResponse } from "@/services/apiv2";
|
||||||
|
|
||||||
@@ -85,8 +92,24 @@ export function CreateProductCategoryDialog({
|
|||||||
const [isLoadingLines, setIsLoadingLines] = useState(false);
|
const [isLoadingLines, setIsLoadingLines] = useState(false);
|
||||||
const [lines, setLines] = useState<Option[]>([]);
|
const [lines, setLines] = useState<Option[]>([]);
|
||||||
const [linesCache, setLinesCache] = useState<Record<string, 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]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -241,56 +264,109 @@ export function CreateProductCategoryDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Company Select - Searchable */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="create-category-company">Company</Label>
|
<Label>Company</Label>
|
||||||
<Select
|
<Popover open={companyOpen} onOpenChange={setCompanyOpen}>
|
||||||
value={companyId}
|
<PopoverTrigger asChild>
|
||||||
onValueChange={(value) => {
|
<Button
|
||||||
setCompanyId(value);
|
variant="outline"
|
||||||
setLineId("");
|
role="combobox"
|
||||||
}}
|
aria-expanded={companyOpen}
|
||||||
>
|
className="w-full justify-between font-normal"
|
||||||
<SelectTrigger id="create-category-company">
|
>
|
||||||
<SelectValue placeholder="Select a company" />
|
{selectedCompanyLabel || "Select a company"}
|
||||||
</SelectTrigger>
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
<SelectContent>
|
</Button>
|
||||||
{companyOptions.map((company) => (
|
</PopoverTrigger>
|
||||||
<SelectItem key={company.value} value={company.value}>
|
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||||
{company.label}
|
<Command>
|
||||||
</SelectItem>
|
<CommandInput placeholder="Search companies..." />
|
||||||
))}
|
<CommandList>
|
||||||
</SelectContent>
|
<CommandEmpty>No company found.</CommandEmpty>
|
||||||
</Select>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Line Select - Searchable */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="create-category-line">
|
<Label>
|
||||||
Parent Line <span className="text-muted-foreground">(optional)</span>
|
Parent Line <span className="text-muted-foreground">(optional)</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Popover open={lineOpen} onOpenChange={setLineOpen}>
|
||||||
value={lineId}
|
<PopoverTrigger asChild>
|
||||||
onValueChange={setLineId}
|
<Button
|
||||||
disabled={!companyId || isLoadingLines || !lines.length}
|
variant="outline"
|
||||||
>
|
role="combobox"
|
||||||
<SelectTrigger id="create-category-line">
|
aria-expanded={lineOpen}
|
||||||
<SelectValue
|
className="w-full justify-between font-normal"
|
||||||
placeholder={
|
disabled={!companyId || isLoadingLines}
|
||||||
!companyId
|
>
|
||||||
? "Select a company first"
|
{!companyId
|
||||||
: isLoadingLines
|
? "Select a company first"
|
||||||
? "Loading product lines..."
|
: isLoadingLines
|
||||||
: "Leave empty to create a new line"
|
? "Loading product lines..."
|
||||||
}
|
: selectedLineLabel || "Leave empty to create a new line"}
|
||||||
/>
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</SelectTrigger>
|
</Button>
|
||||||
<SelectContent>
|
</PopoverTrigger>
|
||||||
{lines.map((line) => (
|
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||||
<SelectItem key={line.value} value={line.value}>
|
<Command>
|
||||||
{line.label}
|
<CommandInput placeholder="Search lines..." />
|
||||||
</SelectItem>
|
<CommandList>
|
||||||
))}
|
<CommandEmpty>No line found.</CommandEmpty>
|
||||||
</SelectContent>
|
<CommandGroup>
|
||||||
</Select>
|
{/* 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 && (
|
{companyId && !isLoadingLines && !lines.length && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
No existing lines found for this company. A new line will be created.
|
No existing lines found for this company. A new line will be created.
|
||||||
|
|||||||
@@ -1288,7 +1288,7 @@ const MatchColumnsStepComponent = <T extends string>({
|
|||||||
}, [fields]);
|
}, [fields]);
|
||||||
|
|
||||||
// Fix handleOnContinue - it should be useCallback, not useEffect
|
// Fix handleOnContinue - it should be useCallback, not useEffect
|
||||||
const handleOnContinue = useCallback(async () => {
|
const handleOnContinue = useCallback(async (useNewValidation: boolean = false) => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1325,7 +1325,7 @@ const MatchColumnsStepComponent = <T extends string>({
|
|||||||
})
|
})
|
||||||
: normalizedData
|
: normalizedData
|
||||||
|
|
||||||
await onContinue(enhancedData, data, columns, globalSelections)
|
await onContinue(enhancedData, data, columns, globalSelections, useNewValidation)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -1909,20 +1909,28 @@ const MatchColumnsStepComponent = <T extends string>({
|
|||||||
|
|
||||||
<div className="border-t bg-muted px-6 py-4">
|
<div className="border-t bg-muted px-6 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
||||||
{onBack && (
|
{onBack && (
|
||||||
<Button variant="outline" onClick={onBack}>
|
<Button variant="outline" onClick={onBack}>
|
||||||
{translations.matchColumnsStep.backButtonTitle}
|
{translations.matchColumnsStep.backButtonTitle}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
className="ml-auto"
|
<Button
|
||||||
disabled={isLoading}
|
variant="outline"
|
||||||
onClick={handleOnContinue}
|
disabled={isLoading}
|
||||||
>
|
onClick={() => handleOnContinue(false)}
|
||||||
{translations.matchColumnsStep.nextButtonTitle}
|
>
|
||||||
</Button>
|
{translations.matchColumnsStep.nextButtonTitle}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => handleOnContinue(true)}
|
||||||
|
>
|
||||||
|
{translations.matchColumnsStep.nextButtonTitle} (New Validation)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { RawData } from "../../types"
|
|||||||
export type MatchColumnsProps<T extends string> = {
|
export type MatchColumnsProps<T extends string> = {
|
||||||
data: RawData[]
|
data: RawData[]
|
||||||
headerValues: 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
|
onBack?: () => void
|
||||||
initialGlobalSelections?: GlobalSelections
|
initialGlobalSelections?: GlobalSelections
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SelectHeaderStep } from "./SelectHeaderStep/SelectHeaderStep"
|
|||||||
import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
|
import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
|
||||||
import { mapWorkbook } from "../utils/mapWorkbook"
|
import { mapWorkbook } from "../utils/mapWorkbook"
|
||||||
import { ValidationStepNew } from "./ValidationStepNew"
|
import { ValidationStepNew } from "./ValidationStepNew"
|
||||||
|
import { ValidationStep } from "./ValidationStep"
|
||||||
import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep"
|
import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep"
|
||||||
import { MatchColumnsStep } from "./MatchColumnsStep/MatchColumnsStep"
|
import { MatchColumnsStep } from "./MatchColumnsStep/MatchColumnsStep"
|
||||||
import type { GlobalSelections } from "./MatchColumnsStep/types"
|
import type { GlobalSelections } from "./MatchColumnsStep/types"
|
||||||
@@ -21,6 +22,7 @@ export enum StepType {
|
|||||||
selectHeader = "selectHeader",
|
selectHeader = "selectHeader",
|
||||||
matchColumns = "matchColumns",
|
matchColumns = "matchColumns",
|
||||||
validateData = "validateData",
|
validateData = "validateData",
|
||||||
|
validateDataNew = "validateDataNew",
|
||||||
imageUpload = "imageUpload",
|
imageUpload = "imageUpload",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +50,12 @@ export type StepState =
|
|||||||
globalSelections?: GlobalSelections
|
globalSelections?: GlobalSelections
|
||||||
isFromScratch?: boolean
|
isFromScratch?: boolean
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: StepType.validateDataNew
|
||||||
|
data: any[]
|
||||||
|
globalSelections?: GlobalSelections
|
||||||
|
isFromScratch?: boolean
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: StepType.imageUpload
|
type: StepType.imageUpload
|
||||||
data: any[]
|
data: any[]
|
||||||
@@ -87,7 +95,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
|
|
||||||
// Keep track of global selections across steps
|
// Keep track of global selections across steps
|
||||||
const [persistedGlobalSelections, setPersistedGlobalSelections] = useState<GlobalSelections | undefined>(
|
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
|
? state.globalSelections
|
||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
@@ -179,13 +187,13 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
data={state.data}
|
data={state.data}
|
||||||
headerValues={state.headerValues}
|
headerValues={state.headerValues}
|
||||||
initialGlobalSelections={persistedGlobalSelections}
|
initialGlobalSelections={persistedGlobalSelections}
|
||||||
onContinue={async (values, rawData, columns, globalSelections) => {
|
onContinue={async (values, rawData, columns, globalSelections, useNewValidation) => {
|
||||||
try {
|
try {
|
||||||
const data = await matchColumnsStepHook(values, rawData, columns)
|
const data = await matchColumnsStepHook(values, rawData, columns)
|
||||||
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook)
|
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook)
|
||||||
|
|
||||||
// Apply global selections to each row of data if they exist
|
// Apply global selections to each row of data if they exist
|
||||||
const dataWithGlobalSelections = globalSelections
|
const dataWithGlobalSelections = globalSelections
|
||||||
? dataWithMeta.map((row: Data<string> & { __index?: string }) => {
|
? dataWithMeta.map((row: Data<string> & { __index?: string }) => {
|
||||||
const newRow = { ...row } as any;
|
const newRow = { ...row } as any;
|
||||||
if (globalSelections.supplier) newRow.supplier = globalSelections.supplier;
|
if (globalSelections.supplier) newRow.supplier = globalSelections.supplier;
|
||||||
@@ -195,10 +203,12 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
return newRow;
|
return newRow;
|
||||||
})
|
})
|
||||||
: dataWithMeta;
|
: dataWithMeta;
|
||||||
|
|
||||||
setPersistedGlobalSelections(globalSelections)
|
setPersistedGlobalSelections(globalSelections)
|
||||||
|
|
||||||
|
// Route to new or old validation step based on user choice
|
||||||
onNext({
|
onNext({
|
||||||
type: StepType.validateData,
|
type: useNewValidation ? StepType.validateDataNew : StepType.validateData,
|
||||||
data: dataWithGlobalSelections,
|
data: dataWithGlobalSelections,
|
||||||
globalSelections,
|
globalSelections,
|
||||||
})
|
})
|
||||||
@@ -238,6 +248,35 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
isFromScratch={state.isFromScratch}
|
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:
|
case StepType.imageUpload:
|
||||||
return (
|
return (
|
||||||
<ImageUploadStep
|
<ImageUploadStep
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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';
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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 [];
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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.+\)$/, '');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ const StepTypeToStepRecord: Record<StepType, (typeof steps)[number]> = {
|
|||||||
[StepType.selectHeader]: "selectHeaderStep",
|
[StepType.selectHeader]: "selectHeaderStep",
|
||||||
[StepType.matchColumns]: "matchColumnsStep",
|
[StepType.matchColumns]: "matchColumnsStep",
|
||||||
[StepType.validateData]: "validationStep",
|
[StepType.validateData]: "validationStep",
|
||||||
|
[StepType.validateDataNew]: "validationStep",
|
||||||
[StepType.imageUpload]: "imageUploadStep",
|
[StepType.imageUpload]: "imageUploadStep",
|
||||||
}
|
}
|
||||||
const StepToStepTypeRecord: Record<(typeof steps)[number], StepType> = {
|
const StepToStepTypeRecord: Record<(typeof steps)[number], StepType> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user