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 7108c93..cef1002 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 @@ -1,8 +1,5 @@ -import { useCallback, useEffect, useMemo, useState } from "react" -import { UserTableColumn } from "./components/UserTableColumn" +import React, { useCallback, useEffect, useMemo, useState } from "react" import { useRsi } from "../../hooks/useRsi" -import { TemplateColumn } from "./components/TemplateColumn" -import { ColumnGrid } from "./components/ColumnGrid" import { setColumn } from "./utils/setColumn" import { setIgnoreColumn } from "./utils/setIgnoreColumn" import { setSubColumn } from "./utils/setSubColumn" @@ -11,20 +8,7 @@ import type { Field, RawData, Fields } from "../../types" import { getMatchedColumns } from "./utils/getMatchedColumns" import { findUnmatchedRequiredFields } from "./utils/findUnmatchedRequiredFields" import { toast } from "sonner" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogPortal, - 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, @@ -35,13 +19,13 @@ import { import { useQuery } from "@tanstack/react-query" import config from "@/config" import { Button } from "@/components/ui/button" -import { CheckCircle2, AlertCircle, HelpCircle } from "lucide-react" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" +import { CheckCircle2, AlertCircle, InfoIcon, EyeIcon, EyeOffIcon, ArrowRightIcon, XIcon, FileIcon, LinkIcon } from "lucide-react" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { Separator } from "@/components/ui/separator" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" export type MatchColumnsProps = { data: RawData[] @@ -126,7 +110,7 @@ export const MatchColumnsStep = ({ onBack, initialGlobalSelections }: MatchColumnsProps) => { - const dataExample = data.slice(0, 2) + const dataExample = useMemo(() => data.slice(0, 3), [data]) // Show 3 sample rows const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations } = useRsi() const [isLoading, setIsLoading] = useState(false) const [columns, setColumns] = useState>( @@ -134,6 +118,26 @@ export const MatchColumnsStep = ({ ([...headerValues] as string[]).map((value, index) => ({ type: ColumnType.empty, index, header: value ?? "" })), ) const [globalSelections, setGlobalSelections] = useState(initialGlobalSelections || {}) + const [showAllColumns, setShowAllColumns] = useState(false) + const [expandedValueMappings, setExpandedValueMappings] = useState([]) + + // Toggle the expanded state for value mappings + const toggleValueMapping = (columnIndex: number) => { + setExpandedValueMappings(prev => + prev.includes(columnIndex) + ? prev.filter(idx => idx !== columnIndex) + : [...prev, columnIndex] + ); + }; + + // Check if column is expandable (has value mappings) + const isExpandable = useCallback((column: Column) => { + return ( + column.type === ColumnType.matchedSelect || + column.type === ColumnType.matchedSelectOptions || + column.type === ColumnType.matchedMultiSelect + ); + }, []); // Initialize with any provided global selections useEffect(() => { @@ -182,6 +186,290 @@ export const MatchColumnsStep = ({ enabled: !!globalSelections.line, }); + // Find mapped column for a specific field + const findMappedColumnForField = useCallback((fieldKey: string) => { + return columns.find(col => { + // Check if it's a matched column with value property + return "value" in col && col.value === fieldKey && + (col.type === ColumnType.matched || + col.type === ColumnType.matchedCheckbox || + col.type === ColumnType.matchedSelect || + col.type === ColumnType.matchedSelectOptions || + col.type === ColumnType.matchedMultiInput || + col.type === ColumnType.matchedMultiSelect); + }); + }, [columns]); + + // Get the first value from the sample data for a column + const getFirstValueFromColumn = useCallback((column: Column) => { + if (!data.length || !data[0]) return null; + return data[0][column.index]; + }, [data]); + + // Get mapped company value (if company is mapped to a column) + const mappedCompanyColumn = useMemo(() => findMappedColumnForField('company'), [findMappedColumnForField]); + const mappedCompanyValue = useMemo(() => { + // If using global selection, return that + if (globalSelections.company) return globalSelections.company; + + // If company is mapped to a column, get the first value + if (mappedCompanyColumn && "matchedOptions" in mappedCompanyColumn) { + const firstEntry = data[0]?.[mappedCompanyColumn.index]; + // Find the mapped value for this entry + const mappedOption = mappedCompanyColumn.matchedOptions.find(opt => opt.entry === firstEntry); + return mappedOption?.value as string || null; + } + + return null; + }, [globalSelections.company, mappedCompanyColumn, data]); + + // Get mapped line value (if line is mapped to a column) + const mappedLineColumn = useMemo(() => findMappedColumnForField('line'), [findMappedColumnForField]); + const mappedLineValue = useMemo(() => { + // If using global selection, return that + if (globalSelections.line) return globalSelections.line; + + // If line is mapped to a column, get the first value + if (mappedLineColumn && "matchedOptions" in mappedLineColumn) { + const firstEntry = data[0]?.[mappedLineColumn.index]; + // Find the mapped value for this entry + const mappedOption = mappedLineColumn.matchedOptions.find(opt => opt.entry === firstEntry); + return mappedOption?.value as string || null; + } + + return null; + }, [globalSelections.line, mappedLineColumn, data]); + + // Fetch product lines for mapped company + const { data: mappedProductLines } = useQuery({ + queryKey: ["product-lines-mapped", mappedCompanyValue], + queryFn: async () => { + if (!mappedCompanyValue) return []; + const response = await fetch(`${config.apiUrl}/import/product-lines/${mappedCompanyValue}`); + if (!response.ok) { + throw new Error("Failed to fetch product lines for mapped company"); + } + return response.json(); + }, + enabled: !!mappedCompanyValue && mappedCompanyValue !== globalSelections.company, + }); + + // Fetch sublines for mapped line + const { data: mappedSublines } = useQuery({ + queryKey: ["sublines-mapped", mappedLineValue], + queryFn: async () => { + if (!mappedLineValue) return []; + const response = await fetch(`${config.apiUrl}/import/sublines/${mappedLineValue}`); + if (!response.ok) { + throw new Error("Failed to fetch sublines for mapped line"); + } + return response.json(); + }, + enabled: !!mappedLineValue && mappedLineValue !== globalSelections.line, + }); + + // Check if a field is covered by global selections + const isFieldCoveredByGlobalSelections = useCallback((key: string) => { + const isCovered = (key === 'supplier' && globalSelections.supplier) || + (key === 'company' && globalSelections.company) || + (key === 'line' && globalSelections.line) || + (key === 'subline' && globalSelections.subline); + console.log(`Field ${key} covered by global selections:`, isCovered); + return isCovered; + }, [globalSelections]); + + // Get possible mapping values for a field + const getFieldOptions = useCallback((fieldKey: string) => { + const fieldsArray = Array.isArray(fields) ? fields : [fields]; + const field = fieldsArray.find(f => f.key === fieldKey); + + if (!field) { + console.log(`Field ${fieldKey} not found`); + return []; + } + + // Handle special hierarchical fields + if (fieldKey === 'line') { + // For line, return the appropriate product lines based on the company + if (globalSelections.company) { + console.log(`Using product lines for global company:`, productLines); + return productLines || []; + } else if (mappedCompanyValue) { + console.log(`Using product lines for mapped company:`, mappedProductLines); + return mappedProductLines || []; + } + } else if (fieldKey === 'subline') { + // For subline, return the appropriate sublines based on the line + if (globalSelections.line) { + console.log(`Using sublines for global line:`, sublines); + return sublines || []; + } else if (mappedLineValue) { + console.log(`Using sublines for mapped line:`, mappedSublines); + return mappedSublines || []; + } + } + + // For other fields, check in both places - directly on the field or in fieldType.options + let options = field.options || []; + + // If no options at the root level, check in fieldType + if ((!options || options.length === 0) && field.fieldType && field.fieldType.options) { + options = field.fieldType.options; + console.log(`Found options in fieldType for ${fieldKey}:`, options); + } else { + console.log(`Using options from root level for ${fieldKey}:`, options); + } + + if (!options || options.length === 0) { + console.log(`No options found for field ${fieldKey}`, field); + } + + return options || []; + }, [ + fields, + globalSelections.company, + globalSelections.line, + productLines, + sublines, + mappedCompanyValue, + mappedLineValue, + mappedProductLines, + mappedSublines + ]); + + // Check if a column has unmapped values (for select fields) + const hasUnmappedValues = useCallback((column: Column) => { + if (!isExpandable(column) || !("matchedOptions" in column) || !("value" in column)) { + return false; + } + + const fieldOptions = getFieldOptions(column.value as string); + + // If there are options available but some values aren't mapped, consider it unmapped + return fieldOptions.length > 0 && + column.matchedOptions.some(option => !option.value); + }, [isExpandable, getFieldOptions]); + + // Get matched, unmapped, and columns with unmapped values + const { matchedColumns, unmatchedColumns, columnsWithUnmappedValues } = useMemo(() => { + // First identify columns with unmapped values (they need special treatment) + const withUnmappedValues = columns.filter(col => + col.type !== ColumnType.empty && + col.type !== ColumnType.ignored && + hasUnmappedValues(col) + ); + + // These are columns that are mapped AND have all their values properly mapped + const fullyMapped = columns.filter(col => + col.type !== ColumnType.empty && + col.type !== ColumnType.ignored && + !hasUnmappedValues(col) + ); + + // Unmapped columns + const unmatched = columns.filter(col => col.type === ColumnType.empty); + + return { + matchedColumns: fullyMapped, + unmatchedColumns: unmatched, + columnsWithUnmappedValues: withUnmappedValues + }; + }, [columns, hasUnmappedValues]); + + // Get ignored columns + const ignoredColumns = useMemo(() => { + return columns.filter(col => col.type === ColumnType.ignored); + }, [columns]); + + // Get mapping information for required fields + const requiredFieldMappings = useMemo(() => { + const fieldsArray = Array.isArray(fields) ? fields : [fields]; + const mappings = new Map(); + + // Check global selections + fieldsArray.forEach(field => { + const key = field.key as string; + if (isFieldCoveredByGlobalSelections(key)) { + mappings.set(key, { isGlobal: true }); + } + }); + + // Check column mappings + columns.forEach(column => { + if ("value" in column) { + const key = column.value as string; + if (!mappings.has(key) || !mappings.get(key)?.isGlobal) { + mappings.set(key, { + isGlobal: false, + columnHeader: column.header, + columnIndex: column.index + }); + } + } + }); + + return mappings; + }, [columns, fields, isFieldCoveredByGlobalSelections]); + + // Available fields for mapping (excluding already mapped fields) + const availableFields = useMemo(() => { + const fieldsArray = Array.isArray(fields) ? fields : [fields]; + const mappedFieldKeys = matchedColumns + .filter(col => "value" in col) + .map(col => (col as any).value); + + return fieldsArray.filter(field => + !mappedFieldKeys.includes(field.key) && + !isFieldCoveredByGlobalSelections(field.key) + ); + }, [fields, matchedColumns, isFieldCoveredByGlobalSelections]); + + // All available fields including already mapped ones (for editing mapped columns) + const allFields = useMemo(() => { + const fieldsArray = Array.isArray(fields) ? fields : [fields]; + return fieldsArray.filter(field => !isFieldCoveredByGlobalSelections(field.key)); + }, [fields, isFieldCoveredByGlobalSelections]); + + // For filtering fields by type + const getFieldsByKeyPrefix = useCallback((prefix: string, fieldsToUse = availableFields) => { + return fieldsToUse.filter(field => + typeof field.key === 'string' && field.key.startsWith(prefix) + ); + }, [availableFields]); + + // Group all fields by category (for editing mapped columns) + const allFieldCategories = useMemo(() => { + return [ + { name: "Basic Info", fields: getFieldsByKeyPrefix('basic', allFields) }, + { name: "Product", fields: getFieldsByKeyPrefix('product', allFields) }, + { name: "Inventory", fields: getFieldsByKeyPrefix('inventory', allFields) }, + { name: "Pricing", fields: getFieldsByKeyPrefix('pricing', allFields) }, + { name: "Other", fields: allFields.filter(f => + !f.key.startsWith('basic') && + !f.key.startsWith('product') && + !f.key.startsWith('inventory') && + !f.key.startsWith('pricing') + ) } + ].filter(category => category.fields.length > 0); + }, [allFields, getFieldsByKeyPrefix]); + + // Group available fields by category (for unmapped columns) + const availableFieldCategories = useMemo(() => { + return [ + { name: "Basic Info", fields: getFieldsByKeyPrefix('basic') }, + { name: "Product", fields: getFieldsByKeyPrefix('product') }, + { name: "Inventory", fields: getFieldsByKeyPrefix('inventory') }, + { name: "Pricing", fields: getFieldsByKeyPrefix('pricing') }, + { name: "Other", fields: availableFields.filter(f => + !f.key.startsWith('basic') && + !f.key.startsWith('product') && + !f.key.startsWith('inventory') && + !f.key.startsWith('pricing') + ) } + ].filter(category => category.fields.length > 0); + }, [availableFields, getFieldsByKeyPrefix]); + + // Override onChange to handle the new interface const onChange = useCallback( (value: T, columnIndex: number) => { const field = (fields as Fields).find((f) => f.key === value) @@ -193,7 +481,41 @@ export const MatchColumnsStep = ({ columns.map>((column, index) => { if (columnIndex === index) { // Set the new column value - return setColumn(column, field as Field, data, autoMapSelectValues) + const updatedColumn = setColumn(column, field as Field, data, autoMapSelectValues); + + // Auto-map values if this is a field with options + if (isExpandable(updatedColumn) && "matchedOptions" in updatedColumn) { + // Get available options for this field + const fieldOptions = getFieldOptions(field.key as string); + + if (fieldOptions && fieldOptions.length > 0) { + // Try to auto-map each value + updatedColumn.matchedOptions = updatedColumn.matchedOptions.map(option => { + // If already mapped, keep it + if (option.value) return option; + + const entryValue = String(option.entry || '').trim(); + + // Try to find a case-insensitive match + const matchingOption = fieldOptions.find((opt: { label: string, value: string }) => + String(opt.label).toLowerCase() === entryValue.toLowerCase() || + String(opt.value).toLowerCase() === entryValue.toLowerCase() + ); + + if (matchingOption) { + console.log(`Auto-matched "${entryValue}" to "${matchingOption.label}" (${matchingOption.value})`); + return { + ...option, + value: matchingOption.value + }; + } + + return option; + }); + } + } + + return updatedColumn; } else if (index === existingFieldIndex) { // Clear the old column that had this field toast.warning(translations.matchColumnsStep.duplicateColumnWarningTitle, { @@ -212,11 +534,64 @@ export const MatchColumnsStep = ({ columns, data, fields, + getFieldOptions, + isExpandable, translations.matchColumnsStep.duplicateColumnWarningDescription, translations.matchColumnsStep.duplicateColumnWarningTitle, ], ) + // Auto-map values for all columns + const autoMapAllValues = useCallback(() => { + setColumns( + columns.map>((column) => { + // Only process expandable columns with unmatched options + if (isExpandable(column) && "matchedOptions" in column && "value" in column) { + const fieldKey = column.value as string; + const fieldOptions = getFieldOptions(fieldKey); + + if (fieldOptions && fieldOptions.length > 0) { + // Create a copy of the column + const updatedColumn = { ...column }; + + // Try to auto-map each value + updatedColumn.matchedOptions = updatedColumn.matchedOptions.map(option => { + // If already mapped, keep it + if (option.value) return option; + + const entryValue = String(option.entry || '').trim(); + + // Try to find a case-insensitive match + const matchingOption = fieldOptions.find((opt: { label: string, value: string }) => + String(opt.label).toLowerCase() === entryValue.toLowerCase() || + String(opt.value).toLowerCase() === entryValue.toLowerCase() + ); + + if (matchingOption) { + console.log(`Auto-matched "${entryValue}" to "${matchingOption.label}" (${matchingOption.value})`); + return { + ...option, + value: matchingOption.value + }; + } + + return option; + }); + + return updatedColumn; + } + } + + return column; + }), + ) + }, [columns, getFieldOptions, isExpandable]); + + // Run auto-mapping on component mount + useEffect(() => { + autoMapAllValues(); + }, [autoMapAllValues]); + const onIgnore = useCallback( (columnIndex: number) => { setColumns(columns.map((column, index) => (columnIndex === index ? setIgnoreColumn(column) : column))) @@ -315,16 +690,6 @@ export const MatchColumnsStep = ({ return field?.label || key; }, [fields]); - // Check if a field is covered by global selections - const isFieldCoveredByGlobalSelections = useCallback((key: string) => { - const isCovered = (key === 'supplier' && globalSelections.supplier) || - (key === 'company' && globalSelections.company) || - (key === 'line' && globalSelections.line) || - (key === 'subline' && globalSelections.subline); - console.log(`Field ${key} covered by global selections:`, isCovered); - return isCovered; - }, [globalSelections]); - const handleOnContinue = useCallback(async () => { setIsLoading(true) // Normalize the data with global selections before continuing @@ -343,19 +708,173 @@ export const MatchColumnsStep = ({ [], ) + // Helper to get sample data for a column + const getColumnSamples = (columnIndex: number) => { + return dataExample.map(row => row[columnIndex]); + }; + + // Automatically expand columns with unmapped values + useEffect(() => { + columnsWithUnmappedValues.forEach(column => { + if (!expandedValueMappings.includes(column.index)) { + setExpandedValueMappings(prev => [...prev, column.index]); + } + }); + }, [columnsWithUnmappedValues, expandedValueMappings]); + + // Render the sample data preview + const renderSamplePreview = (columnIndex: number) => { + const samples = getColumnSamples(columnIndex); + return ( +
+ + + + + +
+

Sample Data

+
+ +
+ {samples.map((sample, i) => ( +
+ {i + 1}: + {String(sample || '(empty)')} +
+ ))} +
+
+
+
+
+ ); + }; + + // Render the field selector for a column + const renderFieldSelector = (column: Column, isUnmapped: boolean = false) => { + // For ignored columns, show a badge + if (column.type === ColumnType.ignored) { + return Ignored; + } + + // Get the current value if this is a mapped column + const currentValue = "value" in column ? column.value as string : undefined; + + // Use all fields for mapped columns, and only available fields for unmapped columns + const fieldCategoriesForSelector = isUnmapped ? availableFieldCategories : allFieldCategories; + + return ( + + ); + }; + + // Render value mappings for select-type fields + const renderValueMappings = (column: Column) => { + if (!isExpandable(column) || !("matchedOptions" in column) || !("value" in column)) { + return null; + } + + const fieldOptions = getFieldOptions(column.value as string); + console.log(`Rendering value mappings for ${column.header}`, fieldOptions); + + // If no options available, show a message + if (!fieldOptions || fieldOptions.length === 0) { + return ( +
+

+ No options available for this field. Options mapping is not required. +

+
+ ); + } + + return ( +
+
+

Map Values from Column to Field Options

+

+ Match values found in your spreadsheet to options available in the system +

+
+
+ {column.matchedOptions.map((matched, i) => { + // Set default value if none exists + const currentValue = (matched.value as string) || ""; + // Ensure entry is a string + const entryValue = matched.entry || ""; + const isUnmapped = !currentValue; + + return ( +
+
+ {entryValue || '(empty)'} +
+
+ +
+ +
+ ); + })} +
+
+ ); + }; + return (
-
-
- {/* Global Selections Section */} - - - Global Selections - - -
+
+
+ {/* Left panel - Global selections & Required fields */} +
+
+

Global Settings

+

+ Apply these values to all imported items +

+ +
- - - -
- ( - row[column.index])} - /> - )} - templateColumn={(column) => } - /> -
- - {/* Required Fields Checklist */} - - - - Required Fields +
+ + + + {/* Required Fields Section - Updated to show source column */} +
+
+

Required Fields

- + -

- These fields are required for product import. You can either map them from your spreadsheet or set them globally above. - {unmatchedRequiredFields.length > 0 && " Missing fields will need to be filled in during validation."} -

+

Map columns to required fields or set them globally

- - - -
+
+ +
{requiredFields.length > 0 ? ( requiredFields.map(field => { const isMatched = matchedRequiredFields.includes(field.key); - const isCoveredByGlobal = isFieldCoveredByGlobalSelections(field.key); + const fieldMapping = requiredFieldMappings.get(field.key); + const isCoveredByGlobal = fieldMapping?.isGlobal; const isAccountedFor = isMatched || isCoveredByGlobal; return (
{isAccountedFor ? ( - + ) : ( - + )} - + {getFieldLabel(field.key)} - {isCoveredByGlobal && ( - (set globally) - )} + + {isCoveredByGlobal ? + "(global)" : + fieldMapping?.columnHeader ? + `(from "${fieldMapping.columnHeader}")` : + "" + } +
); }) ) : ( -
- No required fields found in the configuration. +
+ No required fields found.
)}
- - +
+ + {/* Stats summary for column mapping */} +
+
+
+ Columns total: + {columns.length} +
+
+ Mapped: + {matchedColumns.length} +
+
+ Ignored: + {ignoredColumns.length} +
+
+ Unmapped: + {unmatchedColumns.length} +
+
+
+
+ + {/* Right panel - Column mapping interface */} +
+
+
+

Map Spreadsheet Columns

+ 0 ? "destructive" : "outline"}> + {unmatchedRequiredFields.length} required missing + + {columnsWithUnmappedValues.length > 0 && ( + + {columnsWithUnmappedValues.length} with unmapped values + + )} +
+ +
+ +
+
+ +
+ + + + + Spreadsheet Column + Data + + Map To Field + Action + + + + {/* Always show columns with unmapped values */} + {columnsWithUnmappedValues.map((column) => { + const isExpanded = expandedValueMappings.includes(column.index); + + return ( + + + {column.header} + + {renderSamplePreview(column.index)} + + + + + + {renderFieldSelector(column)} + + + + + + + + {/* Value mappings row */} + {isExpanded && ( + + + {renderValueMappings(column)} + + + )} + + ); + })} + + {/* Always show unmapped columns */} + {unmatchedColumns.map((column) => ( + + {column.header} + + {renderSamplePreview(column.index)} + + + + + + {renderFieldSelector(column, true)} + + + + + + ))} + + {/* Show matched columns if showAllColumns is true */} + {showAllColumns && matchedColumns.map((column) => { + const isExpanded = expandedValueMappings.includes(column.index); + const canExpandValues = isExpandable(column); + + return ( + + + {column.header} + + {renderSamplePreview(column.index)} + + + + + + {renderFieldSelector(column)} + + + {canExpandValues && ( + + )} + + + + + {/* Value mappings row */} + {isExpanded && canExpandValues && ( + + + {renderValueMappings(column)} + + + )} + + ); + })} + + {/* Show ignored columns if showAllColumns is true */} + {showAllColumns && ignoredColumns.map((column) => ( + + {column.header} + + {renderSamplePreview(column.index)} + + + + + + Ignored + + + + + + ))} + + {/* Show a message if all columns are mapped/ignored */} + {unmatchedColumns.length === 0 && columnsWithUnmappedValues.length === 0 && !showAllColumns && ( + + + All columns have been mapped or ignored. + + + + )} + +
+
+
+
-
+ +
{onBack && ( )} -
- {unmatchedRequiredFields.length > 0 && ( - - {unmatchedRequiredFields.length} required field(s) missing - - )} - -
+