From 6bf93d33ea9f7049ff15a1cd8b468ac00508077f Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 24 Feb 2025 15:03:32 -0500 Subject: [PATCH] Add global options to pass in to validate step, move remove empty/duplicate button to select header row step --- .../MatchColumnsStep/MatchColumnsStep.tsx | 239 +++++++++- .../components/ColumnGrid.tsx | 126 ++--- .../SelectHeaderStep/SelectHeaderStep.tsx | 143 +++++- .../src/steps/UploadFlow.tsx | 33 +- .../steps/ValidationStep/ValidationStep.tsx | 441 ++++++++++++------ .../lib/react-spreadsheet-import/src/types.ts | 37 +- inventory/src/pages/Import.tsx | 1 + 7 files changed, 754 insertions(+), 266 deletions(-) diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/MatchColumnsStep.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/MatchColumnsStep.tsx index 3837529..7fd86bb 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/MatchColumnsStep.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/MatchColumnsStep.tsx @@ -24,12 +24,31 @@ import { AlertDialogOverlay, } from "@/components/ui/alert-dialog" import { DeepReadonly as TsDeepReadonly } from "ts-essentials" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { useQuery } from "@tanstack/react-query" +import config from "@/config" +import { Button } from "@/components/ui/button" export type MatchColumnsProps = { data: RawData[] headerValues: RawData - onContinue: (data: any[], rawData: RawData[], columns: Columns) => void + onContinue: (data: any[], rawData: RawData[], columns: Columns, globalSelections?: GlobalSelections) => void onBack?: () => void + initialGlobalSelections?: GlobalSelections +} + +export type GlobalSelections = { + supplier?: string + company?: string + line?: string + subline?: string } export enum ColumnType { @@ -98,6 +117,7 @@ export const MatchColumnsStep = ({ headerValues, onContinue, onBack, + initialGlobalSelections }: MatchColumnsProps) => { const dataExample = data.slice(0, 2) const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations, allowInvalidSubmit } = useRsi() @@ -107,6 +127,54 @@ export const MatchColumnsStep = ({ ([...headerValues] as string[]).map((value, index) => ({ type: ColumnType.empty, index, header: value ?? "" })), ) const [showUnmatchedFieldsAlert, setShowUnmatchedFieldsAlert] = useState(false) + const [globalSelections, setGlobalSelections] = useState(initialGlobalSelections || {}) + + // Initialize with any provided global selections + useEffect(() => { + if (initialGlobalSelections) { + setGlobalSelections(initialGlobalSelections) + } + }, [initialGlobalSelections]) + + // Fetch field options from the API + const { data: fieldOptions } = useQuery({ + queryKey: ["import-field-options"], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/import/field-options`); + if (!response.ok) { + throw new Error("Failed to fetch field options"); + } + return response.json(); + }, + }); + + // Fetch product lines when company is selected + const { data: productLines } = useQuery({ + queryKey: ["product-lines", globalSelections.company], + queryFn: async () => { + if (!globalSelections.company) return []; + const response = await fetch(`${config.apiUrl}/import/product-lines/${globalSelections.company}`); + if (!response.ok) { + throw new Error("Failed to fetch product lines"); + } + return response.json(); + }, + enabled: !!globalSelections.company, + }); + + // Fetch sublines when line is selected + const { data: sublines } = useQuery({ + queryKey: ["sublines", globalSelections.line], + queryFn: async () => { + if (!globalSelections.line) return []; + const response = await fetch(`${config.apiUrl}/import/sublines/${globalSelections.line}`); + if (!response.ok) { + throw new Error("Failed to fetch sublines"); + } + return response.json(); + }, + enabled: !!globalSelections.line, + }); const onChange = useCallback( (value: T, columnIndex: number) => { @@ -185,17 +253,19 @@ export const MatchColumnsStep = ({ setShowUnmatchedFieldsAlert(true) } else { setIsLoading(true) - await onContinue(normalizeTableData(columns, data, fields), data, columns) + // Normalize the data with global selections before continuing + const normalizedData = normalizeTableData(columns, data, fields) + await onContinue(normalizedData, data, columns, globalSelections) setIsLoading(false) } - }, [unmatchedRequiredFields.length, onContinue, columns, data, fields]) + }, [unmatchedRequiredFields.length, onContinue, columns, data, fields, globalSelections]) const handleAlertOnContinue = useCallback(async () => { setShowUnmatchedFieldsAlert(false) setIsLoading(true) - await onContinue(normalizeTableData(columns, data, fields), data, columns) + await onContinue(normalizeTableData(columns, data, fields), data, columns, globalSelections) setIsLoading(false) - }, [onContinue, columns, data, fields]) + }, [onContinue, columns, data, fields, globalSelections]) useEffect( () => { @@ -242,21 +312,150 @@ export const MatchColumnsStep = ({ - ( - row[column.index])} - /> - )} - templateColumn={(column) => } - /> +
+
+
+
+
+ {/* Global Selections Section */} + + + Global Selections + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+ ( + row[column.index])} + /> + )} + templateColumn={(column) => } + /> +
+
+
+
+
+
+
+ {onBack && ( + + )} + +
+
+
) } diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/components/ColumnGrid.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/components/ColumnGrid.tsx index 539ac29..6ea072b 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/components/ColumnGrid.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/MatchColumnsStep/components/ColumnGrid.tsx @@ -2,7 +2,6 @@ import type React from "react" import type { Column, Columns } from "../MatchColumnsStep" import { ColumnType } from "../MatchColumnsStep" import { useRsi } from "../../../hooks/useRsi" -import { Button } from "@/components/ui/button" import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" type ColumnGridProps = { @@ -18,9 +17,6 @@ export const ColumnGrid = ({ columns, userColumn, templateColumn, - onContinue, - onBack, - isLoading, }: ColumnGridProps) => { const { translations } = useRsi() const normalColumnWidth = 250 @@ -32,81 +28,61 @@ export const ColumnGrid = ({ ) return ( -
-
-
-
-

- {translations.matchColumnsStep.title} -

-
- -
- {/* Your table section */} -
-

- {translations.matchColumnsStep.userTableTitle} -

-
-
- `${col.type === ColumnType.ignored ? ignoredColumnWidth : normalColumnWidth}px` - ).join(" "), - }} - > - {columns.map((column, index) => ( -
- {userColumn(column)} -
- ))} +
+
+

+ {translations.matchColumnsStep.title} +

+
+ +
+ {/* Your table section */} +
+

+ {translations.matchColumnsStep.userTableTitle} +

+
+
+ `${col.type === ColumnType.ignored ? ignoredColumnWidth : normalColumnWidth}px` + ).join(" "), + }} + > + {columns.map((column, index) => ( +
+ {userColumn(column)}
-
-
-
- - {/* Will become section */} -
-

- {translations.matchColumnsStep.templateTitle} -

-
- `${col.type === ColumnType.ignored ? ignoredColumnWidth : normalColumnWidth}px` - ).join(" "), - }} - > - {columns.map((column, index) => ( -
- {templateColumn(column)} -
- ))} -
+ ))}
+
- - +
+ + {/* Will become section */} +
+

+ {translations.matchColumnsStep.templateTitle} +

+
+ `${col.type === ColumnType.ignored ? ignoredColumnWidth : normalColumnWidth}px` + ).join(" "), + }} + > + {columns.map((column, index) => ( +
+ {templateColumn(column)} +
+ ))} +
+
-
-
-
- {onBack && ( - - )} - -
-
+ +
) } diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/SelectHeaderStep/SelectHeaderStep.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/SelectHeaderStep/SelectHeaderStep.tsx index 256ad98..ef9a098 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/SelectHeaderStep/SelectHeaderStep.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/SelectHeaderStep/SelectHeaderStep.tsx @@ -3,6 +3,7 @@ import { SelectHeaderTable } from "./components/SelectHeaderTable" import { useRsi } from "../../hooks/useRsi" import type { RawData } from "../../types" import { Button } from "@/components/ui/button" +import { useToast } from "@/hooks/use-toast" type SelectHeaderProps = { data: RawData[] @@ -12,17 +13,142 @@ type SelectHeaderProps = { export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps) => { const { translations } = useRsi() + const { toast } = useToast() const [selectedRows, setSelectedRows] = useState>(new Set([0])) const [isLoading, setIsLoading] = useState(false) + const [localData, setLocalData] = useState(data) const handleContinue = useCallback(async () => { const [selectedRowIndex] = selectedRows // We consider data above header to be redundant - const trimmedData = data.slice(selectedRowIndex + 1) + const trimmedData = localData.slice(selectedRowIndex + 1) setIsLoading(true) - await onContinue(data[selectedRowIndex], trimmedData) + await onContinue(localData[selectedRowIndex], trimmedData) setIsLoading(false) - }, [onContinue, data, selectedRows]) + }, [onContinue, localData, selectedRows]) + + const discardEmptyAndDuplicateRows = useCallback(() => { + // Helper function to count non-empty values in a row + const countNonEmptyValues = (values: Record): number => { + return Object.values(values).filter(val => + val !== undefined && + val !== null && + (typeof val === 'string' ? val.trim() !== '' : true) + ).length; + }; + + // Helper function to normalize row values for case-insensitive comparison + const normalizeRowForComparison = (row: Record): Record => { + return Object.entries(row).reduce((acc, [key, value]) => { + // Convert string values to lowercase for case-insensitive comparison + if (typeof value === 'string') { + acc[key.toLowerCase()] = value.toLowerCase().trim(); + } else { + acc[key.toLowerCase()] = value; + } + return acc; + }, {} as Record); + }; + + // First, analyze all rows to determine if we have rows with multiple values + const rowsWithValues = localData.map(row => { + return countNonEmptyValues(row); + }); + + // Check if we have any rows with more than one value + const hasMultiValueRows = rowsWithValues.some(count => count > 1); + + // Get the selected header row + const [selectedRowIndex] = selectedRows; + const selectedHeaderRow = localData[selectedRowIndex]; + + // Debug: Log the selected header row + console.log("Selected header row:", selectedHeaderRow); + + const normalizedHeaderRow = normalizeRowForComparison(selectedHeaderRow); + + // Debug: Log the normalized header row + console.log("Normalized header row:", normalizedHeaderRow); + + const selectedHeaderStr = JSON.stringify(Object.entries(normalizedHeaderRow).sort()); + + // Filter out empty rows, rows with single values (if we have multi-value rows), + // and duplicate rows (including duplicates of the header row) + const seen = new Set(); + // Add the selected header row to the seen set first + seen.add(selectedHeaderStr); + + // Debug: Track which rows are being removed and why + const removedRows: { index: number; reason: string; row: any }[] = []; + + const filteredRows = localData.filter((row, index) => { + // Always keep the selected header row + if (index === selectedRowIndex) { + return true; + } + + // Check if it's empty or has only one value + const nonEmptyCount = rowsWithValues[index]; + if (nonEmptyCount === 0 || (hasMultiValueRows && nonEmptyCount <= 1)) { + removedRows.push({ index, reason: "Empty or single value", row }); + return false; + } + + // Check if it's a duplicate (case-insensitive) + const normalizedRow = normalizeRowForComparison(row); + + // Debug: If this row might be a duplicate of the header, log it + if (index < 5 || index === selectedRowIndex + 1 || index === selectedRowIndex - 1) { + console.log(`Row ${index} normalized:`, normalizedRow); + } + + const rowStr = JSON.stringify(Object.entries(normalizedRow).sort()); + + if (seen.has(rowStr)) { + removedRows.push({ + index, + reason: "Duplicate", + row, + normalizedRow, + rowStr, + headerStr: selectedHeaderStr + }); + return false; + } + + seen.add(rowStr); + return true; + }); + + // Debug: Log removed rows + console.log("Removed rows:", removedRows); + + // Only update if we actually removed any rows + if (filteredRows.length < localData.length) { + // Adjust the selected row index if needed + const newSelectedIndex = filteredRows.findIndex(row => + JSON.stringify(Object.entries(normalizeRowForComparison(row)).sort()) === selectedHeaderStr + ); + + // Debug: Log the new selected index + console.log("New selected index:", newSelectedIndex); + + setLocalData(filteredRows); + setSelectedRows(new Set([newSelectedIndex])); + + toast({ + title: "Rows removed", + description: `Removed ${localData.length - filteredRows.length} empty, single-value, or duplicate rows`, + variant: "default" + }); + } else { + toast({ + title: "No rows removed", + description: "No empty, single-value, or duplicate rows were found", + variant: "default" + }); + } + }, [localData, selectedRows, toast]); return (
@@ -32,8 +158,17 @@ export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps
+
+ +
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/UploadFlow.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/UploadFlow.tsx index 00e67fd..82cbcdc 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/UploadFlow.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/UploadFlow.tsx @@ -6,7 +6,7 @@ import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep" import { mapWorkbook } from "../utils/mapWorkbook" import { ValidationStep } from "./ValidationStep/ValidationStep" import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations" -import { MatchColumnsStep } from "./MatchColumnsStep/MatchColumnsStep" +import { MatchColumnsStep, type GlobalSelections } from "./MatchColumnsStep/MatchColumnsStep" import { exceedsMaxRecords } from "../utils/exceedsMaxRecords" import { useRsi } from "../hooks/useRsi" import type { RawData } from "../types" @@ -20,6 +20,7 @@ export enum StepType { matchColumns = "matchColumns", validateData = "validateData", } + export type StepState = | { type: StepType.upload @@ -36,10 +37,12 @@ export type StepState = type: StepType.matchColumns data: RawData[] headerValues: RawData + globalSelections?: GlobalSelections } | { type: StepType.validateData data: any[] + globalSelections?: GlobalSelections } interface Props { @@ -72,6 +75,13 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => { [toast, translations], ) + // Keep track of global selections across steps + const [persistedGlobalSelections, setPersistedGlobalSelections] = useState( + state.type === StepType.validateData || state.type === StepType.matchColumns + ? state.globalSelections + : undefined + ) + switch (state.type) { case StepType.upload: return ( @@ -132,6 +142,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => { type: StepType.matchColumns, data, headerValues, + globalSelections: persistedGlobalSelections, }) } catch (e) { errorToast((e as Error).message) @@ -145,13 +156,16 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => { { + initialGlobalSelections={persistedGlobalSelections} + onContinue={async (values, rawData, columns, globalSelections) => { try { const data = await matchColumnsStepHook(values, rawData, columns) const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook) + setPersistedGlobalSelections(globalSelections) onNext({ type: StepType.validateData, data: dataWithMeta, + globalSelections, }) } catch (e) { errorToast((e as Error).message) @@ -161,7 +175,20 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => { /> ) case StepType.validateData: - return + return ( + { + if (onBack) { + // When going back, preserve the global selections + setPersistedGlobalSelections(state.globalSelections) + onBack() + } + }} + globalSelections={state.globalSelections} + /> + ) default: return } diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx index 7bcb7db..342d365 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useState, useEffect, memo } from "react" import { useRsi } from "../../hooks/useRsi" import type { Meta, Error } from "./types" import { addErrorsAndRunHooks } from "./utils/dataMutations" -import type { Data, SelectOption, Result } from "../../types" +import type { Data, SelectOption, Result, Fields, Field } from "../../types" import { Check, ChevronsUpDown, ArrowDown, AlertCircle, Loader2 } from "lucide-react" import { cn } from "@/lib/utils" import { @@ -74,6 +74,8 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import type { GlobalSelections } from "../MatchColumnsStep/MatchColumnsStep" +import { useQuery } from "@tanstack/react-query" // Template interface interface Template { @@ -103,8 +105,10 @@ type Props = { initialData: RowData[] file: File onBack?: () => void + globalSelections?: GlobalSelections } +// Remove the local Field type declaration since we're importing it type BaseFieldType = { multiline?: boolean; price?: boolean; @@ -121,7 +125,7 @@ type MultiInputFieldType = BaseFieldType & { type SelectFieldType = { type: "select" | "multi-select"; - options: SelectOption[]; + options: readonly SelectOption[]; } type CheckboxFieldType = { @@ -131,23 +135,14 @@ type CheckboxFieldType = { type FieldType = InputFieldType | MultiInputFieldType | SelectFieldType | CheckboxFieldType; -type Field = { - label: string; - key: T; - description?: string; - alternateMatches?: string[]; - validations?: ({ rule: string } & Record)[]; - fieldType: FieldType; - width?: number; - disabled?: boolean; - onChange?: (value: string) => void; -} type CellProps = { value: any; onChange: (value: any) => void; error?: { level: string; message: string }; field: Field; + productLines?: SelectOption[]; + sublines?: SelectOption[]; } // Define ValidationIcon before EditableCell @@ -167,12 +162,39 @@ const ValidationIcon = memo(({ error }: { error: { level: string, message: strin )) // Wrap EditableCell with memo to avoid unnecessary re-renders -const EditableCell = memo(({ value, onChange, error, field }: CellProps) => { +const EditableCell = memo(({ value, onChange, error, field, productLines, sublines }: CellProps) => { const [isEditing, setIsEditing] = useState(false) const [inputValue, setInputValue] = useState(value ?? "") const [searchQuery, setSearchQuery] = useState("") const [localValues, setLocalValues] = useState([]) + // Determine if the field should be disabled based on its key and context + const isFieldDisabled = useMemo(() => { + if (field.key === 'line') { + // Enable the line field if we have product lines available + return !productLines || productLines.length === 0; + } + if (field.key === 'subline') { + // Enable subline field if we have sublines available + return !sublines || sublines.length === 0; + } + return field.disabled; + }, [field.key, field.disabled, productLines, sublines]); + + // For debugging + useEffect(() => { + if (field.key === 'subline') { + console.log('Subline field state:', { + disabled: field.disabled, + isFieldDisabled, + value, + options: field.fieldType.type === 'select' ? field.fieldType.options : [], + sublines, + hasSublines: sublines && sublines.length > 0 + }); + } + }, [field, value, sublines, isFieldDisabled]); + const handleWheel = useCallback((e: React.WheelEvent) => { const commandList = e.currentTarget; commandList.scrollTop += e.deltaY; @@ -245,21 +267,33 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => { const getDisplayValue = (value: any, fieldType: Field["fieldType"]) => { if (fieldType.type === "select" || fieldType.type === "multi-select") { if (fieldType.type === "select") { - return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value + // For line and subline fields, ensure we're using the latest options + if (field.key === 'line' && productLines?.length) { + const option = productLines.find((opt: SelectOption) => opt.value === value); + return option?.label || value; + } + if (field.key === 'subline' && sublines?.length) { + const option = sublines.find((opt: SelectOption) => opt.value === value); + return option?.label || value; + } + return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value; } if (Array.isArray(value)) { - return value.map(v => fieldType.options.find((opt: SelectOption) => opt.value === v)?.label || v).join(", ") + const options = field.key === 'line' && productLines?.length ? productLines : + field.key === 'subline' && sublines?.length ? sublines : + fieldType.options; + return value.map(v => options.find((opt: SelectOption) => opt.value === v)?.label || v).join(", "); } - return value + return value; } if (fieldType.type === "checkbox") { - if (typeof value === "boolean") return value ? "Yes" : "No" - return value + if (typeof value === "boolean") return value ? "Yes" : "No"; + return value; } if (fieldType.type === "multi-input" && Array.isArray(value)) { - return value.join(", ") + return value.join(", "); } - return value + return value; } const validateAndCommit = (newValue: string | boolean) => { @@ -329,7 +363,9 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => { No options found. - {field.fieldType.options + {(field.key === 'line' && productLines ? productLines : + field.key === 'subline' && sublines ? sublines : + field.fieldType.options) .filter(option => option.label.toLowerCase().includes(searchQuery.toLowerCase()) ) @@ -412,7 +448,9 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => { No options found. - {field.fieldType.options + {(field.key === 'line' && productLines ? productLines : + field.key === 'subline' && sublines ? sublines : + field.fieldType.options) .filter(option => option.label.toLowerCase().includes(searchQuery.toLowerCase()) ) @@ -578,7 +616,7 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
{ - if (field.fieldType.type !== "checkbox" && !field.disabled) { + if (field.fieldType.type !== "checkbox" && !isFieldDisabled) { e.stopPropagation() setIsEditing(true) setInputValue(Array.isArray(value) ? value.join(", ") : value ?? "") @@ -590,7 +628,7 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => { field.fieldType.multiline && "max-h-[100px] overflow-y-auto", currentError ? "border-destructive" : "border-input", field.fieldType.type === "checkbox" ? "flex items-center" : "flex items-center justify-between", - field.disabled && "opacity-50 cursor-not-allowed bg-muted" + isFieldDisabled && "opacity-50 cursor-not-allowed bg-muted" )} > {((field.fieldType.type === "input" || field.fieldType.type === "multi-input") && field.fieldType.multiline) ? ( @@ -620,7 +658,7 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
)} {(field.fieldType.type === "select" || field.fieldType.type === "multi-select") && ( - + )} {currentError && }
@@ -1013,11 +1051,152 @@ const SaveTemplateDialog = memo(({ export const ValidationStep = ({ initialData, file, - onBack}: Props) => { + onBack, + globalSelections +}: Props) => { const { translations, fields, onClose, onSubmit, rowHook, tableHook, allowInvalidSubmit } = useRsi(); const { toast } = useToast(); - const [data, setData] = useState<(Data & ExtendedMeta)[]>(initialData) + // Fetch product lines when company is selected + const { data: productLines } = useQuery({ + queryKey: ["product-lines", globalSelections?.company], + queryFn: async () => { + if (!globalSelections?.company) return []; + console.log('Fetching product lines for company:', globalSelections.company); + const response = await fetch(`${config.apiUrl}/import/product-lines/${globalSelections.company}`); + if (!response.ok) { + console.error('Failed to fetch product lines:', response.status, response.statusText); + throw new Error("Failed to fetch product lines"); + } + const data = await response.json(); + console.log('Received product lines:', data); + return data; + }, + enabled: !!globalSelections?.company, + staleTime: 30000, // Cache for 30 seconds + }); + + // Fetch sublines when line is selected + const { data: sublines } = useQuery({ + queryKey: ["sublines", globalSelections?.line], + queryFn: async () => { + if (!globalSelections?.line) return []; + const response = await fetch(`${config.apiUrl}/import/sublines/${globalSelections.line}`); + if (!response.ok) { + throw new Error("Failed to fetch sublines"); + } + return response.json(); + }, + enabled: !!globalSelections?.line, + }); + + // Apply global selections to initial data and validate + const initialDataWithGlobals = useMemo(() => { + if (!globalSelections) return initialData; + + // Find the field definitions for our global selection fields + const supplierField = Array.from(fields as ReadonlyFields).find(f => f.key === 'supplier'); + const companyField = Array.from(fields as ReadonlyFields).find(f => f.key === 'company'); + const lineField = Array.from(fields as ReadonlyFields).find(f => f.key === 'line'); + const sublineField = Array.from(fields as ReadonlyFields).find(f => f.key === 'subline'); + + // Helper function to safely set a field value and update options if needed + const setFieldValue = (field: Field | undefined, value: string | undefined, options?: SelectOption[]) => { + if (!field || !value) return undefined; + if (field.fieldType.type === 'select') { + // Use provided options if available, otherwise use field's default options + const fieldOptions = options || (field.fieldType as SelectFieldType).options; + // First try to find by value (ID) + const optionByValue = fieldOptions.find(opt => opt.value === value); + if (optionByValue) { + return optionByValue.value; + } + // Then try to find by label (name) + const optionByLabel = fieldOptions.find(opt => opt.label.toLowerCase() === value.toLowerCase()); + if (optionByLabel) { + return optionByLabel.value; + } + } + return value; + }; + + // Apply global selections to each row + const newData = initialData.map(row => { + const newRow = { ...row }; + + // Apply each global selection if it exists + if (globalSelections.supplier) { + const supplierValue = setFieldValue(supplierField as Field, globalSelections.supplier); + if (supplierValue) newRow.supplier = supplierValue; + } + + if (globalSelections.company) { + const companyValue = setFieldValue(companyField as Field, globalSelections.company); + if (companyValue) newRow.company = companyValue; + } + + if (globalSelections.line && productLines) { + const lineValue = setFieldValue(lineField as Field, globalSelections.line, productLines); + if (lineValue) newRow.line = lineValue; + } + + if (globalSelections.subline && sublines) { + const sublineValue = setFieldValue(sublineField as Field, globalSelections.subline, sublines); + if (sublineValue) newRow.subline = sublineValue; + } + + return newRow; + }); + + return newData; + }, [initialData, globalSelections, fields, productLines, sublines]); + + // Update field options with fetched data + const fieldsWithUpdatedOptions = useMemo(() => { + return Array.from(fields as ReadonlyFields).map(field => { + if (field.key === 'line') { + return { + ...field, + fieldType: { + ...field.fieldType, + options: productLines || (field.fieldType.type === 'select' ? field.fieldType.options : []), + }, + disabled: (!productLines || productLines.length === 0) && !globalSelections?.line + } as Field; + } + if (field.key === 'subline') { + return { + ...field, + fieldType: { + ...field.fieldType, + options: sublines || (field.fieldType.type === 'select' ? field.fieldType.options : []), + }, + // Enable subline field if we have a global line selection or if we have sublines available + disabled: !globalSelections?.line && (!sublines || sublines.length === 0) + } as Field; + } + return field; + }); + }, [fields, productLines, sublines, globalSelections?.line]); + + const [data, setData] = useState[]>(initialDataWithGlobals); + + // Run validation when component mounts or when global selections change + useEffect(() => { + const validateData = async () => { + // Cast the fields to the expected type for validation + const validationFields = fieldsWithUpdatedOptions as unknown as Fields; + const validatedData = await addErrorsAndRunHooks( + initialDataWithGlobals, + validationFields, + rowHook, + tableHook + ); + setData(validatedData as RowData[]); + }; + validateData(); + }, [initialDataWithGlobals, fieldsWithUpdatedOptions, rowHook, tableHook]); + const [rowSelection, setRowSelection] = useState({}) const [filterByErrors, setFilterByErrors] = useState(false) const [showSubmitAlert, setShowSubmitAlert] = useState(false) @@ -1211,6 +1390,8 @@ export const ValidationStep = ({ onChange={(newValue) => updateRows(rowIndex, column.id, newValue)} error={error} field={field as Field} + productLines={productLines} + sublines={sublines} /> ) }, @@ -1222,7 +1403,7 @@ export const ValidationStep = ({ }))) ] return baseColumns - }, [fields, updateRows, data, copyValueDown, templates, applyTemplate]) + }, [fields, updateRows, data, copyValueDown, templates, applyTemplate, productLines, sublines]) const table = useReactTable({ data: filteredData, @@ -1244,70 +1425,17 @@ export const ValidationStep = ({ } }, [rowSelection, data, updateData]); - const discardEmptyAndDuplicateRows = useCallback(() => { - // Helper function to count non-empty values in a row - const countNonEmptyValues = (values: Record): number => { - return Object.values(values).filter(val => - val !== undefined && - val !== null && - (typeof val === 'string' ? val.trim() !== '' : true) - ).length; - }; - - // First, analyze all rows to determine if we have rows with multiple values - const rowsWithValues = data.map(row => { - const { __index, __errors, ...values } = row; - return countNonEmptyValues(values); - }); - - // Check if we have any rows with more than one value - const hasMultiValueRows = rowsWithValues.some(count => count > 1); - - // Filter out empty rows and rows with single values (if we have multi-value rows) - const nonEmptyRows = data.filter((_row, index) => { - const nonEmptyCount = rowsWithValues[index]; - - // Keep the row if: - // 1. It has more than one value, OR - // 2. It has exactly one value AND we don't have any rows with multiple values - return nonEmptyCount > 0 && (!hasMultiValueRows || nonEmptyCount > 1); - }); - - // Then, remove duplicates by creating a unique string representation of each row - const seen = new Set(); - const uniqueRows = nonEmptyRows.filter(row => { - const { __index, __errors, ...values } = row; - const rowStr = JSON.stringify(Object.entries(values).sort()); - if (seen.has(rowStr)) { - return false; - } - seen.add(rowStr); - return true; - }); - - // Only update if we actually removed any rows - if (uniqueRows.length < data.length) { - updateData(uniqueRows); - setRowSelection({}); - toast({ - title: "Rows removed", - description: `Removed ${data.length - uniqueRows.length} empty, single-value, or duplicate rows`, - variant: "default" - }); - } - }, [data, updateData, toast]); - - const normalizeValue = useCallback((value: any, field: DeepReadonlyField) => { + const normalizeValue = useCallback((value: any, field: DeepReadonlyField): string | undefined => { if (field.fieldType.type === "checkbox") { - if (typeof value === "boolean") return value + if (typeof value === "boolean") return value ? "true" : "false" if (typeof value === "string") { const normalizedValue = value.toLowerCase().trim() if (field.fieldType.booleanMatches) { - return !!field.fieldType.booleanMatches[normalizedValue] + return !!field.fieldType.booleanMatches[normalizedValue] ? "true" : "false" } - return ["yes", "true", "1"].includes(normalizedValue) + return ["yes", "true", "1"].includes(normalizedValue) ? "true" : "false" } - return false + return "false" } if (field.fieldType.type === "select" && field.fieldType.options) { // Ensure the value matches one of the options @@ -1320,61 +1448,85 @@ export const ValidationStep = ({ ) return matchByLabel ? matchByLabel.value : value } - return value + return value?.toString() }, []) const submitData = useCallback(async () => { - const calculatedData: Result = data.reduce( - (acc, value) => { - const { __index, __errors, __template, ...values } = value - - const normalizedValues = Object.entries(values).reduce((obj, [key, val]) => { - const field = Array.from(fields as ReadonlyFields).find((f) => f.key === key) - if (field) { - obj[key as keyof Data] = normalizeValue(val, field as Field) - } else { - obj[key as keyof Data] = val as string | boolean | undefined - } - return obj - }, {} as Data) + const result: Result = { + validData: [], + invalidData: [], + all: data + }; - if (__errors) { - for (const key in __errors) { - if (__errors[key].level === "error") { - acc.invalidData.push(normalizedValues) - return acc - } + data.forEach((value) => { + const { __index, __errors, __template, ...values } = value; + + const normalizedValues = Object.entries(values).reduce((obj, [key, val]) => { + const field = Array.from(fields as ReadonlyFields).find((f) => f.key === key); + if (field) { + const normalizedVal = normalizeValue(val, field as Field); + if (normalizedVal !== undefined) { + obj[key as keyof Data] = normalizedVal as Data[keyof Data]; + } + } else if (val !== undefined) { + obj[key as keyof Data] = String(val) as Data[keyof Data]; + } + return obj; + }, {} as Data); + + // Apply global selections with proper normalization + if (globalSelections) { + const supplierField = Array.from(fields as ReadonlyFields).find(f => f.key === 'supplier'); + const companyField = Array.from(fields as ReadonlyFields).find(f => f.key === 'company'); + const lineField = Array.from(fields as ReadonlyFields).find(f => f.key === 'line'); + const sublineField = Array.from(fields as ReadonlyFields).find(f => f.key === 'subline'); + + const supplier = normalizeValue(normalizedValues.supplier, supplierField as Field); + const company = normalizeValue(normalizedValues.company, companyField as Field); + const line = normalizeValue(normalizedValues.line, lineField as Field); + const subline = normalizeValue(normalizedValues.subline, sublineField as Field); + + if (supplier) normalizedValues.supplier = supplier; + if (company) normalizedValues.company = company; + if (line) normalizedValues.line = line; + if (subline) normalizedValues.subline = subline; + } + + if (__errors) { + for (const key in __errors) { + if (__errors[key].level === "error") { + result.invalidData.push(normalizedValues); + return; } } - acc.validData.push(normalizedValues) - return acc - }, - { validData: [] as Data[], invalidData: [] as Data[], all: data }, - ) - setShowSubmitAlert(false) - setSubmitting(true) - const response = onSubmit(calculatedData, file) + } + result.validData.push(normalizedValues); + }); + + setShowSubmitAlert(false); + setSubmitting(true); + const response = onSubmit(result, file); if (response?.then) { response .then(() => { - onClose() + onClose(); }) .catch((err: Error) => { - const defaultMessage = translations.alerts.submitError.defaultMessage - const errorMessage = typeof defaultMessage === 'string' ? defaultMessage : 'An error occurred' + const defaultMessage = translations.alerts.submitError.defaultMessage; + const errorMessage = typeof defaultMessage === 'string' ? defaultMessage : 'An error occurred'; toast({ variant: "destructive", title: translations.alerts.submitError.title, description: String(err?.message || errorMessage), - }) + }); }) .finally(() => { - setSubmitting(false) - }) + setSubmitting(false); + }); } else { - onClose() + onClose(); } - }, [data, fields, file, onClose, onSubmit, normalizeValue, toast, translations]); + }, [data, fields, file, onClose, onSubmit, normalizeValue, toast, translations, globalSelections]); const onContinue = useCallback(() => { const invalidData = data.find((value) => { @@ -1743,25 +1895,25 @@ export const ValidationStep = ({ ))} -
+ -