From 262890a7be313a1a93c99d2d8c532c2b22b646ea Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 17 Jan 2026 19:19:47 -0500 Subject: [PATCH] Rewrite validation step part 1 --- .../CreateProductCategoryDialog.tsx | 180 +++- .../MatchColumnsStep/MatchColumnsStep.tsx | 30 +- .../steps/MatchColumnsStep/types.ts | 2 +- .../product-import/steps/UploadFlow.tsx | 51 +- .../components/CopyDownBanner.tsx | 51 + .../components/FloatingSelectionBar.tsx | 197 ++++ .../components/InitializingOverlay.tsx | 55 ++ .../components/SearchableTemplateSelect.tsx | 337 +++++++ .../components/ValidationContainer.tsx | 161 +++ .../components/ValidationFooter.tsx | 104 ++ .../components/ValidationTable.tsx | 914 ++++++++++++++++++ .../components/ValidationToolbar.tsx | 198 ++++ .../components/cells/CheckboxCell.tsx | 119 +++ .../components/cells/ComboboxCell.tsx | 158 +++ .../components/cells/InputCell.tsx | 116 +++ .../components/cells/MultiSelectCell.tsx | 159 +++ .../components/cells/MultilineInput.tsx | 174 ++++ .../components/cells/SelectCell.tsx | 174 ++++ .../ValidationStep/dialogs/AiDebugDialog.tsx | 125 +++ .../dialogs/AiValidationProgress.tsx | 74 ++ .../dialogs/AiValidationResults.tsx | 151 +++ .../hooks/useAiValidation/index.ts | 204 ++++ .../hooks/useAiValidation/useAiApi.ts | 147 +++ .../hooks/useAiValidation/useAiProgress.ts | 132 +++ .../hooks/useAiValidation/useAiTransform.ts | 166 ++++ .../ValidationStep/hooks/useFieldOptions.ts | 76 ++ .../ValidationStep/hooks/useProductLines.ts | 238 +++++ .../hooks/useTemplateManagement.ts | 334 +++++++ .../ValidationStep/hooks/useUpcValidation.ts | 290 ++++++ .../hooks/useValidationActions.ts | 460 +++++++++ .../steps/ValidationStep/index.tsx | 265 +++++ .../steps/ValidationStep/store/selectors.ts | 484 ++++++++++ .../steps/ValidationStep/store/types.ts | 401 ++++++++ .../ValidationStep/store/validationStore.ts | 704 ++++++++++++++ .../ValidationStep/utils/aiValidationUtils.ts | 111 +++ .../ValidationStep/utils/countryUtils.ts | 66 ++ .../ValidationStep/utils/dataMutations.ts | 142 +++ .../steps/ValidationStep/utils/priceUtils.ts | 62 ++ .../steps/ValidationStep/utils/upcUtils.ts | 63 ++ .../components/product-import/utils/steps.ts | 1 + 40 files changed, 7806 insertions(+), 70 deletions(-) create mode 100644 inventory/src/components/product-import/steps/ValidationStep/components/CopyDownBanner.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/components/FloatingSelectionBar.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/components/InitializingOverlay.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/components/SearchableTemplateSelect.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/components/ValidationToolbar.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/components/cells/CheckboxCell.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/components/cells/ComboboxCell.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/components/cells/InputCell.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/components/cells/MultiSelectCell.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/components/cells/SelectCell.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/dialogs/AiDebugDialog.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/dialogs/AiValidationProgress.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/dialogs/AiValidationResults.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/index.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/useAiApi.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/useAiProgress.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/useAiTransform.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStep/hooks/useFieldOptions.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStep/hooks/useProductLines.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStep/hooks/useTemplateManagement.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStep/hooks/useUpcValidation.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStep/hooks/useValidationActions.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStep/index.tsx create mode 100644 inventory/src/components/product-import/steps/ValidationStep/store/selectors.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStep/store/types.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStep/utils/aiValidationUtils.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStep/utils/countryUtils.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStep/utils/dataMutations.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStep/utils/priceUtils.ts create mode 100644 inventory/src/components/product-import/steps/ValidationStep/utils/upcUtils.ts diff --git a/inventory/src/components/product-import/CreateProductCategoryDialog.tsx b/inventory/src/components/product-import/CreateProductCategoryDialog.tsx index c731c7f..05a0ebb 100644 --- a/inventory/src/components/product-import/CreateProductCategoryDialog.tsx +++ b/inventory/src/components/product-import/CreateProductCategoryDialog.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Loader2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState, useRef } from "react"; +import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { @@ -15,12 +15,19 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; import config from "@/config"; import { createProductCategory, type CreateProductCategoryResponse } from "@/services/apiv2"; @@ -85,8 +92,24 @@ export function CreateProductCategoryDialog({ const [isLoadingLines, setIsLoadingLines] = useState(false); const [lines, setLines] = useState([]); const [linesCache, setLinesCache] = useState>({}); + + // Popover open states + const [companyOpen, setCompanyOpen] = useState(false); + const [lineOpen, setLineOpen] = useState(false); const companyOptions = useMemo(() => normalizeOptions(companies), [companies]); + + // Get display label for selected company + const selectedCompanyLabel = useMemo(() => { + const company = companyOptions.find((c) => c.value === companyId); + return company?.label || ""; + }, [companyOptions, companyId]); + + // Get display label for selected line + const selectedLineLabel = useMemo(() => { + const line = lines.find((l) => l.value === lineId); + return line?.label || ""; + }, [lines, lineId]); useEffect(() => { if (!isOpen) { @@ -241,56 +264,109 @@ export function CreateProductCategoryDialog({
+ {/* Company Select - Searchable */}
- - + + + + + + + + + + No company found. + + {companyOptions.map((company) => ( + { + setCompanyId(company.value); + setLineId(""); + setCompanyOpen(false); + }} + > + {company.label} + {company.value === companyId && ( + + )} + + ))} + + + + +
+ {/* Line Select - Searchable */}
-
diff --git a/inventory/src/components/product-import/steps/MatchColumnsStep/types.ts b/inventory/src/components/product-import/steps/MatchColumnsStep/types.ts index ee84d09..cb892a5 100644 --- a/inventory/src/components/product-import/steps/MatchColumnsStep/types.ts +++ b/inventory/src/components/product-import/steps/MatchColumnsStep/types.ts @@ -3,7 +3,7 @@ import type { RawData } from "../../types" export type MatchColumnsProps = { data: RawData[] headerValues: RawData - onContinue: (data: any[], rawData: RawData[], columns: Columns, globalSelections?: GlobalSelections) => void + onContinue: (data: any[], rawData: RawData[], columns: Columns, globalSelections?: GlobalSelections, useNewValidation?: boolean) => void onBack?: () => void initialGlobalSelections?: GlobalSelections } diff --git a/inventory/src/components/product-import/steps/UploadFlow.tsx b/inventory/src/components/product-import/steps/UploadFlow.tsx index 61a8579..30cd251 100644 --- a/inventory/src/components/product-import/steps/UploadFlow.tsx +++ b/inventory/src/components/product-import/steps/UploadFlow.tsx @@ -5,6 +5,7 @@ import { SelectHeaderStep } from "./SelectHeaderStep/SelectHeaderStep" import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep" import { mapWorkbook } from "../utils/mapWorkbook" import { ValidationStepNew } from "./ValidationStepNew" +import { ValidationStep } from "./ValidationStep" import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep" import { MatchColumnsStep } from "./MatchColumnsStep/MatchColumnsStep" import type { GlobalSelections } from "./MatchColumnsStep/types" @@ -21,6 +22,7 @@ export enum StepType { selectHeader = "selectHeader", matchColumns = "matchColumns", validateData = "validateData", + validateDataNew = "validateDataNew", imageUpload = "imageUpload", } @@ -48,6 +50,12 @@ export type StepState = globalSelections?: GlobalSelections isFromScratch?: boolean } + | { + type: StepType.validateDataNew + data: any[] + globalSelections?: GlobalSelections + isFromScratch?: boolean + } | { type: StepType.imageUpload data: any[] @@ -87,7 +95,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => { // Keep track of global selections across steps const [persistedGlobalSelections, setPersistedGlobalSelections] = useState( - state.type === StepType.validateData || state.type === StepType.matchColumns + state.type === StepType.validateData || state.type === StepType.validateDataNew || state.type === StepType.matchColumns ? state.globalSelections : undefined ) @@ -179,13 +187,13 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => { data={state.data} headerValues={state.headerValues} initialGlobalSelections={persistedGlobalSelections} - onContinue={async (values, rawData, columns, globalSelections) => { + onContinue={async (values, rawData, columns, globalSelections, useNewValidation) => { try { const data = await matchColumnsStepHook(values, rawData, columns) const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook) - + // Apply global selections to each row of data if they exist - const dataWithGlobalSelections = globalSelections + const dataWithGlobalSelections = globalSelections ? dataWithMeta.map((row: Data & { __index?: string }) => { const newRow = { ...row } as any; if (globalSelections.supplier) newRow.supplier = globalSelections.supplier; @@ -195,10 +203,12 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => { return newRow; }) : dataWithMeta; - + setPersistedGlobalSelections(globalSelections) + + // Route to new or old validation step based on user choice onNext({ - type: StepType.validateData, + type: useNewValidation ? StepType.validateDataNew : StepType.validateData, data: dataWithGlobalSelections, globalSelections, }) @@ -238,6 +248,35 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => { isFromScratch={state.isFromScratch} /> ) + case StepType.validateDataNew: + // New Zustand-based ValidationStep component + return ( + { + // If we started from scratch, we need to go back to the upload step + if (state.isFromScratch) { + onNext({ + type: StepType.upload + }); + } else if (onBack) { + // Use the provided onBack function + onBack(); + } + }} + onNext={(validatedData: any[]) => { + // Go to image upload step with the validated data + onNext({ + type: StepType.imageUpload, + data: validatedData, + file: uploadedFile || new File([], "empty.xlsx"), + globalSelections: state.globalSelections + }); + }} + isFromScratch={state.isFromScratch} + /> + ) case StepType.imageUpload: return ( { + const isActive = useIsCopyDownActive(); + + if (!isActive) return null; + + const handleCancel = () => { + useValidationStore.getState().cancelCopyDown(); + }; + + return ( +
+
+
+
+ + Click on the last row you want to copy to + + +
+
+
+ ); +}); + +CopyDownBanner.displayName = 'CopyDownBanner'; diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/FloatingSelectionBar.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/FloatingSelectionBar.tsx new file mode 100644 index 0000000..7ee565c --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/components/FloatingSelectionBar.tsx @@ -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 = {}; + 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 ( +
+
+ {/* Selection count badge */} +
+
+ {selectedCount} selected +
+ +
+ + {/* Divider */} +
+ + {/* Apply template to selected */} +
+ Apply template: + +
+ + {/* Divider */} +
+ + {/* Save as template - only when single row selected */} + {hasSingleRow && ( + <> + + + {/* Divider */} +
+ + )} + + {/* Delete selected */} + +
+
+ ); +}); + +FloatingSelectionBar.displayName = 'FloatingSelectionBar'; diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/InitializingOverlay.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/InitializingOverlay.tsx new file mode 100644 index 0000000..275fcea --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/components/InitializingOverlay.tsx @@ -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 = { + 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 = { + 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 ( +
+
+ +
{displayMessage}
+
+ +
+ +
+ +
+ This may take a moment for large imports +
+
+ ); +}; diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/SearchableTemplateSelect.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/SearchableTemplateSelect.tsx new file mode 100644 index 0000000..33cc4ef --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/components/SearchableTemplateSelect.tsx @@ -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 = ({ + templates = [], + value, + onValueChange, + getTemplateDisplayText, + placeholder = 'Select template', + className, + triggerClassName, + defaultBrand, + disabled = false, +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedBrand, setSelectedBrand] = useState(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(); + 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 = {}; + + 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 ( + { + try { + onValueChange(currentValue); + setOpen(false); + setSearchTerm(''); + } catch (err) { + console.error('Error selecting template:', err); + } + }} + className="flex items-center justify-between" + > + {displayText} + {value === template.id.toString() && } + + ); + } catch (err) { + console.error('Error rendering template item:', err); + return null; + } + }, + [onValueChange, value, getTemplateDisplayText] + ); + + return ( + + + + + + +
+ {brands.length > 0 && ( +
+ +
+ )} + + + +
+ +
+
+ + +
+

No templates found.

+
+
+ + + + {!searchTerm ? ( + selectedBrand ? ( + groupedTemplates[selectedBrand]?.length > 0 ? ( + b.id === selectedBrand)?.name || selectedBrand}> + {groupedTemplates[selectedBrand]?.map((template) => renderCommandItem(template))} + + ) : ( + // 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 ( + + {companyTemplates.map((template) => renderCommandItem(template))} + + ); + }) + ) + ) : ( + Object.entries(groupedTemplates).map(([companyId, companyTemplates]) => { + const brand = brands.find((b) => b.id === companyId); + const companyName = brand ? brand.name : companyId; + + return ( + + {companyTemplates.map((template) => renderCommandItem(template))} + + ); + }) + ) + ) : ( + {filteredTemplates.map((template) => renderCommandItem(template))} + )} + + +
+
+
+ ); +}; + +export default SearchableTemplateSelect; diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx new file mode 100644 index 0000000..d02676f --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx @@ -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 ( +
+ {/* Toolbar */} + + + {/* Main table area */} +
+ +
+ + {/* Footer with navigation */} + + + {/* Floating selection bar - appears when rows selected */} + + + {/* AI Validation dialogs */} + {aiValidation.isValidating && aiValidation.progress && ( + + )} + + {aiValidation.results && !aiValidation.isValidating && ( + + )} + + {/* AI Debug Dialog - for viewing prompt */} + + + {/* Template form dialog - for saving row as template */} + [0]['initialData']} + mode="create" + fieldOptions={templateFormFieldOptions} + /> +
+ ); +}; diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx new file mode 100644 index 0000000..6007111 --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx @@ -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 ( +
+ {/* Back button */} +
+ {canGoBack && onBack && ( + + )} +
+ + {/* Status summary - only show success message when no errors */} +
+ {errorCount === 0 && rowCount > 0 && ( +
+ + + All {rowCount} products validated + +
+ )} +
+ + {/* Action buttons */} +
+ {/* Show Prompt Debug - Admin only */} + {onShowDebug && ( + + + + )} + + {/* AI Validate */} + {onAiValidate && ( + + )} + + {/* Next button */} + {onNext && ( + + )} +
+
+ ); +}; diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx new file mode 100644 index 0000000..719d86a --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx @@ -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, 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 = {}; + +/** + * CellWrapper props - receives data from parent VirtualRow + * NO STORE SUBSCRIPTIONS - purely presentational + */ +interface CellWrapperProps { + field: Field; + 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 ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={isCopyDownTarget ? handleTargetClick : undefined} + > + {/* Wrap cell in a div that blocks pointer events during copy-down for this field */} +
+ +
+ + {/* Copy-down button - appears on hover */} + {showCopyDownButton && ( + + )} + + {/* UPC Generate button - appears on hover for empty UPC cells */} + {showGenerateUpcButton && ( + + + + + + +

{hasValidSupplier ? 'Generate UPC' : 'Select a supplier first'}

+
+
+
+ )} + + {/* UPC generating spinner */} + {isGeneratingUpc && ( +
+ +
+ )} +
+ ); +}); + +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(); + 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 = { + __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 ( +
+ +
+ ); + } + + if (!templates || templates.length === 0) { + return ( +
+ No templates +
+ ); + } + + return ( + + ); +}); + +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[]; + fields: Field[]; + 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 ( +
+ {/* Selection checkbox cell */} +
+ toggleRowSelection(rowId)} + /> +
+ + {/* Template column */} +
+ +
+ + {/* 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 ( +
+ +
+ ); + })} +
+ ); +}); + +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 ( + + ); +}); + +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(null); + const headerRef = useRef(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(); + + 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[]>(() => { + // Selection column - uses isolated HeaderCheckbox to prevent cascading re-renders + const selectionColumn: ColumnDef = { + id: 'select', + header: () => , + size: 40, + }; + + // Template column + const templateColumn: ColumnDef = { + id: 'template', + header: () => Template, + size: TEMPLATE_COLUMN_WIDTH, + }; + + // Data columns from fields with widths from config.ts + const dataColumns: ColumnDef[] = fields.map((field) => ({ + id: field.key, + header: () => ( +
+ {field.label} + {field.validations?.some((v: Validation) => v.rule === 'required') && ( + * + )} +
+ ), + 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 ( +
+ {/* Copy-down banner - shows when copy-down mode is active */} + + + {/* Fixed header - OUTSIDE the scroll container but syncs horizontal scroll */} +
+
+ {columns.map((column, index) => ( +
+ {typeof column.header === 'function' + ? column.header({} as any) + : column.header} +
+ ))} +
+
+ + {/* Scrollable virtualized content */} +
+
+ {/* 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 ( + + ); + })} +
+
+
+ ); +}; diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationToolbar.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationToolbar.tsx new file mode 100644 index 0000000..5b651ce --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationToolbar.tsx @@ -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 ( +
+ {/* Top row: Search and stats */} +
+ {/* Search */} +
+ + setSearchText(e.target.value)} + className="pl-9" + /> +
+ + {/* Error filter toggle */} +
+ + +
+ + {/* Stats */} +
+ {rowCount} products + {errorCount > 0 && ( + + {errorCount} errors in {rowsWithErrors} rows + + )} + {selectedRowCount > 0 && ( + {selectedRowCount} selected + )} +
+
+ + {/* Bottom row: Actions */} +
+ {/* Add row */} + + + {/* Create template */} + + + {/* Create product line/subline */} + + + New Line/Subline + + } + companies={companyOptions} + onCreated={handleCategoryCreated} + /> + + {/* Delete selected */} + +
+
+ ); +}; diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/CheckboxCell.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/CheckboxCell.tsx new file mode 100644 index 0000000..f1c8bb1 --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/CheckboxCell.tsx @@ -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; + options?: SelectOption[]; + rowIndex: number; + isValidating: boolean; + errors: ValidationError[]; + onChange: (value: unknown) => void; + onBlur: (value: unknown) => void; + onFetchOptions?: () => void; + booleanMatches?: Record; +} + +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 ( +
+ +
+ ); +}; + +// Memoize to prevent re-renders when parent table state changes +export const CheckboxCell = memo(CheckboxCellComponent); diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/ComboboxCell.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/ComboboxCell.tsx new file mode 100644 index 0000000..0569c16 --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/ComboboxCell.tsx @@ -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; + options?: SelectOption[]; + rowIndex: number; + isValidating: boolean; + errors: ValidationError[]; + onChange: (value: unknown) => void; + onBlur: (value: unknown) => void; + onFetchOptions?: () => Promise; +} + +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 ( +
+ + + + + + + + + {isLoadingOptions ? ( +
+ +
+ ) : ( + <> + No {field.label.toLowerCase()} found. + + {options.map((option) => ( + handleSelect(option.value)} + > + + {option.label} + + ))} + + + )} +
+
+
+
+ {isValidating && ( +
+ +
+ )} +
+ ); +}; + +// Memoize to prevent re-renders when parent table state changes +export const ComboboxCell = memo(ComboboxCellComponent); diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/InputCell.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/InputCell.tsx new file mode 100644 index 0000000..46b7eb0 --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/InputCell.tsx @@ -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; + 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(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) => { + 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 ( +
+ + {isValidating && ( +
+ +
+ )} +
+ ); +}; + +// Memoize to prevent re-renders when parent table state changes +export const InputCell = memo(InputCellComponent); diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultiSelectCell.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultiSelectCell.tsx new file mode 100644 index 0000000..319816c --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultiSelectCell.tsx @@ -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; + 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 ( + + + + + + + + + No options found. + + {options.map((option) => ( + handleSelect(option.value)} + > + + {option.label} + + ))} + + + + + + ); +}; + +// Memoize to prevent re-renders when parent table state changes +export const MultiSelectCell = memo(MultiSelectCellComponent); diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx new file mode 100644 index 0000000..7584b64 --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx @@ -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; + 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(null); + const cellRef = useRef(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) => { + setEditValue(e.target.value); + }, []); + + // Calculate display value + const displayValue = localDisplayValue !== null ? localDisplayValue : String(value ?? ''); + + return ( +
+ + +
+ {displayValue} +
+
+ +
+ + +