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 a6b5fb8..d8330f8 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,8 +2,7 @@ import { useCallback, useMemo, useState } from "react" import { useRsi } from "../../hooks/useRsi" import type { Meta } from "./types" import { addErrorsAndRunHooks } from "./utils/dataMutations" -import { generateColumns } from "./components/columns" -import type { Data } from "../../types" +import type { Data, Field, SelectOption } from "../../types" import { Table, TableBody, @@ -36,6 +35,13 @@ import { import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { useToast } from "@/hooks/use-toast" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" type Props = { initialData: (Data & Meta)[] @@ -43,49 +49,125 @@ type Props = { onBack?: () => void } -const EditableCell = ({ value, onChange, error }: { value: string, onChange: (value: string) => void, error?: { level: string } }) => { +type CellProps = { + value: any, + onChange: (value: any) => void, + error?: { level: string, message: string }, + field: Field +} + +const EditableCell = ({ value, onChange, error, field }: CellProps) => { const [isEditing, setIsEditing] = useState(false) - const [inputValue, setInputValue] = useState(value || "") + const [inputValue, setInputValue] = useState(value ?? "") - // Always show input for error cells - if (error?.level === "error" || isEditing) { - return ( - setInputValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - onChange(inputValue) - if (!error?.level) { - setIsEditing(false) - } - } - }} - onBlur={() => { - onChange(inputValue) - if (!error?.level) { - setIsEditing(false) - } - }} - className={`w-full bg-transparent ${ - error?.level === "error" - ? "border-destructive text-destructive" - : "" - }`} - autoFocus={!error?.level} - /> - ) + const getDisplayValue = (value: any, fieldType: Field["fieldType"]) => { + if (fieldType.type === "select") { + return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value + } + if (fieldType.type === "checkbox") { + if (typeof value === "boolean") return value ? "Yes" : "No" + return value + } + return value } + const isRequiredAndEmpty = field.validations?.some(v => v.rule === "required") && !value + + // Show editing UI for: + // 1. Error cells + // 2. When actively editing + // 3. Required select fields that are empty + // 4. Checkbox fields (always show the checkbox) + const shouldShowEditUI = error?.level === "error" || + isEditing || + (field.fieldType.type === "select" && isRequiredAndEmpty) || + field.fieldType.type === "checkbox" + + if (shouldShowEditUI) { + switch (field.fieldType.type) { + case "select": + return ( + + ) + case "checkbox": + return ( +
+ { + onChange(checked) + }} + /> +
+ ) + default: + return ( + setInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + onChange(inputValue) + if (!error?.level) { + setIsEditing(false) + } + } + }} + onBlur={() => { + onChange(inputValue) + if (!error?.level) { + setIsEditing(false) + } + }} + className={`w-full bg-transparent ${ + error?.level === "error" + ? "border-destructive text-destructive" + : "" + }`} + autoFocus={!error?.level} + /> + ) + } + } + + // Display mode return (
{ - setIsEditing(true) - setInputValue(value || "") + if (field.fieldType.type !== "checkbox") { + setIsEditing(true) + setInputValue(value ?? "") + } }} - className="cursor-text py-2" + className={`cursor-text py-2 ${ + error?.level === "error" ? "text-destructive" : "" + }`} > - {value || ""} + {getDisplayValue(value, field.fieldType)}
) } @@ -158,12 +240,13 @@ export const ValidationStep = ({ initialData, file, onBack }: ), enableSorting: false, enableHiding: false, + size: 50, }, - ...generateColumns(fields).map((col): ColumnDef & Meta> => ({ - accessorKey: col.key, - header: col.name, + ...fields.map((field: Field): ColumnDef & Meta> => ({ + accessorKey: field.key, + header: field.label, cell: ({ row, column }) => { - const value = row.getValue(column.id) as string + const value = row.getValue(column.id) const error = row.original.__errors?.[column.id] const rowIndex = row.index @@ -172,9 +255,16 @@ export const ValidationStep = ({ initialData, file, onBack }: value={value} onChange={(newValue) => updateRows(rowIndex, column.id, newValue)} error={error} + field={field} /> ) }, + // Use configured width or fallback to sensible defaults + size: (field as any).width || ( + field.fieldType.type === "checkbox" ? 80 : + field.fieldType.type === "select" ? 150 : + 200 + ), })), ] return baseColumns @@ -200,19 +290,57 @@ export const ValidationStep = ({ initialData, file, onBack }: } } + const normalizeValue = useCallback((value: any, field: Field) => { + if (field.fieldType.type === "checkbox") { + if (typeof value === "boolean") return value + if (typeof value === "string") { + const normalizedValue = value.toLowerCase().trim() + if (field.fieldType.booleanMatches) { + return !!field.fieldType.booleanMatches[normalizedValue] + } + return ["yes", "true", "1"].includes(normalizedValue) + } + return false + } + if (field.fieldType.type === "select") { + // Ensure the value matches one of the options + if (field.fieldType.options.some(opt => opt.value === value)) { + return value + } + // Try to match by label + const matchByLabel = field.fieldType.options.find( + opt => opt.label.toLowerCase() === String(value).toLowerCase() + ) + return matchByLabel ? matchByLabel.value : value + } + return value + }, []) + const submitData = async () => { const calculatedData = data.reduce( (acc, value) => { const { __index, __errors, ...values } = value + + // Normalize values based on field types + const normalizedValues = Object.entries(values).reduce((obj, [key, val]) => { + const field = fields.find((f: Field) => f.key === key) + if (field) { + obj[key as keyof Data] = normalizeValue(val, field) + } else { + obj[key as keyof Data] = val as string | boolean | undefined + } + return obj + }, {} as Data) + if (__errors) { for (const key in __errors) { if (__errors[key].level === "error") { - acc.invalidData.push(values as unknown as Data) + acc.invalidData.push(normalizedValues) return acc } } } - acc.validData.push(values as unknown as Data) + acc.validData.push(normalizedValues) return acc }, { validData: [] as Data[], invalidData: [] as Data[], all: data }, @@ -308,49 +436,64 @@ export const ValidationStep = ({ initialData, file, onBack }: -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ))} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - +
+
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + ))} - )) - ) : ( - - - {filterByErrors - ? translations.validationStep.noRowsMessageWhenFiltered - : translations.validationStep.noRowsMessage} - - - )} - -
+ ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + {filterByErrors + ? translations.validationStep.noRowsMessageWhenFiltered + : translations.validationStep.noRowsMessage} + + + )} + + +
diff --git a/inventory/src/pages/import/Import.tsx b/inventory/src/pages/import/Import.tsx index 487040e..92fa43f 100644 --- a/inventory/src/pages/import/Import.tsx +++ b/inventory/src/pages/import/Import.tsx @@ -10,11 +10,13 @@ const IMPORT_FIELDS = [ { label: "Name", key: "name", - alternateMatches: ["product", "product name", "item name"], + alternateMatches: ["product", "product name", "item name", "title"], fieldType: { type: "input", }, example: "Widget X", + description: "The name or title of the product", + width: 300, validations: [ { rule: "required", @@ -26,35 +28,154 @@ const IMPORT_FIELDS = [ { label: "SKU", key: "sku", - alternateMatches: ["item number", "product code"], + alternateMatches: ["item number", "product code", "product id", "item id"], fieldType: { type: "input", }, example: "WX-123", + description: "Unique product identifier", + width: 120, validations: [ { rule: "required", errorMessage: "SKU is required", level: "error", }, + { + rule: "unique", + errorMessage: "SKU must be unique", + level: "error", + }, ], }, + { + label: "Category", + key: "category", + alternateMatches: ["product category", "type", "product type"], + fieldType: { + type: "select", + options: [ + { label: "Electronics", value: "electronics" }, + { label: "Clothing", value: "clothing" }, + { label: "Food & Beverage", value: "food_beverage" }, + { label: "Office Supplies", value: "office_supplies" }, + { label: "Other", value: "other" }, + ], + }, + width: 150, + validations: [ + { + rule: "required", + errorMessage: "Category is required", + level: "error", + }, + ], + example: "Electronics", + description: "Product category", + }, { label: "Quantity", key: "quantity", - alternateMatches: ["qty", "stock", "amount"], + alternateMatches: ["qty", "stock", "amount", "inventory", "stock level"], fieldType: { type: "input", }, example: "100", + description: "Current stock quantity", + width: 100, validations: [ { rule: "required", errorMessage: "Quantity is required", level: "error", }, + { + rule: "regex", + value: "^[0-9]+$", + errorMessage: "Quantity must be a positive number", + level: "error", + }, ], }, + { + label: "Price", + key: "price", + alternateMatches: ["unit price", "cost", "selling price", "retail price"], + fieldType: { + type: "input", + }, + example: "29.99", + description: "Selling price per unit", + width: 100, + validations: [ + { + rule: "required", + errorMessage: "Price is required", + level: "error", + }, + { + rule: "regex", + value: "^\\d*\\.?\\d+$", + errorMessage: "Price must be a valid number", + level: "error", + }, + ], + }, + { + label: "In Stock", + key: "inStock", + alternateMatches: ["available", "active", "status"], + fieldType: { + type: "checkbox", + booleanMatches: { + yes: true, + no: false, + "in stock": true, + "out of stock": false, + available: true, + unavailable: false, + }, + }, + width: 80, + example: "Yes", + description: "Whether the item is currently in stock", + }, + { + label: "Minimum Stock", + key: "minStock", + alternateMatches: ["min qty", "reorder point", "low stock level"], + fieldType: { + type: "input", + }, + example: "10", + description: "Minimum stock level before reorder", + width: 100, + validations: [ + { + rule: "regex", + value: "^[0-9]+$", + errorMessage: "Minimum stock must be a positive number", + level: "error", + }, + ], + }, + { + label: "Location", + key: "location", + alternateMatches: ["storage location", "warehouse", "shelf", "bin"], + fieldType: { + type: "select", + options: [ + { label: "Warehouse A", value: "warehouse_a" }, + { label: "Warehouse B", value: "warehouse_b" }, + { label: "Store Front", value: "store_front" }, + { label: "External Storage", value: "external" }, + ], + }, + width: 150, + example: "Warehouse A", + description: "Storage location of the product", + }, ]; export function Import() {