Product import fixes/enhancements
This commit is contained in:
@@ -95,7 +95,6 @@ export const BASE_IMPORT_FIELDS = [
|
||||
fieldType: { type: "input" },
|
||||
width: 110,
|
||||
validations: [
|
||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
|
||||
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||
],
|
||||
@@ -265,7 +264,7 @@ export const BASE_IMPORT_FIELDS = [
|
||||
label: "HTS Code",
|
||||
key: "hts_code",
|
||||
description: "Harmonized Tariff Schedule code",
|
||||
alternateMatches: ["taric","hts"],
|
||||
alternateMatches: ["taric","hts","hs code","hs code (commodity code)"],
|
||||
fieldType: { type: "input" },
|
||||
width: 130,
|
||||
validations: [
|
||||
@@ -286,7 +285,7 @@ export const BASE_IMPORT_FIELDS = [
|
||||
label: "Description",
|
||||
key: "description",
|
||||
description: "Detailed product description",
|
||||
alternateMatches: ["details/description"],
|
||||
alternateMatches: ["details/description","description of item"],
|
||||
fieldType: {
|
||||
type: "input",
|
||||
multiline: true
|
||||
|
||||
@@ -47,6 +47,7 @@ export const ImageUploadStep = ({
|
||||
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"));
|
||||
const [targetEnvironment, setTargetEnvironment] = useState<SubmitOptions["targetEnvironment"]>("prod");
|
||||
const [useTestDataSource, setUseTestDataSource] = useState<boolean>(false);
|
||||
const [skipApiSubmission, setSkipApiSubmission] = useState<boolean>(false);
|
||||
|
||||
// Use our hook for product images initialization
|
||||
const { productImages, setProductImages, getFullImageUrl } = useProductImagesInit(data);
|
||||
@@ -177,6 +178,7 @@ export const ImageUploadStep = ({
|
||||
const submitOptions: SubmitOptions = {
|
||||
targetEnvironment,
|
||||
useTestDataSource,
|
||||
skipApiSubmission,
|
||||
};
|
||||
|
||||
await onSubmit(updatedData, file, submitOptions);
|
||||
@@ -186,7 +188,7 @@ export const ImageUploadStep = ({
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource]);
|
||||
}, [data, file, onSubmit, productImages, targetEnvironment, useTestDataSource, skipApiSubmission]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
|
||||
@@ -297,27 +299,43 @@ export const ImageUploadStep = ({
|
||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-6">
|
||||
{hasDebugPermission && (
|
||||
<div className="flex gap-4 text-sm">
|
||||
{!skipApiSubmission && (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<Switch
|
||||
id="product-import-api-environment"
|
||||
checked={targetEnvironment === "dev"}
|
||||
onCheckedChange={(checked) => setTargetEnvironment(checked ? "dev" : "prod")}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="product-import-api-environment" className="text-sm font-medium">
|
||||
Use test API
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Switch
|
||||
id="product-import-api-test-data"
|
||||
checked={useTestDataSource}
|
||||
onCheckedChange={(checked) => setUseTestDataSource(checked)}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="product-import-api-test-data" className="text-sm font-medium">
|
||||
Use test database
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Switch
|
||||
id="product-import-api-environment"
|
||||
checked={targetEnvironment === "dev"}
|
||||
onCheckedChange={(checked) => setTargetEnvironment(checked ? "dev" : "prod")}
|
||||
id="product-import-skip-api"
|
||||
checked={skipApiSubmission}
|
||||
onCheckedChange={(checked) => setSkipApiSubmission(checked)}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="product-import-api-environment" className="text-sm font-medium">
|
||||
Use test API
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Switch
|
||||
id="product-import-api-test-data"
|
||||
checked={useTestDataSource}
|
||||
onCheckedChange={(checked) => setUseTestDataSource(checked)}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="product-import-api-test-data" className="text-sm font-medium">
|
||||
Use test database
|
||||
<Label htmlFor="product-import-skip-api" className="text-sm font-medium text-amber-600">
|
||||
Skip API (Debug)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,17 +147,17 @@ const MemoizedColumnSamplePreview = React.memo(function ColumnSamplePreview({ sa
|
||||
<FileSpreadsheetIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="right" align="start" className="w-[250px] p-0">
|
||||
<ScrollArea className="h-[200px] overflow-y-auto">
|
||||
<PopoverContent side="right" align="start" className="w-[280px] p-0" onWheel={(e) => e.stopPropagation()}>
|
||||
<div className="max-h-[300px] overflow-y-auto overscroll-contain" style={{ overscrollBehavior: 'contain' }}>
|
||||
<div className="p-3 space-y-2">
|
||||
{samples.map((sample, i) => (
|
||||
<div key={i} className="text-sm">
|
||||
<div key={i} className="text-sm break-words">
|
||||
<span className="font-medium">{String(sample || '(empty)')}</span>
|
||||
{i < samples.length - 1 && <Separator className="w-full my-2" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
+19
-2
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react"
|
||||
import { useCallback, useState, useMemo } from "react"
|
||||
import { SelectHeaderTable } from "./components/SelectHeaderTable"
|
||||
import { useRsi } from "../../hooks/useRsi"
|
||||
import type { RawData } from "../../types"
|
||||
@@ -11,12 +11,29 @@ type SelectHeaderProps = {
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a row is completely empty (all values are undefined, null, or whitespace-only strings)
|
||||
*/
|
||||
const isRowCompletelyEmpty = (row: RawData): boolean => {
|
||||
return Object.values(row).every(val =>
|
||||
val === undefined ||
|
||||
val === null ||
|
||||
(typeof val === 'string' && val.trim() === '')
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps) => {
|
||||
const { translations } = useRsi()
|
||||
const { toast } = useToast()
|
||||
const [selectedRows, setSelectedRows] = useState<ReadonlySet<number>>(new Set([0]))
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [localData, setLocalData] = useState<RawData[]>(data)
|
||||
|
||||
// Automatically filter out completely empty rows on initial load
|
||||
const initialFilteredData = useMemo(() => {
|
||||
return data.filter(row => !isRowCompletelyEmpty(row));
|
||||
}, [data]);
|
||||
|
||||
const [localData, setLocalData] = useState<RawData[]>(initialFilteredData)
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
const [selectedRowIndex] = selectedRows
|
||||
|
||||
+105
-13
@@ -1,21 +1,63 @@
|
||||
import { useCallback, useState } from "react"
|
||||
import { useCallback, useState, useMemo } from "react"
|
||||
import type XLSX from "xlsx"
|
||||
import * as XLSXLib from "xlsx"
|
||||
import { useRsi } from "../../hooks/useRsi"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
|
||||
|
||||
type SelectSheetProps = {
|
||||
sheetNames: string[]
|
||||
workbook: XLSX.WorkBook
|
||||
onContinue: (sheetName: string) => Promise<void>
|
||||
onBack?: () => void
|
||||
}
|
||||
|
||||
export const SelectSheetStep = ({ sheetNames, onContinue, onBack }: SelectSheetProps) => {
|
||||
const MAX_PREVIEW_ROWS = 10
|
||||
const MAX_PREVIEW_COLS = 8
|
||||
const MAX_CELL_LENGTH = 30
|
||||
|
||||
export const SelectSheetStep = ({ sheetNames, workbook, onContinue, onBack }: SelectSheetProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { translations } = useRsi()
|
||||
const [value, setValue] = useState(sheetNames[0])
|
||||
|
||||
// Generate preview data for each sheet
|
||||
const sheetPreviews = useMemo(() => {
|
||||
const previews: Record<string, (string | number | null)[][]> = {}
|
||||
|
||||
for (const sheetName of sheetNames) {
|
||||
const sheet = workbook.Sheets[sheetName]
|
||||
if (!sheet) continue
|
||||
|
||||
// Convert sheet to array of arrays
|
||||
const data = XLSXLib.utils.sheet_to_json<(string | number | null)[]>(sheet, {
|
||||
header: 1,
|
||||
defval: null,
|
||||
})
|
||||
|
||||
// Take first N rows and limit columns
|
||||
const previewRows = data.slice(0, MAX_PREVIEW_ROWS).map(row =>
|
||||
(row as (string | number | null)[]).slice(0, MAX_PREVIEW_COLS)
|
||||
)
|
||||
|
||||
previews[sheetName] = previewRows
|
||||
}
|
||||
|
||||
return previews
|
||||
}, [sheetNames, workbook])
|
||||
|
||||
const truncateCell = (value: string | number | null): string => {
|
||||
if (value === null || value === undefined) return ""
|
||||
const str = String(value)
|
||||
if (str.length > MAX_CELL_LENGTH) {
|
||||
return str.slice(0, MAX_CELL_LENGTH - 1) + "…"
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
const handleOnContinue = useCallback(
|
||||
async (data: typeof value) => {
|
||||
setIsLoading(true)
|
||||
@@ -37,19 +79,69 @@ export const SelectSheetStep = ({ sheetNames, onContinue, onBack }: SelectSheetP
|
||||
<RadioGroup
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
className="space-y-4"
|
||||
className="space-y-6"
|
||||
>
|
||||
{sheetNames.map((sheetName) => (
|
||||
<div key={sheetName} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={sheetName} id={sheetName} />
|
||||
<Label
|
||||
htmlFor={sheetName}
|
||||
className="text-base"
|
||||
{sheetNames.map((sheetName) => {
|
||||
const preview = sheetPreviews[sheetName] || []
|
||||
const isSelected = value === sheetName
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sheetName}
|
||||
className={`rounded-lg border p-4 transition-colors cursor-pointer ${
|
||||
isSelected ? "border-primary bg-primary/5" : "border-border hover:border-muted-foreground/50"
|
||||
}`}
|
||||
onClick={() => setValue(sheetName)}
|
||||
>
|
||||
{sheetName}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<RadioGroupItem value={sheetName} id={sheetName} />
|
||||
<Label
|
||||
htmlFor={sheetName}
|
||||
className="text-base font-medium cursor-pointer"
|
||||
>
|
||||
{sheetName}
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({preview.length === 10 ? 'first ' : ''}{preview.length} rows shown)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{preview.length > 0 && (
|
||||
<ScrollArea className="w-full">
|
||||
<div className="rounded border bg-muted/30">
|
||||
<table className="text-xs w-full">
|
||||
<tbody>
|
||||
{preview.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={rowIndex}
|
||||
className={rowIndex === 0 ? "bg-muted/50 font-medium" : ""}
|
||||
>
|
||||
{row.map((cell, colIndex) => (
|
||||
<td
|
||||
key={colIndex}
|
||||
className="px-2 py-1 border-r border-b last:border-r-0 whitespace-nowrap max-w-[150px] overflow-hidden text-ellipsis"
|
||||
title={cell !== null ? String(cell) : ""}
|
||||
>
|
||||
{truncateCell(cell)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{preview.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No data in this sheet
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from "react"
|
||||
import { useCallback, useState, useEffect } from "react"
|
||||
import type XLSX from "xlsx"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { UploadStep } from "./UploadStep/UploadStep"
|
||||
import { SelectHeaderStep } from "./SelectHeaderStep/SelectHeaderStep"
|
||||
import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
|
||||
@@ -14,6 +15,7 @@ import type { RawData, Data } from "../types"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
|
||||
import { useValidationStore } from "./ValidationStep/store/validationStore"
|
||||
|
||||
export enum StepType {
|
||||
upload = "upload",
|
||||
@@ -82,6 +84,31 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
||||
onSubmit } = useRsi()
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const resetValidationStore = useValidationStore((state) => state.reset)
|
||||
|
||||
// Fresh taxonomy data per session:
|
||||
// Invalidate caches on mount and clear on unmount to ensure fresh data each import session
|
||||
useEffect(() => {
|
||||
// On mount - invalidate import-related query caches to fetch fresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['field-options'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['product-lines'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['product-lines-mapped'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['sublines'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['sublines-mapped'] });
|
||||
|
||||
return () => {
|
||||
// On unmount - remove queries from cache entirely and reset Zustand store
|
||||
queryClient.removeQueries({ queryKey: ['field-options'] });
|
||||
queryClient.removeQueries({ queryKey: ['product-lines'] });
|
||||
queryClient.removeQueries({ queryKey: ['product-lines-mapped'] });
|
||||
queryClient.removeQueries({ queryKey: ['sublines'] });
|
||||
queryClient.removeQueries({ queryKey: ['sublines-mapped'] });
|
||||
|
||||
// Reset the validation store to clear productLinesCache and sublinesCache
|
||||
resetValidationStore();
|
||||
};
|
||||
}, [queryClient, resetValidationStore]);
|
||||
const errorToast = useCallback(
|
||||
(description: string) => {
|
||||
toast({
|
||||
@@ -143,6 +170,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
||||
return (
|
||||
<SelectSheetStep
|
||||
sheetNames={state.workbook.SheetNames}
|
||||
workbook={state.workbook}
|
||||
onContinue={async (sheetName) => {
|
||||
if (maxRecords && exceedsMaxRecords(state.workbook.Sheets[sheetName], maxRecords)) {
|
||||
errorToast(translations.uploadStep.maxRecordsExceeded(maxRecords.toString()))
|
||||
|
||||
+31
-22
@@ -146,28 +146,37 @@ export const ValidationContainer = ({
|
||||
};
|
||||
|
||||
// Convert rows to sanity check format
|
||||
return rows.map((row) => ({
|
||||
name: row.name as string | undefined,
|
||||
supplier: row.supplier as string | undefined,
|
||||
supplier_name: getFieldLabel('supplier', row.supplier),
|
||||
company: row.company as string | undefined,
|
||||
company_name: getFieldLabel('company', row.company),
|
||||
supplier_no: row.supplier_no as string | undefined,
|
||||
msrp: row.msrp as string | number | undefined,
|
||||
cost_each: row.cost_each as string | number | undefined,
|
||||
qty_per_unit: row.qty_per_unit as string | number | undefined,
|
||||
case_qty: row.case_qty as string | number | undefined,
|
||||
tax_cat: row.tax_cat as string | number | undefined,
|
||||
tax_cat_name: getFieldLabel('tax_cat', row.tax_cat),
|
||||
size_cat: row.size_cat as string | number | undefined,
|
||||
size_cat_name: getFieldLabel('size_cat', row.size_cat),
|
||||
themes: row.themes as string | undefined,
|
||||
categories: row.categories as string | undefined,
|
||||
weight: row.weight as string | number | undefined,
|
||||
length: row.length as string | number | undefined,
|
||||
width: row.width as string | number | undefined,
|
||||
height: row.height as string | number | undefined,
|
||||
}));
|
||||
return rows.map((row) => {
|
||||
const product: ProductForSanityCheck = {
|
||||
name: row.name as string | undefined,
|
||||
supplier: row.supplier as string | undefined,
|
||||
supplier_name: getFieldLabel('supplier', row.supplier),
|
||||
company: row.company as string | undefined,
|
||||
company_name: getFieldLabel('company', row.company),
|
||||
supplier_no: row.supplier_no as string | undefined,
|
||||
msrp: row.msrp as string | number | undefined,
|
||||
cost_each: row.cost_each as string | number | undefined,
|
||||
qty_per_unit: row.qty_per_unit as string | number | undefined,
|
||||
case_qty: row.case_qty as string | number | undefined,
|
||||
tax_cat: row.tax_cat as string | number | undefined,
|
||||
tax_cat_name: getFieldLabel('tax_cat', row.tax_cat),
|
||||
size_cat: row.size_cat as string | number | undefined,
|
||||
size_cat_name: getFieldLabel('size_cat', row.size_cat),
|
||||
themes: row.themes as string | undefined,
|
||||
categories: row.categories as string | undefined,
|
||||
weight: row.weight as string | number | undefined,
|
||||
length: row.length as string | number | undefined,
|
||||
width: row.width as string | number | undefined,
|
||||
height: row.height as string | number | undefined,
|
||||
};
|
||||
|
||||
// Add AI supplemental context if present (from MatchColumnsStep "AI context only" columns)
|
||||
if (row.__aiSupplemental && typeof row.__aiSupplemental === 'object') {
|
||||
product.additional_context = row.__aiSupplemental;
|
||||
}
|
||||
|
||||
return product;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle viewing cached sanity check results
|
||||
|
||||
+374
-54
@@ -16,7 +16,9 @@ import { useMemo, useRef, useCallback, memo, useState } from 'react';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ArrowDown, Wand2, Loader2, Calculator } from 'lucide-react';
|
||||
import { ArrowDown, Wand2, Loader2, Calculator, Scale } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -1493,7 +1495,7 @@ HeaderCheckbox.displayName = 'HeaderCheckbox';
|
||||
*
|
||||
* Renders a column header for MSRP or Cost Each with a hover button
|
||||
* that fills empty cells based on the other price field.
|
||||
* - MSRP: Fill with Cost Each × 2
|
||||
* - MSRP: Opens popover with multiplier options (2x-2.5x) and round-to-.X9 checkbox
|
||||
* - Cost Each: Fill with MSRP ÷ 2
|
||||
*
|
||||
* PERFORMANCE: Uses local hover state and getState() for bulk updates.
|
||||
@@ -1505,14 +1507,32 @@ interface PriceColumnHeaderProps {
|
||||
isRequired: boolean;
|
||||
}
|
||||
|
||||
const MSRP_MULTIPLIERS = [2.0, 2.1, 2.2, 2.3, 2.4, 2.5];
|
||||
|
||||
/**
|
||||
* Round up to nearest .X9 (e.g., 12.32 → 12.39, 15.71 → 15.79)
|
||||
*/
|
||||
const roundToNine = (value: number): number => {
|
||||
const wholePart = Math.floor(value);
|
||||
const decimal = value - wholePart;
|
||||
// Get the tenths digit and add .09 to make it end in 9
|
||||
const tenths = Math.floor(decimal * 10);
|
||||
return wholePart + (tenths / 10) + 0.09;
|
||||
};
|
||||
|
||||
const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHeaderProps) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [hasFillableCells, setHasFillableCells] = useState(false);
|
||||
const [selectedMultiplier, setSelectedMultiplier] = useState(2.0);
|
||||
const [shouldRoundToNine, setShouldRoundToNine] = useState(false);
|
||||
|
||||
// Determine the source field and calculation
|
||||
const sourceField = fieldKey === 'msrp' ? 'cost_each' : 'msrp';
|
||||
const tooltipText = fieldKey === 'msrp'
|
||||
? 'Fill empty cells with Cost Each × 2'
|
||||
const isMsrp = fieldKey === 'msrp';
|
||||
|
||||
// Determine the source field
|
||||
const sourceField = isMsrp ? 'cost_each' : 'msrp';
|
||||
const tooltipText = isMsrp
|
||||
? 'Fill empty MSRP from Cost'
|
||||
: 'Fill empty cells with MSRP ÷ 2';
|
||||
|
||||
// Check if there are any cells that can be filled (called on hover)
|
||||
@@ -1537,7 +1557,7 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
||||
setHasFillableCells(checkFillableCells());
|
||||
}, [checkFillableCells]);
|
||||
|
||||
const handleCalculate = useCallback(() => {
|
||||
const handleCalculateMsrp = useCallback((multiplier: number, roundNine: boolean) => {
|
||||
const updatedIndices: number[] = [];
|
||||
|
||||
// Use setState() for efficient batch update with Immer
|
||||
@@ -1553,26 +1573,65 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
||||
if (isEmpty && hasSource) {
|
||||
const sourceNum = parseFloat(String(sourceValue));
|
||||
if (!isNaN(sourceNum) && sourceNum > 0) {
|
||||
// Calculate the new value
|
||||
let newValue: string;
|
||||
if (fieldKey === 'msrp') {
|
||||
let msrp = sourceNum * 2;
|
||||
// Round down .00 to .99 for better pricing (e.g., 13.00 → 12.99)
|
||||
if (msrp === Math.floor(msrp)) {
|
||||
let msrp = sourceNum * multiplier;
|
||||
|
||||
if (multiplier === 2.0) {
|
||||
// For 2x: auto-adjust by ±1 cent to get to .99 if close
|
||||
const cents = Math.round((msrp % 1) * 100);
|
||||
if (cents === 0) {
|
||||
// .00 → subtract 1 cent to get .99
|
||||
msrp -= 0.01;
|
||||
} else if (cents === 98) {
|
||||
// .98 → add 1 cent to get .99
|
||||
msrp += 0.01;
|
||||
}
|
||||
newValue = msrp.toFixed(2);
|
||||
} else {
|
||||
newValue = (sourceNum / 2).toFixed(2);
|
||||
// Otherwise leave as-is
|
||||
} else if (roundNine) {
|
||||
// For >2x with checkbox: round to nearest .X9
|
||||
msrp = roundToNine(msrp);
|
||||
}
|
||||
draft.rows[index][fieldKey] = newValue;
|
||||
|
||||
draft.rows[index][fieldKey] = msrp.toFixed(2);
|
||||
updatedIndices.push(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Clear validation errors for all updated cells
|
||||
if (updatedIndices.length > 0) {
|
||||
const { clearFieldError } = useValidationStore.getState();
|
||||
updatedIndices.forEach((rowIndex) => {
|
||||
clearFieldError(rowIndex, fieldKey);
|
||||
});
|
||||
|
||||
toast.success(`Updated ${updatedIndices.length} ${label} value${updatedIndices.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
setIsPopoverOpen(false);
|
||||
}, [fieldKey, sourceField, label]);
|
||||
|
||||
const handleCalculateCostEach = useCallback(() => {
|
||||
const updatedIndices: number[] = [];
|
||||
|
||||
useValidationStore.setState((draft) => {
|
||||
draft.rows.forEach((row, index) => {
|
||||
const currentValue = row[fieldKey];
|
||||
const sourceValue = row[sourceField];
|
||||
|
||||
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
|
||||
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
|
||||
|
||||
if (isEmpty && hasSource) {
|
||||
const sourceNum = parseFloat(String(sourceValue));
|
||||
if (!isNaN(sourceNum) && sourceNum > 0) {
|
||||
draft.rows[index][fieldKey] = (sourceNum / 2).toFixed(2);
|
||||
updatedIndices.push(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Clear validation errors for all updated cells (removes "required" error styling)
|
||||
if (updatedIndices.length > 0) {
|
||||
const { clearFieldError } = useValidationStore.getState();
|
||||
updatedIndices.forEach((rowIndex) => {
|
||||
@@ -1587,38 +1646,118 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
||||
<div
|
||||
className="flex items-center gap-1 truncate w-full group relative"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onMouseLeave={() => {
|
||||
if (!isPopoverOpen) setIsHovered(false);
|
||||
}}
|
||||
>
|
||||
<span className="">{label}</span>
|
||||
{isRequired && (
|
||||
<span className="text-destructive flex-shrink-0">*</span>
|
||||
)}
|
||||
{isHovered && hasFillableCells && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCalculate();
|
||||
}}
|
||||
className={cn(
|
||||
'absolute right-1 top-1/2 -translate-y-1/2',
|
||||
'flex items-center gap-0.5',
|
||||
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'transition-opacity'
|
||||
{(isHovered || isPopoverOpen) && hasFillableCells && (
|
||||
isMsrp ? (
|
||||
// MSRP: Show popover with multiplier options
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
'absolute right-1 top-1/2 -translate-y-1/2',
|
||||
'flex items-center gap-0.5',
|
||||
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'transition-opacity'
|
||||
)}
|
||||
>
|
||||
<Calculator className="h-3 w-3" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<PopoverContent className="w-52 p-3" align="end">
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Calculate MSRP from Cost
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Multiplier</p>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{MSRP_MULTIPLIERS.map((m) => (
|
||||
<Button
|
||||
key={m}
|
||||
variant={selectedMultiplier === m ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setSelectedMultiplier(m)}
|
||||
>
|
||||
{m}x
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{selectedMultiplier > 2.0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="round-to-nine"
|
||||
checked={shouldRoundToNine}
|
||||
onCheckedChange={(checked) => setShouldRoundToNine(checked === true)}
|
||||
/>
|
||||
<label htmlFor="round-to-nine" className="text-xs cursor-pointer">
|
||||
Round to .X9 (e.g., 12.39)
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Calculator className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{selectedMultiplier === 2.0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auto-adjusts ±1¢ for .99 pricing
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full h-7 text-xs"
|
||||
onClick={() => handleCalculateMsrp(selectedMultiplier, shouldRoundToNine)}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
// Cost Each: Simple click behavior
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCalculateCostEach();
|
||||
}}
|
||||
className={cn(
|
||||
'absolute right-1 top-1/2 -translate-y-1/2',
|
||||
'flex items-center gap-0.5',
|
||||
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'transition-opacity'
|
||||
)}
|
||||
>
|
||||
<Calculator className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1626,6 +1765,169 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
||||
|
||||
PriceColumnHeader.displayName = 'PriceColumnHeader';
|
||||
|
||||
/**
|
||||
* UnitConversionColumnHeader Component
|
||||
*
|
||||
* Renders a column header for weight/dimension fields with a hover button
|
||||
* that opens a popover with unit conversion options.
|
||||
* - Weight: grams → oz, lbs → oz, kg → oz
|
||||
* - Dimensions: cm → in, mm → in
|
||||
*
|
||||
* PERFORMANCE: Uses local hover state and getState() for bulk updates.
|
||||
*/
|
||||
interface UnitConversionColumnHeaderProps {
|
||||
fieldKey: 'weight' | 'length' | 'width' | 'height';
|
||||
label: string;
|
||||
isRequired: boolean;
|
||||
}
|
||||
|
||||
type ConversionOption = {
|
||||
label: string;
|
||||
factor: number;
|
||||
roundTo?: number;
|
||||
};
|
||||
|
||||
const WEIGHT_CONVERSIONS: ConversionOption[] = [
|
||||
{ label: 'Grams → Ounces', factor: 0.035274, roundTo: 2 },
|
||||
{ label: 'Pounds → Ounces', factor: 16, roundTo: 2 },
|
||||
{ label: 'Kilograms → Ounces', factor: 35.274, roundTo: 2 },
|
||||
];
|
||||
|
||||
const DIMENSION_CONVERSIONS: ConversionOption[] = [
|
||||
{ label: 'Centimeters → Inches', factor: 0.393701, roundTo: 2 },
|
||||
{ label: 'Millimeters → Inches', factor: 0.0393701, roundTo: 2 },
|
||||
];
|
||||
|
||||
const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitConversionColumnHeaderProps) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [hasConvertibleCells, setHasConvertibleCells] = useState(false);
|
||||
|
||||
const isWeightField = fieldKey === 'weight';
|
||||
const conversions = isWeightField ? WEIGHT_CONVERSIONS : DIMENSION_CONVERSIONS;
|
||||
|
||||
// Check if there are any cells with numeric values that can be converted
|
||||
const checkConvertibleCells = useCallback(() => {
|
||||
const { rows } = useValidationStore.getState();
|
||||
return rows.some((row) => {
|
||||
const value = row[fieldKey];
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
const num = parseFloat(String(value));
|
||||
return !isNaN(num) && num > 0;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [fieldKey]);
|
||||
|
||||
// Update convertible check on hover
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setIsHovered(true);
|
||||
setHasConvertibleCells(checkConvertibleCells());
|
||||
}, [checkConvertibleCells]);
|
||||
|
||||
const handleConversion = useCallback((conversion: ConversionOption) => {
|
||||
const updatedIndices: number[] = [];
|
||||
|
||||
// Use setState() for efficient batch update with Immer
|
||||
useValidationStore.setState((draft) => {
|
||||
draft.rows.forEach((row, index) => {
|
||||
const value = row[fieldKey];
|
||||
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
const num = parseFloat(String(value));
|
||||
if (!isNaN(num) && num > 0) {
|
||||
const converted = num * conversion.factor;
|
||||
const rounded = conversion.roundTo !== undefined
|
||||
? converted.toFixed(conversion.roundTo)
|
||||
: converted.toString();
|
||||
draft.rows[index][fieldKey] = rounded;
|
||||
updatedIndices.push(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Clear validation errors for all updated cells
|
||||
if (updatedIndices.length > 0) {
|
||||
const { clearFieldError } = useValidationStore.getState();
|
||||
updatedIndices.forEach((rowIndex) => {
|
||||
clearFieldError(rowIndex, fieldKey);
|
||||
});
|
||||
|
||||
toast.success(`Converted ${updatedIndices.length} ${label} value${updatedIndices.length === 1 ? '' : 's'}`);
|
||||
} else {
|
||||
toast.info('No values to convert');
|
||||
}
|
||||
|
||||
setIsPopoverOpen(false);
|
||||
}, [fieldKey, label]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1 truncate w-full group relative"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => {
|
||||
if (!isPopoverOpen) setIsHovered(false);
|
||||
}}
|
||||
>
|
||||
<span className="">{label}</span>
|
||||
{isRequired && (
|
||||
<span className="text-destructive flex-shrink-0">*</span>
|
||||
)}
|
||||
{(isHovered || isPopoverOpen) && hasConvertibleCells && (
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={cn(
|
||||
'absolute right-1 top-1/2 -translate-y-1/2',
|
||||
'flex items-center gap-0.5',
|
||||
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'transition-opacity'
|
||||
)}
|
||||
>
|
||||
<Scale className="h-3 w-3" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Convert units for entire column</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<PopoverContent className="w-48 p-2" align="end">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Convert {isWeightField ? 'Weight' : 'Dimensions'}
|
||||
</p>
|
||||
{conversions.map((conversion) => (
|
||||
<Button
|
||||
key={conversion.label}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-xs h-7"
|
||||
onClick={() => handleConversion(conversion)}
|
||||
>
|
||||
{conversion.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
UnitConversionColumnHeader.displayName = 'UnitConversionColumnHeader';
|
||||
|
||||
/**
|
||||
* Main table component
|
||||
*
|
||||
@@ -1731,23 +2033,41 @@ export const ValidationTable = () => {
|
||||
const dataColumns: ColumnDef<RowData>[] = fields.map((field) => {
|
||||
const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false;
|
||||
const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each';
|
||||
const isUnitConversionColumn = field.key === 'weight' || field.key === 'length' || field.key === 'width' || field.key === 'height';
|
||||
|
||||
return {
|
||||
id: field.key,
|
||||
header: () => isPriceColumn ? (
|
||||
<PriceColumnHeader
|
||||
fieldKey={field.key as 'msrp' | 'cost_each'}
|
||||
label={field.label}
|
||||
isRequired={isRequired}
|
||||
/>
|
||||
) : (
|
||||
// Determine which header component to render
|
||||
const renderHeader = () => {
|
||||
if (isPriceColumn) {
|
||||
return (
|
||||
<PriceColumnHeader
|
||||
fieldKey={field.key as 'msrp' | 'cost_each'}
|
||||
label={field.label}
|
||||
isRequired={isRequired}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isUnitConversionColumn) {
|
||||
return (
|
||||
<UnitConversionColumnHeader
|
||||
fieldKey={field.key as 'weight' | 'length' | 'width' | 'height'}
|
||||
label={field.label}
|
||||
isRequired={isRequired}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1 truncate">
|
||||
<span className="truncate">{field.label}</span>
|
||||
{isRequired && (
|
||||
<span className="text-destructive flex-shrink-0">*</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
id: field.key,
|
||||
header: renderHeader,
|
||||
size: field.width || 150,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface ProductForSanityCheck {
|
||||
length?: string | number;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
additional_context?: Record<string, string>; // AI supplemental columns from MatchColumnsStep
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface RowData {
|
||||
__original?: Record<string, unknown>; // Original values before AI changes
|
||||
__corrected?: Record<string, unknown>; // AI-corrected values
|
||||
__changes?: Record<string, boolean>; // Fields changed by AI
|
||||
__aiSupplemental?: string[]; // AI supplemental columns from MatchColumnsStep
|
||||
__aiSupplemental?: Record<string, string>; // AI supplemental columns from MatchColumnsStep (header -> value)
|
||||
|
||||
// Standard fields (from config.ts)
|
||||
supplier?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Data, Fields, Info, RowHook, TableHook, Meta, Errors } from "../../../types"
|
||||
import { v4 } from "uuid"
|
||||
import { ErrorSources, ErrorType } from "../../../types"
|
||||
import { normalizeCountryCode } from "./countryUtils"
|
||||
|
||||
|
||||
type DataWithMeta<T extends string> = Data<T> & Meta & {
|
||||
@@ -56,6 +57,21 @@ export const addErrorsAndRunHooks = async <T extends string>(
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize country of origin (coo) to 2-letter ISO codes
|
||||
processedData.forEach((row) => {
|
||||
const coo = (row as Record<string, unknown>).coo
|
||||
if (typeof coo === "string" && coo.trim()) {
|
||||
const raw = coo.trim()
|
||||
const normalized = normalizeCountryCode(raw)
|
||||
if (normalized) {
|
||||
(row as Record<string, unknown>).coo = normalized
|
||||
} else if (raw.length === 2) {
|
||||
// Uppercase 2-letter values as fallback
|
||||
(row as Record<string, unknown>).coo = raw.toUpperCase()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fields.forEach((field) => {
|
||||
const fieldKey = field.key as string
|
||||
field.validations?.forEach((validation) => {
|
||||
|
||||
+8
-1
@@ -185,11 +185,18 @@ export function buildDescriptionValidationPayload(
|
||||
fields: Field<string>[],
|
||||
overrides?: PayloadOverrides
|
||||
): DescriptionValidationPayload {
|
||||
return {
|
||||
const payload: DescriptionValidationPayload = {
|
||||
name: overrides?.name ?? String(row.name || ''),
|
||||
description: overrides?.description ?? String(row.description || ''),
|
||||
company_name: row.company ? getFieldLabel(fields, 'company', row.company) : undefined,
|
||||
company_id: row.company ? String(row.company) : undefined, // For backend prompt loading
|
||||
categories: row.categories as string | undefined,
|
||||
};
|
||||
|
||||
// Add AI supplemental context if present (from MatchColumnsStep "AI context only" columns)
|
||||
if (row.__aiSupplemental && typeof row.__aiSupplemental === 'object') {
|
||||
payload.additional_context = row.__aiSupplemental;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type Meta = { __index: string }
|
||||
export type SubmitOptions = {
|
||||
targetEnvironment: "dev" | "prod"
|
||||
useTestDataSource: boolean
|
||||
skipApiSubmission?: boolean
|
||||
}
|
||||
|
||||
export type RsiProps<T extends string> = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useContext } from "react";
|
||||
import { useState, useContext, useMemo } from "react";
|
||||
import { ReactSpreadsheetImport, StepType } from "@/components/product-import";
|
||||
import type { StepState } from "@/components/product-import/steps/UploadFlow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -6,9 +6,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Code } from "@/components/ui/code";
|
||||
import { toast } from "sonner";
|
||||
import { motion } from "framer-motion";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import config from "@/config";
|
||||
import { Loader2, AlertCircle, AlertTriangle, Info, CheckCircle, ExternalLink } from "lucide-react";
|
||||
import { Loader2, AlertCircle, AlertTriangle, Info, CheckCircle, ExternalLink, BookmarkPlus } from "lucide-react";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -16,6 +16,7 @@ import type { Data, DataValue, FieldType, Result, SubmitOptions } from "@/compon
|
||||
import { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config";
|
||||
import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/apiv2";
|
||||
import { AuthContext } from "@/contexts/AuthContext";
|
||||
import { TemplateForm } from "@/components/templates/TemplateForm";
|
||||
|
||||
type NormalizedProduct = Record<ImportFieldKey | "product_images", string | string[] | boolean | null>;
|
||||
type ImportResult = Result<string> & { all?: Result<string>["validData"] };
|
||||
@@ -264,8 +265,11 @@ export function Import() {
|
||||
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
|
||||
const [selectedLine, setSelectedLine] = useState<string | null>(null);
|
||||
const [startFromScratch, setStartFromScratch] = useState(false);
|
||||
const [templateSaveDialogOpen, setTemplateSaveDialogOpen] = useState(false);
|
||||
const [selectedProductForTemplate, setSelectedProductForTemplate] = useState<NormalizedProduct | null>(null);
|
||||
const { user } = useContext(AuthContext);
|
||||
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"));
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// ========== TEMPORARY TEST DATA ==========
|
||||
// Uncomment the useEffect below to test the results page without submitting actual data
|
||||
@@ -659,7 +663,11 @@ export function Import() {
|
||||
};
|
||||
|
||||
const normalizeUpcValue = (value: string): string => {
|
||||
const expanded = expandScientificNotation(value);
|
||||
// First strip quotes (single, double, smart quotes) and whitespace
|
||||
const cleaned = value.replace(/['"'"\s]/g, "");
|
||||
// Then handle scientific notation
|
||||
const expanded = expandScientificNotation(cleaned);
|
||||
// Extract only digits
|
||||
const digitsOnly = expanded.replace(/[^0-9]/g, "");
|
||||
return digitsOnly || expanded;
|
||||
};
|
||||
@@ -732,6 +740,38 @@ export function Import() {
|
||||
} as NormalizedProduct;
|
||||
});
|
||||
|
||||
// Handle debug mode: skip API submission entirely
|
||||
if (submitOptions?.skipApiSubmission) {
|
||||
// Generate mock response simulating successful creation
|
||||
const mockCreated = formattedRows.map((product, index) => ({
|
||||
upc: product.upc,
|
||||
item_number: product.item_number,
|
||||
pid: `mock-${Date.now()}-${index}`,
|
||||
}));
|
||||
|
||||
const mockResponse: SubmitNewProductsResponse = {
|
||||
success: true,
|
||||
message: `[DEBUG] Skipped API - ${formattedRows.length} product(s) would have been submitted`,
|
||||
data: {
|
||||
created: mockCreated,
|
||||
errored: [],
|
||||
},
|
||||
};
|
||||
|
||||
setResumeStepState(undefined);
|
||||
setImportOutcome({
|
||||
submittedProducts: formattedRows.map((product) => ({ ...product })),
|
||||
submittedRows: rows.map((row) => ({ ...row })),
|
||||
response: mockResponse,
|
||||
});
|
||||
setIsDebugDataVisible(false);
|
||||
setIsOpen(false);
|
||||
setStartFromScratch(false);
|
||||
|
||||
toast.success(`[DEBUG] Skipped API submission for ${formattedRows.length} product(s)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await submitNewProducts({
|
||||
products: formattedRows,
|
||||
environment: submitOptions?.targetEnvironment ?? "prod",
|
||||
@@ -824,6 +864,8 @@ export function Import() {
|
||||
itemNumber: productItemNumber ?? responseItemNumber ?? "—",
|
||||
url: pidValue ? `https://backend.acherryontop.com/product/${pidValue}` : null,
|
||||
pid: pidValue,
|
||||
// Store index to access full product data for template saving
|
||||
submittedProductIndex: productIndex,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
@@ -918,6 +960,86 @@ export function Import() {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
// Handle opening save template dialog for a created product
|
||||
const handleSaveAsTemplate = (product: NormalizedProduct) => {
|
||||
setSelectedProductForTemplate(product);
|
||||
setTemplateSaveDialogOpen(true);
|
||||
};
|
||||
|
||||
// Convert NormalizedProduct to TemplateForm format
|
||||
const templateFormData = useMemo(() => {
|
||||
if (!selectedProductForTemplate) return null;
|
||||
|
||||
const product = selectedProductForTemplate;
|
||||
|
||||
// Helper to parse numeric values
|
||||
const parseNumeric = (val: string | string[] | boolean | null): number | undefined => {
|
||||
if (typeof val === 'string') {
|
||||
const parsed = parseFloat(val.replace(/[$,]/g, ''));
|
||||
return isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Helper to extract string value
|
||||
const getString = (val: string | string[] | boolean | null): string | undefined => {
|
||||
if (typeof val === 'string') return val || undefined;
|
||||
if (Array.isArray(val) && val.length > 0) return val[0];
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Helper to extract string array
|
||||
const getStringArray = (val: string | string[] | boolean | null): string[] | undefined => {
|
||||
if (Array.isArray(val)) return val.length > 0 ? val : undefined;
|
||||
if (typeof val === 'string' && val) return [val];
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
company: getString(product.company) || '',
|
||||
product_type: getString(product.name) || '', // Use product name as default type
|
||||
supplier: getString(product.supplier),
|
||||
msrp: parseNumeric(product.msrp),
|
||||
cost_each: parseNumeric(product.cost_each),
|
||||
qty_per_unit: parseNumeric(product.qty_per_unit),
|
||||
case_qty: parseNumeric(product.case_qty),
|
||||
hts_code: getString(product.hts_code),
|
||||
description: getString(product.description),
|
||||
weight: parseNumeric(product.weight),
|
||||
length: parseNumeric(product.length),
|
||||
width: parseNumeric(product.width),
|
||||
height: parseNumeric(product.height),
|
||||
tax_cat: getString(product.tax_cat),
|
||||
size_cat: getString(product.size_cat),
|
||||
categories: getStringArray(product.categories),
|
||||
ship_restrictions: getStringArray(product.ship_restrictions),
|
||||
};
|
||||
}, [selectedProductForTemplate]);
|
||||
|
||||
// Convert fieldOptions to TemplateForm format
|
||||
const templateFieldOptions = useMemo(() => {
|
||||
if (!fieldOptions) return null;
|
||||
return {
|
||||
companies: fieldOptions.companies || [],
|
||||
artists: fieldOptions.artists || [],
|
||||
sizes: fieldOptions.sizeCategories || [],
|
||||
themes: fieldOptions.themes || [],
|
||||
categories: fieldOptions.categories || [],
|
||||
colors: fieldOptions.colors || [],
|
||||
suppliers: fieldOptions.suppliers || [],
|
||||
taxCategories: fieldOptions.taxCategories || [],
|
||||
shippingRestrictions: fieldOptions.shippingRestrictions || [],
|
||||
};
|
||||
}, [fieldOptions]);
|
||||
|
||||
// Handle successful template save
|
||||
const handleTemplateSaveSuccess = () => {
|
||||
setTemplateSaveDialogOpen(false);
|
||||
setSelectedProductForTemplate(null);
|
||||
// Invalidate templates query if it exists
|
||||
queryClient.invalidateQueries({ queryKey: ['templates'] });
|
||||
};
|
||||
|
||||
if (isLoadingOptions) {
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
@@ -1084,11 +1206,27 @@ export function Import() {
|
||||
<span className="text-sm font-medium">{product.name}</span>
|
||||
)}
|
||||
</div>
|
||||
{product.submittedProductIndex !== undefined && product.submittedProductIndex >= 0 && importOutcome && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 gap-1.5"
|
||||
onClick={() => handleSaveAsTemplate(importOutcome.submittedProducts[product.submittedProductIndex])}
|
||||
>
|
||||
<BookmarkPlus className="h-4 w-4" />
|
||||
<span className="text-xs">Save as Template</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
UPC: {product.upc}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Item #: {product.itemNumber}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
UPC: {product.upc}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Item #: {product.itemNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1167,6 +1305,19 @@ export function Import() {
|
||||
: undefined)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Template Save Dialog */}
|
||||
<TemplateForm
|
||||
isOpen={templateSaveDialogOpen}
|
||||
onClose={() => {
|
||||
setTemplateSaveDialogOpen(false);
|
||||
setSelectedProductForTemplate(null);
|
||||
}}
|
||||
onSuccess={handleTemplateSaveSuccess}
|
||||
initialData={templateFormData}
|
||||
mode="create"
|
||||
fieldOptions={templateFieldOptions}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user