Column matching step enhancements
This commit is contained in:
@@ -55,7 +55,7 @@ export const BASE_IMPORT_FIELDS = [
|
||||
label: "UPC",
|
||||
key: "upc",
|
||||
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" },
|
||||
width: 165,
|
||||
validations: [
|
||||
@@ -103,7 +103,7 @@ export const BASE_IMPORT_FIELDS = [
|
||||
label: "Name",
|
||||
key: "name",
|
||||
description: "Product name/title",
|
||||
alternateMatches: ["sku description","product name"],
|
||||
alternateMatches: ["sku description","product name","online name"],
|
||||
fieldType: { type: "input" },
|
||||
width: 400,
|
||||
validations: [
|
||||
@@ -115,7 +115,7 @@ export const BASE_IMPORT_FIELDS = [
|
||||
label: "MSRP",
|
||||
key: "msrp",
|
||||
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: {
|
||||
type: "input",
|
||||
price: true
|
||||
@@ -130,7 +130,7 @@ export const BASE_IMPORT_FIELDS = [
|
||||
label: "Min Qty",
|
||||
key: "qty_per_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" },
|
||||
width: 100,
|
||||
validations: [
|
||||
@@ -142,7 +142,7 @@ export const BASE_IMPORT_FIELDS = [
|
||||
label: "Cost Each",
|
||||
key: "cost_each",
|
||||
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: {
|
||||
type: "input",
|
||||
price: true
|
||||
@@ -318,6 +318,7 @@ export const BASE_IMPORT_FIELDS = [
|
||||
label: "Themes",
|
||||
key: "themes",
|
||||
description: "Product themes/styles",
|
||||
alternateMatches: ["themes"],
|
||||
fieldType: {
|
||||
type: "multi-select",
|
||||
options: [], // Will be populated from API
|
||||
|
||||
@@ -1475,20 +1475,44 @@ const MatchColumnsStepComponent = <T extends string>({
|
||||
// Get the pre-created onChange handler for this column
|
||||
const handleChange = columnChangeHandlers.get(column.index);
|
||||
|
||||
const isCostEach = "value" in column && column.value === "cost_each";
|
||||
|
||||
return (
|
||||
<FieldSelector
|
||||
column={column}
|
||||
isUnmapped={isUnmapped}
|
||||
fieldCategories={availableFieldCategories}
|
||||
allFields={allFields}
|
||||
onChange={(value: string) => {
|
||||
if (handleChange) handleChange(value);
|
||||
}}
|
||||
isFieldMappedToOtherColumn={isFieldMappedToOtherColumn}
|
||||
handleCommandListWheel={handleCommandListWheel}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<FieldSelector
|
||||
column={column}
|
||||
isUnmapped={isUnmapped}
|
||||
fieldCategories={availableFieldCategories}
|
||||
allFields={allFields}
|
||||
onChange={(value: string) => {
|
||||
if (handleChange) handleChange(value);
|
||||
}}
|
||||
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
|
||||
const renderValueMappings = useCallback((column: Column<T>) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ export type GlobalSelections = {
|
||||
company?: string
|
||||
line?: string
|
||||
subline?: string
|
||||
costIsTotalCost?: boolean
|
||||
}
|
||||
|
||||
export enum ColumnType {
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import lavenstein from "js-levenshtein"
|
||||
import type { Fields } from "../../../types"
|
||||
|
||||
type AutoMatchAccumulator<T> = {
|
||||
distance: number
|
||||
value: T
|
||||
}
|
||||
|
||||
export const findMatch = <T extends string>(
|
||||
header: string,
|
||||
fields: Fields<T>,
|
||||
autoMapDistance: number,
|
||||
_autoMapDistance: number,
|
||||
): T | undefined => {
|
||||
const headerLower = header.toLowerCase()
|
||||
const smallestValue = fields.reduce<AutoMatchAccumulator<T>>((acc, field) => {
|
||||
const distance = Math.min(
|
||||
...[
|
||||
lavenstein(field.key.toLowerCase(), headerLower),
|
||||
...(field.alternateMatches?.map((alternate) => lavenstein(alternate.toLowerCase(), headerLower)) || []),
|
||||
],
|
||||
)
|
||||
return distance < acc.distance || acc.distance === undefined
|
||||
? ({ value: field.key, distance } as AutoMatchAccumulator<T>)
|
||||
: acc
|
||||
}, {} as AutoMatchAccumulator<T>)
|
||||
return smallestValue.distance <= autoMapDistance ? smallestValue.value : undefined
|
||||
const headerLower = header.toLowerCase().trim()
|
||||
|
||||
for (const field of fields) {
|
||||
if (field.label.toLowerCase().trim() === headerLower) return field.key as T
|
||||
if ((field.key as string).toLowerCase() === headerLower) return field.key as T
|
||||
if (field.alternateMatches?.some((alt) => (alt as string).toLowerCase().trim() === headerLower)) return field.key as T
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import lavenstein from "js-levenshtein"
|
||||
import { findMatch } from "./findMatch"
|
||||
import type { Field, Fields } from "../../../types"
|
||||
import { setColumn } from "./setColumn"
|
||||
@@ -16,21 +15,9 @@ export const getMatchedColumns = <T extends string>(
|
||||
if (autoMatch) {
|
||||
const field = fields.find((field) => field.key === autoMatch) as Field<T>
|
||||
const duplicateIndex = arr.findIndex((column) => "value" in column && column.value === field.key)
|
||||
const duplicate = arr[duplicateIndex]
|
||||
if (duplicate && "value" in duplicate) {
|
||||
return lavenstein(duplicate.value, duplicate.header) < lavenstein(autoMatch, column.header)
|
||||
? [
|
||||
...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),
|
||||
]
|
||||
if (duplicateIndex >= 0) {
|
||||
// Field already matched by an earlier column — keep the first match
|
||||
return [...arr, column]
|
||||
} else {
|
||||
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) => {
|
||||
try {
|
||||
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
|
||||
const dataWithGlobalSelections = globalSelections
|
||||
|
||||
@@ -14,6 +14,7 @@ export const addErrorsAndRunHooks = async <T extends string>(
|
||||
rowHook?: RowHook<T>,
|
||||
tableHook?: TableHook<T>,
|
||||
changedRowIndexes?: number[],
|
||||
options?: { costIsTotalCost?: boolean },
|
||||
): Promise<DataWithMeta<T>[]> => {
|
||||
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
|
||||
processedData.forEach((row) => {
|
||||
const coo = (row as Record<string, unknown>).coo
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user