Column matching step enhancements
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+36
-12
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-16
@@ -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
Reference in New Issue
Block a user