Column matching step enhancements

This commit is contained in:
2026-04-02 09:10:37 -04:00
parent b95bd4a4a0
commit 54f8cc2706
8 changed files with 82 additions and 55 deletions
@@ -55,7 +55,7 @@ export const BASE_IMPORT_FIELDS = [
label: "UPC", label: "UPC",
key: "upc", key: "upc",
description: "Universal Product Code/Barcode", description: "Universal Product Code/Barcode",
alternateMatches: ["barcode", "bar code", "jan", "ean", "upc code"], alternateMatches: ["barcode", "bar code", "jan", "ean", "upc code", "upc number"],
fieldType: { type: "input" }, fieldType: { type: "input" },
width: 165, width: 165,
validations: [ validations: [
@@ -103,7 +103,7 @@ export const BASE_IMPORT_FIELDS = [
label: "Name", label: "Name",
key: "name", key: "name",
description: "Product name/title", description: "Product name/title",
alternateMatches: ["sku description","product name"], alternateMatches: ["sku description","product name","online name"],
fieldType: { type: "input" }, fieldType: { type: "input" },
width: 400, width: 400,
validations: [ validations: [
@@ -115,7 +115,7 @@ export const BASE_IMPORT_FIELDS = [
label: "MSRP", label: "MSRP",
key: "msrp", key: "msrp",
description: "Manufacturer's Suggested Retail Price", description: "Manufacturer's Suggested Retail Price",
alternateMatches: ["retail", "retail price", "sugg retail", "price", "sugg. retail","default price"], alternateMatches: ["retail", "retail price", "sugg retail", "price", "sugg. retail","default price","sugg. retail (indv. pack)"],
fieldType: { fieldType: {
type: "input", type: "input",
price: true price: true
@@ -130,7 +130,7 @@ export const BASE_IMPORT_FIELDS = [
label: "Min Qty", label: "Min Qty",
key: "qty_per_unit", key: "qty_per_unit",
description: "Quantity of items per individual unit", description: "Quantity of items per individual unit",
alternateMatches: ["inner pack", "inner", "min qty", "unit qty", "min. order qty", "supplier qty/unit"], alternateMatches: ["inner pack", "inner", "min qty", "unit qty", "min. order qty", "supplier qty/unit", "wholesale pkg qty"],
fieldType: { type: "input" }, fieldType: { type: "input" },
width: 100, width: 100,
validations: [ validations: [
@@ -142,7 +142,7 @@ export const BASE_IMPORT_FIELDS = [
label: "Cost Each", label: "Cost Each",
key: "cost_each", key: "cost_each",
description: "Wholesale cost per unit", description: "Wholesale cost per unit",
alternateMatches: ["wholesale", "wholesale price", "supplier cost each", "cost each","whls"], alternateMatches: ["wholesale", "wholesale price", "supplier cost each", "cost each","whls","wholesale cost"],
fieldType: { fieldType: {
type: "input", type: "input",
price: true price: true
@@ -318,6 +318,7 @@ export const BASE_IMPORT_FIELDS = [
label: "Themes", label: "Themes",
key: "themes", key: "themes",
description: "Product themes/styles", description: "Product themes/styles",
alternateMatches: ["themes"],
fieldType: { fieldType: {
type: "multi-select", type: "multi-select",
options: [], // Will be populated from API options: [], // Will be populated from API
@@ -1475,20 +1475,44 @@ const MatchColumnsStepComponent = <T extends string>({
// Get the pre-created onChange handler for this column // Get the pre-created onChange handler for this column
const handleChange = columnChangeHandlers.get(column.index); const handleChange = columnChangeHandlers.get(column.index);
const isCostEach = "value" in column && column.value === "cost_each";
return ( return (
<FieldSelector <div className="flex items-center gap-3">
column={column} <FieldSelector
isUnmapped={isUnmapped} column={column}
fieldCategories={availableFieldCategories} isUnmapped={isUnmapped}
allFields={allFields} fieldCategories={availableFieldCategories}
onChange={(value: string) => { allFields={allFields}
if (handleChange) handleChange(value); onChange={(value: string) => {
}} if (handleChange) handleChange(value);
isFieldMappedToOtherColumn={isFieldMappedToOtherColumn} }}
handleCommandListWheel={handleCommandListWheel} isFieldMappedToOtherColumn={isFieldMappedToOtherColumn}
/> handleCommandListWheel={handleCommandListWheel}
/>
{isCostEach && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground whitespace-nowrap cursor-pointer">
<input
type="checkbox"
checked={!!globalSelections.costIsTotalCost}
onChange={(e) => setGlobalSelections(prev => ({ ...prev, costIsTotalCost: e.target.checked }))}
className="h-3.5 w-3.5 rounded border-gray-300"
/>
Divide by min qty
</label>
</TooltipTrigger>
<TooltipContent className="max-w-[250px]">
<p>Enable if the spreadsheet lists total cost per pack rather than cost per individual item. The value will be divided by Min Qty to calculate the actual cost each.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
); );
}, [availableFieldCategories, allFields, columnChangeHandlers, isFieldMappedToOtherColumn, handleCommandListWheel]); }, [availableFieldCategories, allFields, columnChangeHandlers, isFieldMappedToOtherColumn, handleCommandListWheel, globalSelections.costIsTotalCost, setGlobalSelections]);
// Replace the renderValueMappings function with a memoized version // Replace the renderValueMappings function with a memoized version
const renderValueMappings = useCallback((column: Column<T>) => { const renderValueMappings = useCallback((column: Column<T>) => {
@@ -13,6 +13,7 @@ export type GlobalSelections = {
company?: string company?: string
line?: string line?: string
subline?: string subline?: string
costIsTotalCost?: boolean
} }
export enum ColumnType { export enum ColumnType {
@@ -1,27 +1,17 @@
import lavenstein from "js-levenshtein"
import type { Fields } from "../../../types" import type { Fields } from "../../../types"
type AutoMatchAccumulator<T> = {
distance: number
value: T
}
export const findMatch = <T extends string>( export const findMatch = <T extends string>(
header: string, header: string,
fields: Fields<T>, fields: Fields<T>,
autoMapDistance: number, _autoMapDistance: number,
): T | undefined => { ): T | undefined => {
const headerLower = header.toLowerCase() const headerLower = header.toLowerCase().trim()
const smallestValue = fields.reduce<AutoMatchAccumulator<T>>((acc, field) => {
const distance = Math.min( for (const field of fields) {
...[ if (field.label.toLowerCase().trim() === headerLower) return field.key as T
lavenstein(field.key.toLowerCase(), headerLower), if ((field.key as string).toLowerCase() === headerLower) return field.key as T
...(field.alternateMatches?.map((alternate) => lavenstein(alternate.toLowerCase(), headerLower)) || []), if (field.alternateMatches?.some((alt) => (alt as string).toLowerCase().trim() === headerLower)) return field.key as T
], }
)
return distance < acc.distance || acc.distance === undefined return undefined
? ({ value: field.key, distance } as AutoMatchAccumulator<T>)
: acc
}, {} as AutoMatchAccumulator<T>)
return smallestValue.distance <= autoMapDistance ? smallestValue.value : undefined
} }
@@ -1,4 +1,3 @@
import lavenstein from "js-levenshtein"
import { findMatch } from "./findMatch" import { findMatch } from "./findMatch"
import type { Field, Fields } from "../../../types" import type { Field, Fields } from "../../../types"
import { setColumn } from "./setColumn" import { setColumn } from "./setColumn"
@@ -16,21 +15,9 @@ export const getMatchedColumns = <T extends string>(
if (autoMatch) { if (autoMatch) {
const field = fields.find((field) => field.key === autoMatch) as Field<T> const field = fields.find((field) => field.key === autoMatch) as Field<T>
const duplicateIndex = arr.findIndex((column) => "value" in column && column.value === field.key) const duplicateIndex = arr.findIndex((column) => "value" in column && column.value === field.key)
const duplicate = arr[duplicateIndex] if (duplicateIndex >= 0) {
if (duplicate && "value" in duplicate) { // Field already matched by an earlier column — keep the first match
return lavenstein(duplicate.value, duplicate.header) < lavenstein(autoMatch, column.header) return [...arr, column]
? [
...arr.slice(0, duplicateIndex),
setColumn(arr[duplicateIndex], field, data, autoMapSelectValues),
...arr.slice(duplicateIndex + 1),
setColumn(column),
]
: [
...arr.slice(0, duplicateIndex),
setColumn(arr[duplicateIndex]),
...arr.slice(duplicateIndex + 1),
setColumn(column, field, data, autoMapSelectValues),
]
} else { } else {
return [...arr, setColumn(column, field, data, autoMapSelectValues)] return [...arr, setColumn(column, field, data, autoMapSelectValues)]
} }
@@ -257,7 +257,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
onContinue={async (values, rawData, columns, globalSelections, useNewValidation) => { onContinue={async (values, rawData, columns, globalSelections, useNewValidation) => {
try { try {
const data = await matchColumnsStepHook(values, rawData, columns) const data = await matchColumnsStepHook(values, rawData, columns)
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook) const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook, undefined, { costIsTotalCost: globalSelections?.costIsTotalCost })
// Apply global selections to each row of data if they exist // Apply global selections to each row of data if they exist
const dataWithGlobalSelections = globalSelections const dataWithGlobalSelections = globalSelections
@@ -14,6 +14,7 @@ export const addErrorsAndRunHooks = async <T extends string>(
rowHook?: RowHook<T>, rowHook?: RowHook<T>,
tableHook?: TableHook<T>, tableHook?: TableHook<T>,
changedRowIndexes?: number[], changedRowIndexes?: number[],
options?: { costIsTotalCost?: boolean },
): Promise<DataWithMeta<T>[]> => { ): Promise<DataWithMeta<T>[]> => {
const errors: Errors = {} const errors: Errors = {}
@@ -57,6 +58,29 @@ export const addErrorsAndRunHooks = async <T extends string>(
} }
} }
// Extract numeric value from qty_per_unit (e.g. "pack of 25" → "25")
processedData.forEach((row) => {
const qty = (row as Record<string, unknown>).qty_per_unit
if (typeof qty === "string" && qty.trim()) {
const match = qty.match(/\d+/)
if (match) {
(row as Record<string, unknown>).qty_per_unit = match[0]
}
}
})
// Divide cost_each by qty_per_unit when cost represents total cost
if (options?.costIsTotalCost) {
processedData.forEach((row) => {
const r = row as Record<string, unknown>
const cost = parseFloat(String(r.cost_each ?? ""))
const qty = parseInt(String(r.qty_per_unit ?? ""), 10)
if (!isNaN(cost) && qty > 0) {
r.cost_each = (cost / qty).toFixed(2)
}
})
}
// Normalize country of origin (coo) to 2-letter ISO codes // Normalize country of origin (coo) to 2-letter ISO codes
processedData.forEach((row) => { processedData.forEach((row) => {
const coo = (row as Record<string, unknown>).coo const coo = (row as Record<string, unknown>).coo
File diff suppressed because one or more lines are too long