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
|
// Endpoint to check UPC and generate item number
|
||||||
router.get('/check-upc-and-generate-sku', async (req, res) => {
|
router.get('/check-upc-and-generate-sku', async (req, res) => {
|
||||||
const { upc, supplierId } = req.query;
|
const { upc, supplierId } = req.query;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Field, ErrorType } from '../../../types'
|
import { Field, ErrorType } from '../../../types'
|
||||||
import { AlertCircle, ArrowDown, X } from 'lucide-react'
|
import { AlertCircle, ArrowDown, Wand2, X } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -12,6 +12,8 @@ import SelectCell from './cells/SelectCell'
|
|||||||
import MultiSelectCell from './cells/MultiSelectCell'
|
import MultiSelectCell from './cells/MultiSelectCell'
|
||||||
import { TableCell } from '@/components/ui/table'
|
import { TableCell } from '@/components/ui/table'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { useToast } from '@/hooks/use-toast'
|
||||||
|
import config from '@/config'
|
||||||
|
|
||||||
// Context for copy down selection mode
|
// Context for copy down selection mode
|
||||||
export const CopyDownContext = React.createContext<{
|
export const CopyDownContext = React.createContext<{
|
||||||
@@ -203,6 +205,7 @@ export interface ValidationCellProps {
|
|||||||
rowIndex: number
|
rowIndex: number
|
||||||
copyDown?: (endRowIndex?: number) => void
|
copyDown?: (endRowIndex?: number) => void
|
||||||
totalRows?: number
|
totalRows?: number
|
||||||
|
rowData: Record<string, any>
|
||||||
editingCells: Set<string>
|
editingCells: Set<string>
|
||||||
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
|
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||||
}
|
}
|
||||||
@@ -303,11 +306,14 @@ const ValidationCell = React.memo(({
|
|||||||
copyDown,
|
copyDown,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
totalRows = 0,
|
totalRows = 0,
|
||||||
// editingCells not used; keep setEditingCells for API compatibility
|
rowData,
|
||||||
|
editingCells,
|
||||||
setEditingCells
|
setEditingCells
|
||||||
}: ValidationCellProps) => {
|
}: ValidationCellProps) => {
|
||||||
// Use the CopyDown context
|
// Use the CopyDown context
|
||||||
const copyDownContext = React.useContext(CopyDownContext);
|
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
|
// 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
|
// 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
|
// PERFORMANCE FIX: Create cell key for editing state management
|
||||||
const cellKey = `${rowIndex}-${fieldKey}`;
|
const cellKey = `${rowIndex}-${fieldKey}`;
|
||||||
|
const isEditingCell = editingCells.has(cellKey);
|
||||||
|
|
||||||
// SINGLE-CLICK EDITING FIX: Create editing state management functions
|
// SINGLE-CLICK EDITING FIX: Create editing state management functions
|
||||||
const handleStartEdit = React.useCallback(() => {
|
const handleStartEdit = React.useCallback(() => {
|
||||||
@@ -353,9 +360,6 @@ const ValidationCell = React.memo(({
|
|||||||
});
|
});
|
||||||
}, [setEditingCells, cellKey]);
|
}, [setEditingCells, cellKey]);
|
||||||
|
|
||||||
// Force isValidating to be a boolean
|
|
||||||
const isLoading = isValidating === true;
|
|
||||||
|
|
||||||
// Handle copy down button click
|
// Handle copy down button click
|
||||||
const handleCopyDownClick = React.useCallback(() => {
|
const handleCopyDownClick = React.useCallback(() => {
|
||||||
if (copyDown && totalRows > rowIndex + 1) {
|
if (copyDown && totalRows > rowIndex + 1) {
|
||||||
@@ -404,6 +408,91 @@ const ValidationCell = React.memo(({
|
|||||||
return '';
|
return '';
|
||||||
}, [isSourceCell, isSelectedTarget, isInTargetRow]);
|
}, [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 (
|
return (
|
||||||
<TableCell
|
<TableCell
|
||||||
className="p-1 group relative"
|
className="p-1 group relative"
|
||||||
@@ -472,7 +561,7 @@ const ValidationCell = React.memo(({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`truncate overflow-hidden ${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? 'bg-blue-50/50' : ''}`}
|
className={containerClassName}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isSourceCell ? '#dbeafe' :
|
backgroundColor: isSourceCell ? '#dbeafe' :
|
||||||
isSelectedTarget ? '#bfdbfe' :
|
isSelectedTarget ? '#bfdbfe' :
|
||||||
@@ -488,11 +577,32 @@ const ValidationCell = React.memo(({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
hasErrors={hasError || isRequiredButEmpty}
|
hasErrors={hasError || isRequiredButEmpty}
|
||||||
options={options}
|
options={options}
|
||||||
className={cellClassName}
|
className={cellClassNameWithPadding}
|
||||||
fieldKey={fieldKey}
|
fieldKey={fieldKey}
|
||||||
onStartEdit={handleStartEdit}
|
onStartEdit={handleStartEdit}
|
||||||
onEndEdit={handleEndEdit}
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -518,6 +628,9 @@ const ValidationCell = React.memo(({
|
|||||||
// Check field identity
|
// Check field identity
|
||||||
if (prevProps.field !== nextProps.field) return false;
|
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
|
// Shallow options comparison - only if field type is select or multi-select
|
||||||
if (prevProps.field.fieldType?.type === 'select' || prevProps.field.fieldType?.type === 'multi-select') {
|
if (prevProps.field.fieldType?.type === 'select' || prevProps.field.fieldType?.type === 'multi-select') {
|
||||||
const optionsEqual = prevProps.options === nextProps.options ||
|
const optionsEqual = prevProps.options === nextProps.options ||
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import { Protected } from '@/components/auth/Protected'
|
import { Protected } from '@/components/auth/Protected'
|
||||||
import { normalizeCountryCode } from '../utils/countryUtils'
|
import { normalizeCountryCode } from '../utils/countryUtils'
|
||||||
import { cleanPriceField } from '../utils/priceUtils'
|
import { cleanPriceField } from '../utils/priceUtils'
|
||||||
|
import { correctUpcValue } from '../utils/upcUtils'
|
||||||
import InitializingValidation from './InitializingValidation'
|
import InitializingValidation from './InitializingValidation'
|
||||||
/**
|
/**
|
||||||
* ValidationContainer component - the main wrapper for the validation step
|
* 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;
|
return processedValue;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -575,14 +581,20 @@ const ValidationContainer = <T extends string>({
|
|||||||
if (key === 'supplier' && value) {
|
if (key === 'supplier' && value) {
|
||||||
const upcValue = (data[rowIndex] as any)?.upc || (data[rowIndex] as any)?.barcode;
|
const upcValue = (data[rowIndex] as any)?.upc || (data[rowIndex] as any)?.barcode;
|
||||||
if (upcValue) {
|
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;
|
const supplier = (data[rowIndex] as any)?.supplier;
|
||||||
if (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}
|
rowIndex={row.index}
|
||||||
copyDown={(endRowIndex?: number) => handleCopyDown(row.index, field.key as string, endRowIndex)}
|
copyDown={(endRowIndex?: number) => handleCopyDown(row.index, field.key as string, endRowIndex)}
|
||||||
totalRows={data.length}
|
totalRows={data.length}
|
||||||
|
rowData={row.original as Record<string, any>}
|
||||||
editingCells={editingCells}
|
editingCells={editingCells}
|
||||||
setEditingCells={setEditingCells}
|
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 config from '@/config'
|
||||||
|
import { ErrorSources, ErrorType, ValidationError } from '../../../types'
|
||||||
|
|
||||||
interface ValidationState {
|
interface ValidationState {
|
||||||
validatingCells: Set<string>; // Using rowIndex-fieldKey as identifier
|
validatingCells: Set<string>; // Using rowIndex-fieldKey as identifier
|
||||||
@@ -10,7 +11,8 @@ interface ValidationState {
|
|||||||
|
|
||||||
export const useUpcValidation = (
|
export const useUpcValidation = (
|
||||||
data: any[],
|
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
|
// Use a ref for validation state to avoid triggering re-renders
|
||||||
const validationStateRef = useRef<ValidationState>({
|
const validationStateRef = useRef<ValidationState>({
|
||||||
@@ -85,6 +87,53 @@ export const useUpcValidation = (
|
|||||||
}, 0);
|
}, 0);
|
||||||
}, [setData]);
|
}, [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
|
// Mark a row as no longer being validated
|
||||||
const stopValidatingRow = useCallback((rowIndex: number) => {
|
const stopValidatingRow = useCallback((rowIndex: number) => {
|
||||||
validationStateRef.current.validatingRows.delete(rowIndex);
|
validationStateRef.current.validatingRows.delete(rowIndex);
|
||||||
@@ -117,22 +166,41 @@ export const useUpcValidation = (
|
|||||||
console.log(`Fetching product for UPC ${upcValue} with supplier ${supplierId}`);
|
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)}`);
|
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) {
|
if (response.status === 409) {
|
||||||
console.log(`UPC ${upcValue} already exists`);
|
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) {
|
if (!response.ok) {
|
||||||
console.error(`API error: ${response.status}`);
|
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 = payload;
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data?.success) {
|
||||||
return { error: true, message: data.message || 'Unknown error' };
|
return {
|
||||||
|
error: true,
|
||||||
|
code: 'invalid_response',
|
||||||
|
message: data?.message || 'Unknown error'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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 }
|
// 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) {
|
if (product && !product.error && product.data?.itemNumber) {
|
||||||
console.log(`[UPC-DEBUG] Got item number for row ${rowIndex}: ${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
|
clearUpcUniqueError(rowIndex);
|
||||||
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);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
itemNumber: product.data.itemNumber
|
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 {
|
} else {
|
||||||
// No item number found but validation was still attempted
|
// No item number found but validation was still attempted
|
||||||
console.log(`[UPC-DEBUG] No item number found for UPC ${upcValue}`);
|
console.log(`[UPC-DEBUG] No item number found for UPC ${upcValue}`);
|
||||||
@@ -242,6 +315,7 @@ export const useUpcValidation = (
|
|||||||
if (validationStateRef.current.itemNumbers.has(rowIndex)) {
|
if (validationStateRef.current.itemNumbers.has(rowIndex)) {
|
||||||
validationStateRef.current.itemNumbers.delete(rowIndex);
|
validationStateRef.current.itemNumbers.delete(rowIndex);
|
||||||
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
|
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
|
||||||
|
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false };
|
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 rows: ${Array.from(validationStateRef.current.validatingRows).join(', ')}`);
|
||||||
console.log(`[UPC-DEBUG] Updated validating cells: ${Array.from(validationStateRef.current.validatingCells).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
|
// Apply all pending item numbers to the data state
|
||||||
const applyItemNumbersToData = useCallback((callback?: (updatedRows: number[]) => void) => {
|
const applyItemNumbersToData = useCallback((callback?: (updatedRows: number[]) => void) => {
|
||||||
@@ -415,8 +489,31 @@ export const useUpcValidation = (
|
|||||||
// Update item number
|
// Update item number
|
||||||
updateItemNumber(index, itemNumber);
|
updateItemNumber(index, itemNumber);
|
||||||
batchUpdatedRows.push(index);
|
batchUpdatedRows.push(index);
|
||||||
|
clearUpcUniqueError(index);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`No item number found for row ${index} UPC ${upcValue}`);
|
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) {
|
} catch (error) {
|
||||||
console.error(`Error validating row ${index}:`, error);
|
console.error(`Error validating row ${index}:`, error);
|
||||||
@@ -452,7 +549,18 @@ export const useUpcValidation = (
|
|||||||
|
|
||||||
console.log('Completed initial UPC validation');
|
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
|
// Run initial UPC validation when data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useInitialValidation } from "./useInitialValidation";
|
|||||||
import { Props, RowData } from "./validationTypes";
|
import { Props, RowData } from "./validationTypes";
|
||||||
import { normalizeCountryCode } from "../utils/countryUtils";
|
import { normalizeCountryCode } from "../utils/countryUtils";
|
||||||
import { cleanPriceField } from "../utils/priceUtils";
|
import { cleanPriceField } from "../utils/priceUtils";
|
||||||
|
import { correctUpcValue } from "../utils/upcUtils";
|
||||||
|
|
||||||
export const useValidationState = <T extends string>({
|
export const useValidationState = <T extends string>({
|
||||||
initialData,
|
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>;
|
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
|
// 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
|
// Use unique item numbers validation hook
|
||||||
const { validateUniqueItemNumbers } = useUniqueItemNumbersValidation<T>(
|
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 { useState, useContext } from "react";
|
||||||
import { ReactSpreadsheetImport, StepType } from "@/components/product-import";
|
import { ReactSpreadsheetImport, StepType } from "@/components/product-import";
|
||||||
|
import type { StepState } from "@/components/product-import/steps/UploadFlow";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Code } from "@/components/ui/code";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
import { Loader2 } from "lucide-react";
|
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 { 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";
|
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() {
|
export function Import() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
type NormalizedProduct = Record<ImportFieldKey | "product_images", string | string[] | boolean | null>;
|
const [importOutcome, setImportOutcome] = useState<ImportOutcome | null>(null);
|
||||||
type ImportResult = Result<string> & { all?: Result<string>["validData"] };
|
const [isDebugDataVisible, setIsDebugDataVisible] = useState(false);
|
||||||
|
const [resumeStepState, setResumeStepState] = useState<StepState | undefined>();
|
||||||
const [importedData, setImportedData] = useState<NormalizedProduct[] | null>(null);
|
|
||||||
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
|
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
|
||||||
const [selectedLine, setSelectedLine] = useState<string | null>(null);
|
const [selectedLine, setSelectedLine] = useState<string | null>(null);
|
||||||
const [startFromScratch, setStartFromScratch] = useState(false);
|
const [startFromScratch, setStartFromScratch] = useState(false);
|
||||||
const { user } = useContext(AuthContext);
|
const { user } = useContext(AuthContext);
|
||||||
|
const hasDebugPermission = user?.permissions?.includes("admin:debug") ?? false;
|
||||||
|
|
||||||
// Fetch initial field options from the API
|
// Fetch initial field options from the API
|
||||||
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
|
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
|
||||||
@@ -271,7 +421,7 @@ export function Import() {
|
|||||||
|
|
||||||
const handleData = async (data: ImportResult, _file: File, submitOptions: SubmitOptions) => {
|
const handleData = async (data: ImportResult, _file: File, submitOptions: SubmitOptions) => {
|
||||||
try {
|
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 formattedRows: NormalizedProduct[] = rows.map((row) => {
|
||||||
const baseValues = importFields.reduce((acc, field) => {
|
const baseValues = importFields.reduce((acc, field) => {
|
||||||
const rawRow = row as Record<string, DataValue>;
|
const rawRow = row as Record<string, DataValue>;
|
||||||
@@ -306,7 +456,13 @@ export function Import() {
|
|||||||
throw new Error(response.message || "Failed to submit products");
|
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);
|
setIsOpen(false);
|
||||||
|
|
||||||
const successMessage = response.message
|
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) {
|
if (isLoadingOptions) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-6">
|
<div className="container mx-auto py-6">
|
||||||
@@ -355,21 +628,141 @@ export function Import() {
|
|||||||
<CardTitle>Import Data</CardTitle>
|
<CardTitle>Import Data</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<Button onClick={() => setIsOpen(true)} className="w-full">
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setResumeStepState(undefined);
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
Begin Import
|
Begin Import
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{importedData && (
|
{importOutcome && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<CardTitle>Preview Imported Data</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-6">
|
||||||
<Code className="p-4 w-full rounded-md border whitespace-pre-wrap">
|
<p className="text-sm text-muted-foreground">
|
||||||
{JSON.stringify(importedData, null, 2)}
|
Created {createdProducts.length} of {totalSubmitted} product{totalSubmitted === 1 ? "" : "s"}.
|
||||||
</Code>
|
{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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -379,11 +772,17 @@ export function Import() {
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setStartFromScratch(false);
|
setStartFromScratch(false);
|
||||||
|
setResumeStepState(undefined);
|
||||||
}}
|
}}
|
||||||
onSubmit={handleData}
|
onSubmit={handleData}
|
||||||
fields={importFields}
|
fields={importFields}
|
||||||
isNavigationEnabled={true}
|
isNavigationEnabled={true}
|
||||||
initialStepState={startFromScratch ? { type: StepType.validateData, data: [{}] } : undefined}
|
initialStepState={
|
||||||
|
resumeStepState ??
|
||||||
|
(startFromScratch
|
||||||
|
? { type: StepType.validateData, data: [{}], isFromScratch: true }
|
||||||
|
: undefined)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user