Add UPC generation, add automatic correction of UPC check digits, properly deal with already existing UPCs, add in final results display after submitting with option to fix errored products
This commit is contained in:
@@ -955,6 +955,155 @@ router.get('/search-products', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
const UPC_SUPPLIER_PREFIX_LEADING_DIGIT = '4';
|
||||
const UPC_MAX_SEQUENCE = 99999;
|
||||
const UPC_RESERVATION_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function buildSupplierPrefix(supplierId) {
|
||||
const numericId = Number.parseInt(String(supplierId), 10);
|
||||
if (Number.isNaN(numericId) || numericId < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const padded = String(numericId).padStart(5, '0');
|
||||
const prefix = `${UPC_SUPPLIER_PREFIX_LEADING_DIGIT}${padded}`;
|
||||
return prefix.length === 6 ? prefix : null;
|
||||
}
|
||||
|
||||
function calculateUpcCheckDigit(upcWithoutCheckDigit) {
|
||||
if (!/^\d{11}$/.test(upcWithoutCheckDigit)) {
|
||||
throw new Error('UPC body must be 11 numeric characters');
|
||||
}
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < upcWithoutCheckDigit.length; i += 1) {
|
||||
const digit = Number.parseInt(upcWithoutCheckDigit[i], 10);
|
||||
sum += (i % 2 === 0) ? digit * 3 : digit;
|
||||
}
|
||||
|
||||
const mod = sum % 10;
|
||||
return mod === 0 ? 0 : 10 - mod;
|
||||
}
|
||||
|
||||
const upcReservationCache = new Map();
|
||||
const upcGenerationLocks = new Map();
|
||||
|
||||
function getReservedSequence(prefix) {
|
||||
const entry = upcReservationCache.get(prefix);
|
||||
if (!entry) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
upcReservationCache.delete(prefix);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return entry.lastSequence;
|
||||
}
|
||||
|
||||
function setReservedSequence(prefix, sequence) {
|
||||
upcReservationCache.set(prefix, {
|
||||
lastSequence: sequence,
|
||||
expiresAt: Date.now() + UPC_RESERVATION_TTL
|
||||
});
|
||||
}
|
||||
|
||||
async function runWithSupplierLock(prefix, task) {
|
||||
const previous = upcGenerationLocks.get(prefix) || Promise.resolve();
|
||||
const chained = previous.catch(() => {}).then(() => task());
|
||||
upcGenerationLocks.set(prefix, chained);
|
||||
|
||||
try {
|
||||
return await chained;
|
||||
} finally {
|
||||
if (upcGenerationLocks.get(prefix) === chained) {
|
||||
upcGenerationLocks.delete(prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/generate-upc', async (req, res) => {
|
||||
const { supplierId, increment } = req.body || {};
|
||||
|
||||
if (supplierId === undefined || supplierId === null || String(supplierId).trim() === '') {
|
||||
return res.status(400).json({ error: 'Supplier ID is required to generate a UPC' });
|
||||
}
|
||||
|
||||
const supplierPrefix = buildSupplierPrefix(supplierId);
|
||||
if (!supplierPrefix) {
|
||||
return res.status(400).json({ error: 'Supplier ID must be a non-negative number with at most 5 digits' });
|
||||
}
|
||||
|
||||
const step = Number.parseInt(increment, 10);
|
||||
const sequenceIncrement = Number.isNaN(step) || step < 1 ? 1 : step;
|
||||
|
||||
try {
|
||||
const result = await runWithSupplierLock(supplierPrefix, async () => {
|
||||
const { connection } = await getDbConnection();
|
||||
|
||||
const [rows] = await connection.query(
|
||||
`SELECT CAST(SUBSTRING(upc,7,5) AS UNSIGNED) AS num
|
||||
FROM products
|
||||
WHERE LEFT(upc, 6) = ? AND LENGTH(upc) = 12
|
||||
ORDER BY num DESC
|
||||
LIMIT 1`,
|
||||
[supplierPrefix]
|
||||
);
|
||||
|
||||
const lastSequenceFromDb = rows && rows.length > 0 && rows[0].num !== null
|
||||
? Number.parseInt(rows[0].num, 10) || 0
|
||||
: 0;
|
||||
|
||||
const cachedSequence = getReservedSequence(supplierPrefix);
|
||||
const baselineSequence = Math.max(lastSequenceFromDb, cachedSequence);
|
||||
|
||||
let nextSequence = baselineSequence + sequenceIncrement;
|
||||
let candidateUpc = null;
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < 10 && nextSequence <= UPC_MAX_SEQUENCE) {
|
||||
const sequencePart = String(nextSequence).padStart(5, '0');
|
||||
const upcBody = `${supplierPrefix}${sequencePart}`;
|
||||
const checkDigit = calculateUpcCheckDigit(upcBody);
|
||||
const fullUpc = `${upcBody}${checkDigit}`;
|
||||
|
||||
const [existing] = await connection.query(
|
||||
'SELECT 1 FROM products WHERE upc = ? LIMIT 1',
|
||||
[fullUpc]
|
||||
);
|
||||
|
||||
if (!existing || existing.length === 0) {
|
||||
candidateUpc = { upc: fullUpc, sequence: nextSequence };
|
||||
break;
|
||||
}
|
||||
|
||||
nextSequence += 1;
|
||||
attempts += 1;
|
||||
}
|
||||
|
||||
if (!candidateUpc) {
|
||||
const reason = nextSequence > UPC_MAX_SEQUENCE
|
||||
? 'UPC range exhausted for this supplier'
|
||||
: 'Unable to find an available UPC';
|
||||
const error = new Error(reason);
|
||||
error.status = 409;
|
||||
throw error;
|
||||
}
|
||||
|
||||
setReservedSequence(supplierPrefix, candidateUpc.sequence);
|
||||
return candidateUpc.upc;
|
||||
});
|
||||
|
||||
return res.json({ success: true, upc: result });
|
||||
} catch (error) {
|
||||
console.error('Error generating UPC:', error);
|
||||
const status = error.status && Number.isInteger(error.status) ? error.status : 500;
|
||||
const message = status === 500 ? 'Failed to generate UPC' : error.message;
|
||||
return res.status(status).json({ error: message, details: status === 500 ? error.message : undefined });
|
||||
}
|
||||
});
|
||||
|
||||
// Endpoint to check UPC and generate item number
|
||||
router.get('/check-upc-and-generate-sku', async (req, res) => {
|
||||
const { upc, supplierId } = req.query;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Field, ErrorType } from '../../../types'
|
||||
import { AlertCircle, ArrowDown, X } from 'lucide-react'
|
||||
import { AlertCircle, ArrowDown, Wand2, X } from 'lucide-react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -12,6 +12,8 @@ import SelectCell from './cells/SelectCell'
|
||||
import MultiSelectCell from './cells/MultiSelectCell'
|
||||
import { TableCell } from '@/components/ui/table'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import config from '@/config'
|
||||
|
||||
// Context for copy down selection mode
|
||||
export const CopyDownContext = React.createContext<{
|
||||
@@ -203,6 +205,7 @@ export interface ValidationCellProps {
|
||||
rowIndex: number
|
||||
copyDown?: (endRowIndex?: number) => void
|
||||
totalRows?: number
|
||||
rowData: Record<string, any>
|
||||
editingCells: Set<string>
|
||||
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||
}
|
||||
@@ -303,11 +306,14 @@ const ValidationCell = React.memo(({
|
||||
copyDown,
|
||||
rowIndex,
|
||||
totalRows = 0,
|
||||
// editingCells not used; keep setEditingCells for API compatibility
|
||||
rowData,
|
||||
editingCells,
|
||||
setEditingCells
|
||||
}: ValidationCellProps) => {
|
||||
// Use the CopyDown context
|
||||
const copyDownContext = React.useContext(CopyDownContext);
|
||||
const { toast } = useToast();
|
||||
const [isGeneratingUpc, setIsGeneratingUpc] = React.useState(false);
|
||||
|
||||
// CRITICAL FIX: For item_number fields, always prioritize the itemNumber prop over the value
|
||||
// This ensures that when the itemNumber changes, the display value changes
|
||||
@@ -339,6 +345,7 @@ const ValidationCell = React.memo(({
|
||||
|
||||
// PERFORMANCE FIX: Create cell key for editing state management
|
||||
const cellKey = `${rowIndex}-${fieldKey}`;
|
||||
const isEditingCell = editingCells.has(cellKey);
|
||||
|
||||
// SINGLE-CLICK EDITING FIX: Create editing state management functions
|
||||
const handleStartEdit = React.useCallback(() => {
|
||||
@@ -353,9 +360,6 @@ const ValidationCell = React.memo(({
|
||||
});
|
||||
}, [setEditingCells, cellKey]);
|
||||
|
||||
// Force isValidating to be a boolean
|
||||
const isLoading = isValidating === true;
|
||||
|
||||
// Handle copy down button click
|
||||
const handleCopyDownClick = React.useCallback(() => {
|
||||
if (copyDown && totalRows > rowIndex + 1) {
|
||||
@@ -404,6 +408,91 @@ const ValidationCell = React.memo(({
|
||||
return '';
|
||||
}, [isSourceCell, isSelectedTarget, isInTargetRow]);
|
||||
|
||||
const isUpcField = fieldKey === 'upc';
|
||||
const baseIsLoading = isValidating === true;
|
||||
const showGeneratingSkeleton = isUpcField && isGeneratingUpc;
|
||||
const isLoading = baseIsLoading || showGeneratingSkeleton;
|
||||
|
||||
const supplierRaw = rowData?.supplier ?? rowData?.supplier_id ?? rowData?.supplierId;
|
||||
const supplierIdString = supplierRaw !== undefined && supplierRaw !== null
|
||||
? String(supplierRaw).trim()
|
||||
: '';
|
||||
const normalizedSupplierId = /^\d+$/.test(supplierIdString) ? supplierIdString : '';
|
||||
const canGenerateUpc = normalizedSupplierId !== '';
|
||||
const upcValueEmpty = isUpcField && isEmpty(displayValue);
|
||||
const showGenerateButton = upcValueEmpty && !isEditingCell && !copyDownContext.isInCopyDownMode && !isInTargetRow && !isLoading;
|
||||
const cellClassNameWithPadding = showGenerateButton ? `${cellClassName} pr-10`.trim() : cellClassName;
|
||||
const buttonDisabled = !canGenerateUpc || isGeneratingUpc;
|
||||
const tooltipMessage = canGenerateUpc ? 'Generate UPC' : 'Select a supplier before generating a UPC';
|
||||
|
||||
const handleGenerateUpc = React.useCallback(async () => {
|
||||
if (!normalizedSupplierId) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
description: 'Select a supplier before generating a UPC.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGeneratingUpc) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingUpc(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.apiUrl}/import/generate-upc`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ supplierId: normalizedSupplierId })
|
||||
});
|
||||
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (parseError) {
|
||||
// Ignore JSON parse errors and handle via status code
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = payload?.error || `Request failed (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (!payload || !payload.success || !payload.upc) {
|
||||
throw new Error(payload?.error || 'Unexpected response while generating UPC');
|
||||
}
|
||||
|
||||
onChange(payload.upc);
|
||||
} catch (error) {
|
||||
console.error('Error generating UPC:', error);
|
||||
const errorMessage =
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'message' in error &&
|
||||
typeof (error as { message?: unknown }).message === 'string'
|
||||
? (error as { message: string }).message
|
||||
: 'Failed to generate UPC';
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
description: errorMessage,
|
||||
});
|
||||
} finally {
|
||||
setIsGeneratingUpc(false);
|
||||
}
|
||||
}, [normalizedSupplierId, isGeneratingUpc, onChange, toast]);
|
||||
|
||||
const handleGenerateButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!buttonDisabled) {
|
||||
handleGenerateUpc();
|
||||
}
|
||||
}, [buttonDisabled, handleGenerateUpc]);
|
||||
const containerClassName = `truncate overflow-hidden${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? ' bg-blue-50/50' : ''}${showGenerateButton ? ' relative group/upc' : ''}`.trim();
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
className="p-1 group relative"
|
||||
@@ -472,7 +561,7 @@ const ValidationCell = React.memo(({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}
|
||||
className={containerClassName}
|
||||
style={{
|
||||
backgroundColor: isSourceCell ? '#dbeafe' :
|
||||
isSelectedTarget ? '#bfdbfe' :
|
||||
@@ -488,11 +577,32 @@ const ValidationCell = React.memo(({
|
||||
onChange={onChange}
|
||||
hasErrors={hasError || isRequiredButEmpty}
|
||||
options={options}
|
||||
className={cellClassName}
|
||||
className={cellClassNameWithPadding}
|
||||
fieldKey={fieldKey}
|
||||
onStartEdit={handleStartEdit}
|
||||
onEndEdit={handleEndEdit}
|
||||
/>
|
||||
{showGenerateButton && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerateButtonClick}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1 rounded-md border border-input bg-background px-2 py-1 text-xs text-muted-foreground shadow-sm transition-opacity opacity-0 group-hover/upc:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={buttonDisabled}
|
||||
aria-label="Generate UPC"
|
||||
>
|
||||
<Wand2 className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>{tooltipMessage}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -518,6 +628,9 @@ const ValidationCell = React.memo(({
|
||||
// Check field identity
|
||||
if (prevProps.field !== nextProps.field) return false;
|
||||
|
||||
if (prevProps.rowData !== nextProps.rowData) return false;
|
||||
if (prevProps.editingCells !== nextProps.editingCells) return false;
|
||||
|
||||
// Shallow options comparison - only if field type is select or multi-select
|
||||
if (prevProps.field.fieldType?.type === 'select' || prevProps.field.fieldType?.type === 'multi-select') {
|
||||
const optionsEqual = prevProps.options === nextProps.options ||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Protected } from '@/components/auth/Protected'
|
||||
import { normalizeCountryCode } from '../utils/countryUtils'
|
||||
import { cleanPriceField } from '../utils/priceUtils'
|
||||
import { correctUpcValue } from '../utils/upcUtils'
|
||||
import InitializingValidation from './InitializingValidation'
|
||||
/**
|
||||
* ValidationContainer component - the main wrapper for the validation step
|
||||
@@ -431,6 +432,11 @@ const ValidationContainer = <T extends string>({
|
||||
}
|
||||
}
|
||||
|
||||
if ((key === 'upc' || key === 'barcode') && value !== undefined && value !== null) {
|
||||
const { corrected } = correctUpcValue(value);
|
||||
processedValue = corrected;
|
||||
}
|
||||
|
||||
return processedValue;
|
||||
}, []);
|
||||
|
||||
@@ -575,14 +581,20 @@ const ValidationContainer = <T extends string>({
|
||||
if (key === 'supplier' && value) {
|
||||
const upcValue = (data[rowIndex] as any)?.upc || (data[rowIndex] as any)?.barcode;
|
||||
if (upcValue) {
|
||||
handleUpcValidation(rowIndex, value.toString(), upcValue.toString());
|
||||
const normalized = correctUpcValue(upcValue).corrected;
|
||||
if (normalized) {
|
||||
handleUpcValidation(rowIndex, value.toString(), normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((key === 'upc' || key === 'barcode') && value) {
|
||||
if ((key === 'upc' || key === 'barcode') && processedValue) {
|
||||
const supplier = (data[rowIndex] as any)?.supplier;
|
||||
if (supplier) {
|
||||
handleUpcValidation(rowIndex, supplier.toString(), value.toString());
|
||||
const normalized = correctUpcValue(processedValue).corrected;
|
||||
if (normalized) {
|
||||
handleUpcValidation(rowIndex, supplier.toString(), normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -438,6 +438,7 @@ const ValidationTable = <T extends string>({
|
||||
rowIndex={row.index}
|
||||
copyDown={(endRowIndex?: number) => handleCopyDown(row.index, field.key as string, endRowIndex)}
|
||||
totalRows={data.length}
|
||||
rowData={row.original as Record<string, any>}
|
||||
editingCells={editingCells}
|
||||
setEditingCells={setEditingCells}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect, Dispatch, SetStateAction } from 'react'
|
||||
import config from '@/config'
|
||||
import { ErrorSources, ErrorType, ValidationError } from '../../../types'
|
||||
|
||||
interface ValidationState {
|
||||
validatingCells: Set<string>; // Using rowIndex-fieldKey as identifier
|
||||
@@ -10,7 +11,8 @@ interface ValidationState {
|
||||
|
||||
export const useUpcValidation = (
|
||||
data: any[],
|
||||
setData: (updater: any[] | ((prevData: any[]) => any[])) => void
|
||||
setData: (updater: any[] | ((prevData: any[]) => any[])) => void,
|
||||
setValidationErrors: Dispatch<SetStateAction<Map<number, Record<string, ValidationError[]>>>>
|
||||
) => {
|
||||
// Use a ref for validation state to avoid triggering re-renders
|
||||
const validationStateRef = useRef<ValidationState>({
|
||||
@@ -85,6 +87,53 @@ export const useUpcValidation = (
|
||||
}, 0);
|
||||
}, [setData]);
|
||||
|
||||
const applyUpcUniqueError = useCallback((rowIndex: number, message?: string) => {
|
||||
const error: ValidationError = {
|
||||
message: message || 'Must be unique',
|
||||
level: 'error',
|
||||
source: ErrorSources.Table,
|
||||
type: ErrorType.Unique
|
||||
};
|
||||
|
||||
setValidationErrors(prev => {
|
||||
const newErrors = new Map(prev);
|
||||
const existing = { ...(newErrors.get(rowIndex) || {}) };
|
||||
existing.upc = [error];
|
||||
newErrors.set(rowIndex, existing);
|
||||
return newErrors;
|
||||
});
|
||||
}, [setValidationErrors]);
|
||||
|
||||
const clearUpcUniqueError = useCallback((rowIndex: number) => {
|
||||
setValidationErrors(prev => {
|
||||
const existing = prev.get(rowIndex);
|
||||
if (!existing || !existing.upc) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const filtered = existing.upc.filter(err => err.type !== ErrorType.Unique);
|
||||
if (filtered.length === existing.upc.length) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const newErrors = new Map(prev);
|
||||
const updated = { ...existing } as Record<string, ValidationError[]>;
|
||||
if (filtered.length > 0) {
|
||||
updated.upc = filtered;
|
||||
} else {
|
||||
delete updated.upc;
|
||||
}
|
||||
|
||||
if (Object.keys(updated).length > 0) {
|
||||
newErrors.set(rowIndex, updated);
|
||||
} else {
|
||||
newErrors.delete(rowIndex);
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
});
|
||||
}, [setValidationErrors]);
|
||||
|
||||
// Mark a row as no longer being validated
|
||||
const stopValidatingRow = useCallback((rowIndex: number) => {
|
||||
validationStateRef.current.validatingRows.delete(rowIndex);
|
||||
@@ -117,22 +166,41 @@ export const useUpcValidation = (
|
||||
console.log(`Fetching product for UPC ${upcValue} with supplier ${supplierId}`);
|
||||
const response = await fetch(`${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upcValue)}&supplierId=${encodeURIComponent(supplierId)}`);
|
||||
|
||||
// Handle error responses
|
||||
let payload: any = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (parseError) {
|
||||
// Non-JSON responses are treated generically below
|
||||
}
|
||||
|
||||
if (response.status === 409) {
|
||||
console.log(`UPC ${upcValue} already exists`);
|
||||
return { error: true, message: 'UPC already exists' };
|
||||
return {
|
||||
error: true,
|
||||
code: 'conflict',
|
||||
message: payload?.error || 'UPC already exists',
|
||||
data: payload || undefined
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`API error: ${response.status}`);
|
||||
return { error: true, message: `API error (${response.status})` };
|
||||
return {
|
||||
error: true,
|
||||
code: 'http_error',
|
||||
message: payload?.error || `API error (${response.status})`,
|
||||
data: payload || undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Process successful response
|
||||
const data = await response.json();
|
||||
const data = payload;
|
||||
|
||||
if (!data.success) {
|
||||
return { error: true, message: data.message || 'Unknown error' };
|
||||
if (!data?.success) {
|
||||
return {
|
||||
error: true,
|
||||
code: 'invalid_response',
|
||||
message: data?.message || 'Unknown error'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -205,35 +273,40 @@ export const useUpcValidation = (
|
||||
// Extract the item number from the API response - check for !error since API returns { error: boolean, data: any }
|
||||
if (product && !product.error && product.data?.itemNumber) {
|
||||
console.log(`[UPC-DEBUG] Got item number for row ${rowIndex}: ${product.data.itemNumber}`);
|
||||
updateItemNumber(rowIndex, product.data.itemNumber);
|
||||
|
||||
// CRITICAL FIX: Directly update the data with the new item number first
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
if (rowIndex >= 0 && rowIndex < newData.length) {
|
||||
// This should happen before updating the map
|
||||
newData[rowIndex] = {
|
||||
...newData[rowIndex],
|
||||
item_number: product.data.itemNumber
|
||||
};
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
|
||||
// Then, update the map to match what's now in the data
|
||||
validationStateRef.current.itemNumbers.set(rowIndex, product.data.itemNumber);
|
||||
|
||||
// CRITICAL: Force a React state update to ensure all components re-render
|
||||
// Created a brand new Map object to ensure React detects the change
|
||||
const newItemNumbersMap = new Map(validationStateRef.current.itemNumbers);
|
||||
setItemNumberUpdates(newItemNumbersMap);
|
||||
|
||||
// Force a shallow copy of the itemNumbers map to trigger useEffect dependencies
|
||||
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
|
||||
clearUpcUniqueError(rowIndex);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
itemNumber: product.data.itemNumber
|
||||
};
|
||||
} else if (product && product.error) {
|
||||
console.log(`[UPC-DEBUG] UPC validation error for row ${rowIndex}: ${product.message}`);
|
||||
|
||||
// Clear any existing item number value in data and internal state
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
if (rowIndex >= 0 && rowIndex < newData.length) {
|
||||
newData[rowIndex] = {
|
||||
...newData[rowIndex],
|
||||
item_number: ''
|
||||
};
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
|
||||
if (validationStateRef.current.itemNumbers.has(rowIndex)) {
|
||||
validationStateRef.current.itemNumbers.delete(rowIndex);
|
||||
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
|
||||
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
|
||||
}
|
||||
|
||||
if (product.code === 'conflict') {
|
||||
applyUpcUniqueError(rowIndex, 'Must be unique');
|
||||
}
|
||||
|
||||
return { success: false };
|
||||
} else {
|
||||
// No item number found but validation was still attempted
|
||||
console.log(`[UPC-DEBUG] No item number found for UPC ${upcValue}`);
|
||||
@@ -242,6 +315,7 @@ export const useUpcValidation = (
|
||||
if (validationStateRef.current.itemNumbers.has(rowIndex)) {
|
||||
validationStateRef.current.itemNumbers.delete(rowIndex);
|
||||
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
|
||||
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
|
||||
}
|
||||
|
||||
return { success: false };
|
||||
@@ -267,7 +341,7 @@ export const useUpcValidation = (
|
||||
console.log(`[UPC-DEBUG] Updated validating rows: ${Array.from(validationStateRef.current.validatingRows).join(', ')}`);
|
||||
console.log(`[UPC-DEBUG] Updated validating cells: ${Array.from(validationStateRef.current.validatingCells).join(', ')}`);
|
||||
}
|
||||
}, [fetchProductByUpc, updateItemNumber, setData]);
|
||||
}, [fetchProductByUpc, updateItemNumber, setData, applyUpcUniqueError, clearUpcUniqueError]);
|
||||
|
||||
// Apply all pending item numbers to the data state
|
||||
const applyItemNumbersToData = useCallback((callback?: (updatedRows: number[]) => void) => {
|
||||
@@ -415,8 +489,31 @@ export const useUpcValidation = (
|
||||
// Update item number
|
||||
updateItemNumber(index, itemNumber);
|
||||
batchUpdatedRows.push(index);
|
||||
clearUpcUniqueError(index);
|
||||
} else {
|
||||
console.warn(`No item number found for row ${index} UPC ${upcValue}`);
|
||||
|
||||
// Clear any previous item numbers for the row
|
||||
setData(prevData => {
|
||||
const newData = [...prevData];
|
||||
if (index >= 0 && index < newData.length) {
|
||||
newData[index] = {
|
||||
...newData[index],
|
||||
item_number: ''
|
||||
};
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
|
||||
if (validationStateRef.current.itemNumbers.has(index)) {
|
||||
validationStateRef.current.itemNumbers.delete(index);
|
||||
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
|
||||
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
|
||||
}
|
||||
|
||||
if (result.error && result.code === 'conflict') {
|
||||
applyUpcUniqueError(index, 'Must be unique');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error validating row ${index}:`, error);
|
||||
@@ -452,7 +549,18 @@ export const useUpcValidation = (
|
||||
|
||||
console.log('Completed initial UPC validation');
|
||||
}
|
||||
}, [data, fetchProductByUpc, updateItemNumber, startValidatingCell, stopValidatingCell, stopValidatingRow, applyItemNumbersToData]);
|
||||
}, [
|
||||
data,
|
||||
fetchProductByUpc,
|
||||
updateItemNumber,
|
||||
startValidatingCell,
|
||||
stopValidatingCell,
|
||||
stopValidatingRow,
|
||||
applyItemNumbersToData,
|
||||
setData,
|
||||
applyUpcUniqueError,
|
||||
clearUpcUniqueError
|
||||
]);
|
||||
|
||||
// Run initial UPC validation when data changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useInitialValidation } from "./useInitialValidation";
|
||||
import { Props, RowData } from "./validationTypes";
|
||||
import { normalizeCountryCode } from "../utils/countryUtils";
|
||||
import { cleanPriceField } from "../utils/priceUtils";
|
||||
import { correctUpcValue } from "../utils/upcUtils";
|
||||
|
||||
export const useValidationState = <T extends string>({
|
||||
initialData,
|
||||
@@ -81,6 +82,20 @@ export const useValidationState = <T extends string>({
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedRow.upc !== undefined && updatedRow.upc !== null) {
|
||||
const { corrected, changed } = correctUpcValue(updatedRow.upc);
|
||||
if (changed) {
|
||||
updatedRow.upc = corrected;
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedRow.barcode !== undefined && updatedRow.barcode !== null) {
|
||||
const { corrected, changed } = correctUpcValue(updatedRow.barcode);
|
||||
if (changed) {
|
||||
updatedRow.barcode = corrected;
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRow as RowData<T>;
|
||||
});
|
||||
});
|
||||
@@ -119,7 +134,7 @@ export const useValidationState = <T extends string>({
|
||||
);
|
||||
|
||||
// Use UPC validation hook - MUST be initialized before template management
|
||||
const upcValidation = useUpcValidation(data, setData);
|
||||
const upcValidation = useUpcValidation(data, setData, setValidationErrors);
|
||||
|
||||
// Use unique item numbers validation hook
|
||||
const { validateUniqueItemNumbers } = useUniqueItemNumbersValidation<T>(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
const NUMERIC_REGEX = /^\d+$/;
|
||||
|
||||
export function calculateUpcCheckDigit(upcBody: string): number {
|
||||
if (!NUMERIC_REGEX.test(upcBody) || upcBody.length !== 11) {
|
||||
throw new Error('UPC body must be 11 numeric characters');
|
||||
}
|
||||
|
||||
const digits = upcBody.split('').map((d) => Number.parseInt(d, 10));
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 0; i < digits.length; i += 1) {
|
||||
sum += (i % 2 === 0 ? digits[i] * 3 : digits[i]);
|
||||
}
|
||||
|
||||
const mod = sum % 10;
|
||||
return mod === 0 ? 0 : 10 - mod;
|
||||
}
|
||||
|
||||
export function calculateEanCheckDigit(eanBody: string): number {
|
||||
if (!NUMERIC_REGEX.test(eanBody) || eanBody.length !== 12) {
|
||||
throw new Error('EAN body must be 12 numeric characters');
|
||||
}
|
||||
|
||||
const digits = eanBody.split('').map((d) => Number.parseInt(d, 10));
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 0; i < digits.length; i += 1) {
|
||||
sum += (i % 2 === 0 ? digits[i] : digits[i] * 3);
|
||||
}
|
||||
|
||||
const mod = sum % 10;
|
||||
return mod === 0 ? 0 : 10 - mod;
|
||||
}
|
||||
|
||||
export function correctUpcValue(rawValue: unknown): { corrected: string; changed: boolean } {
|
||||
const value = rawValue ?? '';
|
||||
const str = typeof value === 'string' ? value.trim() : String(value);
|
||||
|
||||
if (str === '' || !NUMERIC_REGEX.test(str)) {
|
||||
return { corrected: str, changed: false };
|
||||
}
|
||||
|
||||
if (str.length === 11) {
|
||||
const check = calculateUpcCheckDigit(str);
|
||||
return { corrected: `${str}${check}`, changed: true };
|
||||
}
|
||||
|
||||
if (str.length === 12) {
|
||||
const body = str.slice(0, 11);
|
||||
const check = calculateUpcCheckDigit(body);
|
||||
const corrected = `${body}${check}`;
|
||||
return { corrected, changed: corrected !== str };
|
||||
}
|
||||
|
||||
if (str.length === 13) {
|
||||
const body = str.slice(0, 12);
|
||||
const check = calculateEanCheckDigit(body);
|
||||
const corrected = `${body}${check}`;
|
||||
return { corrected, changed: corrected !== str };
|
||||
}
|
||||
|
||||
return { corrected: str, changed: false };
|
||||
}
|
||||
@@ -1,28 +1,178 @@
|
||||
import { useState, useContext } from "react";
|
||||
import { ReactSpreadsheetImport, StepType } from "@/components/product-import";
|
||||
import type { StepState } from "@/components/product-import/steps/UploadFlow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardDescription, 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 config from "@/config";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { DataValue, FieldType, Result, SubmitOptions } from "@/components/product-import/types";
|
||||
import type { Data, DataValue, FieldType, Result, SubmitOptions } from "@/components/product-import/types";
|
||||
import { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config";
|
||||
import { submitNewProducts } from "@/services/apiv2";
|
||||
import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/apiv2";
|
||||
import { AuthContext } from "@/contexts/AuthContext";
|
||||
|
||||
type NormalizedProduct = Record<ImportFieldKey | "product_images", string | string[] | boolean | null>;
|
||||
type ImportResult = Result<string> & { all?: Result<string>["validData"] };
|
||||
|
||||
interface BackendProductResult {
|
||||
pid?: number | string;
|
||||
upc?: string | number;
|
||||
UPC?: string | number;
|
||||
itemnumber?: string | number;
|
||||
item_number?: string | number;
|
||||
itemNumber?: string | number;
|
||||
error?: unknown;
|
||||
error_msg?: unknown;
|
||||
errors?: unknown;
|
||||
message?: unknown;
|
||||
reason?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ImportOutcome {
|
||||
submittedProducts: NormalizedProduct[];
|
||||
submittedRows: Data<string>[];
|
||||
response: SubmitNewProductsResponse;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
|
||||
const extractBackendPayload = (
|
||||
data: SubmitNewProductsResponse["data"],
|
||||
): { created: BackendProductResult[]; errored: BackendProductResult[] } => {
|
||||
if (!isRecord(data)) {
|
||||
return { created: [], errored: [] };
|
||||
}
|
||||
|
||||
const toList = (value: unknown): BackendProductResult[] =>
|
||||
Array.isArray(value) ? (value.filter(isRecord) as BackendProductResult[]) : [];
|
||||
|
||||
return {
|
||||
created: toList((data as Record<string, unknown>).created),
|
||||
errored: toList((data as Record<string, unknown>).errored),
|
||||
};
|
||||
};
|
||||
|
||||
const getFirstStringValue = (value: string | string[] | boolean | null | undefined): string | null => {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
if (typeof entry === "string" && entry.trim().length > 0) {
|
||||
return entry.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getImageUrlFromValue = (value: string | string[] | boolean | null | undefined): string | null => {
|
||||
if (Array.isArray(value)) {
|
||||
const first = value.find((entry) => typeof entry === "string" && entry.trim().length > 0);
|
||||
return first ? first.trim() : null;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const [first] = value
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
return first ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeIdentifierValue = (value: unknown): string | null => {
|
||||
if (typeof value === "number") {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const findSubmittedProductIndex = (
|
||||
submittedProducts: NormalizedProduct[],
|
||||
entry: BackendProductResult,
|
||||
): number => {
|
||||
const upcCandidate = normalizeIdentifierValue(entry.upc ?? entry.UPC);
|
||||
const itemNumberCandidate = normalizeIdentifierValue(entry.itemnumber ?? entry.item_number ?? entry.itemNumber);
|
||||
|
||||
return submittedProducts.findIndex((product) => {
|
||||
const productUpc = normalizeIdentifierValue(getFirstStringValue(product["upc"]));
|
||||
const productItemNumber = normalizeIdentifierValue(getFirstStringValue(product["item_number"]));
|
||||
return (upcCandidate && productUpc === upcCandidate) || (itemNumberCandidate && productItemNumber === itemNumberCandidate);
|
||||
});
|
||||
};
|
||||
|
||||
const formatErrorDetails = (entry: BackendProductResult): string | null => {
|
||||
if (typeof entry.error_msg === "string") {
|
||||
return entry.error_msg;
|
||||
}
|
||||
|
||||
if (typeof entry.error === "string") {
|
||||
return entry.error;
|
||||
}
|
||||
|
||||
if (Array.isArray(entry.errors)) {
|
||||
const details = entry.errors.filter((item): item is string => typeof item === "string");
|
||||
if (details.length) {
|
||||
return details.join(", ");
|
||||
}
|
||||
} else if (isRecord(entry.errors)) {
|
||||
const segments = Object.entries(entry.errors as Record<string, unknown>).map(([field, issue]) => {
|
||||
if (Array.isArray(issue)) {
|
||||
const messages = issue.filter((item): item is string => typeof item === "string");
|
||||
return `${field}: ${messages.join(", ")}`;
|
||||
}
|
||||
|
||||
if (typeof issue === "string") {
|
||||
return `${field}: ${issue}`;
|
||||
}
|
||||
|
||||
return `${field}: ${JSON.stringify(issue)}`;
|
||||
});
|
||||
|
||||
if (segments.length) {
|
||||
return segments.join("; ");
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof entry.message === "string") {
|
||||
return entry.message;
|
||||
}
|
||||
|
||||
if (typeof entry.reason === "string") {
|
||||
return entry.reason;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export function Import() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
type NormalizedProduct = Record<ImportFieldKey | "product_images", string | string[] | boolean | null>;
|
||||
type ImportResult = Result<string> & { all?: Result<string>["validData"] };
|
||||
|
||||
const [importedData, setImportedData] = useState<NormalizedProduct[] | null>(null);
|
||||
const [importOutcome, setImportOutcome] = useState<ImportOutcome | null>(null);
|
||||
const [isDebugDataVisible, setIsDebugDataVisible] = useState(false);
|
||||
const [resumeStepState, setResumeStepState] = useState<StepState | undefined>();
|
||||
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
|
||||
const [selectedLine, setSelectedLine] = useState<string | null>(null);
|
||||
const [startFromScratch, setStartFromScratch] = useState(false);
|
||||
const { user } = useContext(AuthContext);
|
||||
const hasDebugPermission = user?.permissions?.includes("admin:debug") ?? false;
|
||||
|
||||
// Fetch initial field options from the API
|
||||
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
|
||||
@@ -271,7 +421,7 @@ export function Import() {
|
||||
|
||||
const handleData = async (data: ImportResult, _file: File, submitOptions: SubmitOptions) => {
|
||||
try {
|
||||
const rows = (data.all?.length ? data.all : data.validData) ?? [];
|
||||
const rows = ((data.all?.length ? data.all : data.validData) ?? []) as Data<string>[];
|
||||
const formattedRows: NormalizedProduct[] = rows.map((row) => {
|
||||
const baseValues = importFields.reduce((acc, field) => {
|
||||
const rawRow = row as Record<string, DataValue>;
|
||||
@@ -306,7 +456,13 @@ export function Import() {
|
||||
throw new Error(response.message || "Failed to submit products");
|
||||
}
|
||||
|
||||
setImportedData(formattedRows);
|
||||
setResumeStepState(undefined);
|
||||
setImportOutcome({
|
||||
submittedProducts: formattedRows.map((product) => ({ ...product })),
|
||||
submittedRows: rows.map((row) => ({ ...row })),
|
||||
response,
|
||||
});
|
||||
setIsDebugDataVisible(false);
|
||||
setIsOpen(false);
|
||||
|
||||
const successMessage = response.message
|
||||
@@ -320,6 +476,123 @@ export function Import() {
|
||||
}
|
||||
};
|
||||
|
||||
const backendPayload = importOutcome ? extractBackendPayload(importOutcome.response.data) : { created: [], errored: [] };
|
||||
|
||||
const createdProducts = importOutcome
|
||||
? backendPayload.created.map((entry) => {
|
||||
const productIndex = findSubmittedProductIndex(importOutcome.submittedProducts, entry);
|
||||
const matchedProduct =
|
||||
productIndex >= 0 ? importOutcome.submittedProducts[productIndex] : undefined;
|
||||
const responseUpc = normalizeIdentifierValue(entry.upc ?? entry.UPC);
|
||||
const responseItemNumber = normalizeIdentifierValue(entry.itemnumber ?? entry.item_number ?? entry.itemNumber);
|
||||
const productUpc = normalizeIdentifierValue(getFirstStringValue(matchedProduct ? matchedProduct["upc"] : null));
|
||||
const productItemNumber = normalizeIdentifierValue(getFirstStringValue(matchedProduct ? matchedProduct["item_number"] : null));
|
||||
const productName = getFirstStringValue(matchedProduct ? matchedProduct["name"] : null);
|
||||
const imageUrl = matchedProduct ? getImageUrlFromValue(matchedProduct["product_images"]) : null;
|
||||
const pidValue = normalizeIdentifierValue(entry.pid);
|
||||
const fallbackLabel = responseItemNumber ?? responseUpc ?? (pidValue ? `PID ${pidValue}` : null);
|
||||
|
||||
return {
|
||||
name: productName ?? fallbackLabel ?? "Imported product",
|
||||
imageUrl,
|
||||
upc: productUpc ?? responseUpc ?? "—",
|
||||
itemNumber: productItemNumber ?? responseItemNumber ?? "—",
|
||||
url: pidValue ? `https://backend.acherryontop.com/product/${pidValue}` : null,
|
||||
pid: pidValue,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const erroredProducts = importOutcome
|
||||
? backendPayload.errored.map((entry) => {
|
||||
const productIndex = findSubmittedProductIndex(importOutcome.submittedProducts, entry);
|
||||
const matchedProduct =
|
||||
productIndex >= 0 ? importOutcome.submittedProducts[productIndex] : undefined;
|
||||
const responseUpc = normalizeIdentifierValue(entry.upc ?? entry.UPC);
|
||||
const responseItemNumber = normalizeIdentifierValue(entry.itemnumber ?? entry.item_number ?? entry.itemNumber);
|
||||
const productUpc = normalizeIdentifierValue(getFirstStringValue(matchedProduct ? matchedProduct["upc"] : null));
|
||||
const productItemNumber = normalizeIdentifierValue(getFirstStringValue(matchedProduct ? matchedProduct["item_number"] : null));
|
||||
const productName = getFirstStringValue(matchedProduct ? matchedProduct["name"] : null);
|
||||
const imageUrl = matchedProduct ? getImageUrlFromValue(matchedProduct["product_images"]) : null;
|
||||
const fallbackLabel = responseItemNumber ?? responseUpc ?? "Imported product";
|
||||
|
||||
return {
|
||||
name: productName ?? fallbackLabel,
|
||||
imageUrl,
|
||||
upc: productUpc ?? responseUpc ?? "—",
|
||||
itemNumber: productItemNumber ?? responseItemNumber ?? "—",
|
||||
errorDetails: formatErrorDetails(entry),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const erroredRowsForEditing = importOutcome
|
||||
? backendPayload.errored
|
||||
.map((entry) => {
|
||||
const productIndex = findSubmittedProductIndex(importOutcome.submittedProducts, entry);
|
||||
if (productIndex >= 0) {
|
||||
const matchedRow = importOutcome.submittedRows[productIndex];
|
||||
if (matchedRow) {
|
||||
return { ...matchedRow } as Data<string>;
|
||||
}
|
||||
}
|
||||
|
||||
const matchedProduct =
|
||||
productIndex >= 0 ? importOutcome.submittedProducts[productIndex] : undefined;
|
||||
const fallbackData: Record<string, DataValue> = {};
|
||||
const fallbackUpc = normalizeIdentifierValue(entry.upc ?? entry.UPC);
|
||||
const fallbackItemNumber = normalizeIdentifierValue(entry.itemnumber ?? entry.item_number ?? entry.itemNumber);
|
||||
|
||||
if (fallbackUpc) {
|
||||
fallbackData["upc"] = fallbackUpc;
|
||||
}
|
||||
|
||||
if (fallbackItemNumber) {
|
||||
fallbackData["item_number"] = fallbackItemNumber;
|
||||
}
|
||||
|
||||
if (matchedProduct) {
|
||||
const fallbackName = getFirstStringValue(matchedProduct["name"]);
|
||||
if (fallbackName) {
|
||||
fallbackData["name"] = fallbackName;
|
||||
}
|
||||
|
||||
const productImages = matchedProduct["product_images"];
|
||||
if (Array.isArray(productImages) && productImages.length) {
|
||||
fallbackData["product_images"] = productImages;
|
||||
} else if (typeof productImages === "string" && productImages.trim().length) {
|
||||
fallbackData["product_images"] = productImages;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(fallbackData).length ? (fallbackData as Data<string>) : null;
|
||||
})
|
||||
.filter((row): row is Data<string> => row !== null)
|
||||
: [];
|
||||
|
||||
const hasErroredRowsForEditing = erroredRowsForEditing.length > 0;
|
||||
|
||||
const totalSubmitted = importOutcome?.submittedProducts.length ?? 0;
|
||||
const defaultSummary =
|
||||
totalSubmitted > 0
|
||||
? `Submitted ${totalSubmitted} product${totalSubmitted === 1 ? "" : "s"} successfully`
|
||||
: undefined;
|
||||
const summaryMessage = importOutcome?.response.message ?? defaultSummary;
|
||||
|
||||
const handleResumeErroredProducts = () => {
|
||||
if (!importOutcome || !hasErroredRowsForEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResumeStepState({
|
||||
type: StepType.validateData,
|
||||
data: erroredRowsForEditing.map((row) => ({ ...row })),
|
||||
});
|
||||
setIsDebugDataVisible(false);
|
||||
setStartFromScratch(false);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
if (isLoadingOptions) {
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
@@ -355,21 +628,141 @@ export function Import() {
|
||||
<CardTitle>Import Data</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Button onClick={() => setIsOpen(true)} className="w-full">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setResumeStepState(undefined);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Begin Import
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{importedData && (
|
||||
{importOutcome && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview Imported Data</CardTitle>
|
||||
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>Import Results</CardTitle>
|
||||
{summaryMessage && <CardDescription>{summaryMessage}</CardDescription>}
|
||||
</div>
|
||||
{hasDebugPermission && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsDebugDataVisible((prev) => !prev)}
|
||||
>
|
||||
{isDebugDataVisible ? "Hide Submitted JSON" : "Show Submitted JSON"}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Code className="p-4 w-full rounded-md border whitespace-pre-wrap">
|
||||
{JSON.stringify(importedData, null, 2)}
|
||||
</Code>
|
||||
<CardContent className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Created {createdProducts.length} of {totalSubmitted} product{totalSubmitted === 1 ? "" : "s"}.
|
||||
{erroredProducts.length > 0
|
||||
? ` ${erroredProducts.length} product${erroredProducts.length === 1 ? "" : "s"} need attention.`
|
||||
: ""}
|
||||
</p>
|
||||
{erroredProducts.length > 0 && hasErroredRowsForEditing && (
|
||||
<Button size="sm" onClick={handleResumeErroredProducts}>
|
||||
Fix errored products
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{createdProducts.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold">Created Products</h3>
|
||||
<div className="grid gap-3">
|
||||
{createdProducts.map((product, index) => {
|
||||
const key = product.pid ?? product.upc ?? product.itemNumber ?? index;
|
||||
const imageContent = product.imageUrl ? (
|
||||
<img src={product.imageUrl} alt={product.name} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
|
||||
No Image
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-start gap-4 rounded-md border p-4">
|
||||
{product.url ? (
|
||||
<a
|
||||
href={product.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block h-16 w-16 shrink-0 overflow-hidden rounded-md border bg-muted"
|
||||
>
|
||||
{imageContent}
|
||||
</a>
|
||||
) : (
|
||||
<div className="block h-16 w-16 shrink-0 overflow-hidden rounded-md border bg-muted">
|
||||
{imageContent}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{product.url ? (
|
||||
<a
|
||||
href={product.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{product.name}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-medium">{product.name}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{product.pid ? `PID: ${product.pid} · ` : ""}
|
||||
UPC: {product.upc}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Item #: {product.itemNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{erroredProducts.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold text-destructive">Errored Products</h3>
|
||||
<div className="grid gap-3">
|
||||
{erroredProducts.map((product, index) => (
|
||||
<div key={product.upc ?? product.itemNumber ?? index} className="flex items-start gap-4 rounded-md border border-destructive/40 p-4">
|
||||
<div className="block h-16 w-16 shrink-0 overflow-hidden rounded-md border bg-muted">
|
||||
{product.imageUrl ? (
|
||||
<img src={product.imageUrl} alt={product.name} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">{product.name}</span>
|
||||
<span className="text-xs text-muted-foreground">UPC: {product.upc}</span>
|
||||
<span className="text-xs text-muted-foreground">Item #: {product.itemNumber}</span>
|
||||
{product.errorDetails && (
|
||||
<span className="text-xs text-destructive">{product.errorDetails}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDebugPermission && isDebugDataVisible && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Submitted Payload</h3>
|
||||
<Code className="p-4 w-full rounded-md border whitespace-pre-wrap">
|
||||
{JSON.stringify(importOutcome.submittedProducts, null, 2)}
|
||||
</Code>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -379,11 +772,17 @@ export function Import() {
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
setStartFromScratch(false);
|
||||
setResumeStepState(undefined);
|
||||
}}
|
||||
onSubmit={handleData}
|
||||
fields={importFields}
|
||||
isNavigationEnabled={true}
|
||||
initialStepState={startFromScratch ? { type: StepType.validateData, data: [{}] } : undefined}
|
||||
initialStepState={
|
||||
resumeStepState ??
|
||||
(startFromScratch
|
||||
? { type: StepType.validateData, data: [{}], isFromScratch: true }
|
||||
: undefined)
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user