diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js index 34b732c..b7dbee7 100644 --- a/inventory-server/src/routes/import.js +++ b/inventory-server/src/routes/import.js @@ -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; @@ -1149,4 +1298,4 @@ router.get('/product-categories/:pid', async (req, res) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationCell.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationCell.tsx index d315b5c..1dc375a 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationCell.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationCell.tsx @@ -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 editingCells: Set setEditingCells: React.Dispatch>> } @@ -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) => { + 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 ( ) : (
+ {showGenerateButton && ( + + + + + + +

{tooltipMessage}

+
+
+
+ )}
)} @@ -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 || diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationContainer.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationContainer.tsx index 1a4c034..28c24a2 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationContainer.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationContainer.tsx @@ -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 = ({ } } + if ((key === 'upc' || key === 'barcode') && value !== undefined && value !== null) { + const { corrected } = correctUpcValue(value); + processedValue = corrected; + } + return processedValue; }, []); @@ -575,14 +581,20 @@ const ValidationContainer = ({ 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); + } } } }); diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationTable.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationTable.tsx index cce156e..9690096 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationTable.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/components/ValidationTable.tsx @@ -438,6 +438,7 @@ const ValidationTable = ({ rowIndex={row.index} copyDown={(endRowIndex?: number) => handleCopyDown(row.index, field.key as string, endRowIndex)} totalRows={data.length} + rowData={row.original as Record} editingCells={editingCells} setEditingCells={setEditingCells} /> diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useUpcValidation.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useUpcValidation.tsx index 56e79de..8967479 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useUpcValidation.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useUpcValidation.tsx @@ -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; // Using rowIndex-fieldKey as identifier @@ -9,8 +10,9 @@ interface ValidationState { } export const useUpcValidation = ( - data: any[], - setData: (updater: any[] | ((prevData: any[]) => any[])) => void + data: any[], + setData: (updater: any[] | ((prevData: any[]) => any[])) => void, + setValidationErrors: Dispatch>>> ) => { // Use a ref for validation state to avoid triggering re-renders const validationStateRef = useRef({ @@ -84,6 +86,53 @@ export const useUpcValidation = ( setValidatingRows(new Set(validationStateRef.current.validatingRows)); }, 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; + 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) => { @@ -116,28 +165,47 @@ export const useUpcValidation = ( try { 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(); - - if (!data.success) { - return { error: true, message: data.message || 'Unknown error' }; + + const data = payload; + + if (!data?.success) { + return { + error: true, + code: 'invalid_response', + message: data?.message || 'Unknown error' + }; } - - return { - error: false, - data: { + + return { + error: false, + data: { itemNumber: data.itemNumber || '', ...data } @@ -205,45 +273,51 @@ 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}`); - - // 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); - + updateItemNumber(rowIndex, product.data.itemNumber); + + 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}`); - + // Clear any existing item number to show validation was attempted and failed 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 }; } } catch (error) { @@ -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(() => { diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidationState.tsx b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidationState.tsx index 09c0f60..26fde5d 100644 --- a/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidationState.tsx +++ b/inventory/src/components/product-import/steps/ValidationStepNew/hooks/useValidationState.tsx @@ -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 = ({ initialData, @@ -81,6 +82,20 @@ export const useValidationState = ({ } } + 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; }); }); @@ -119,7 +134,7 @@ export const useValidationState = ({ ); // 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( diff --git a/inventory/src/components/product-import/steps/ValidationStepNew/utils/upcUtils.ts b/inventory/src/components/product-import/steps/ValidationStepNew/utils/upcUtils.ts new file mode 100644 index 0000000..7f76818 --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStepNew/utils/upcUtils.ts @@ -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 }; +} diff --git a/inventory/src/pages/Import.tsx b/inventory/src/pages/Import.tsx index c45c96a..15aff1d 100644 --- a/inventory/src/pages/Import.tsx +++ b/inventory/src/pages/Import.tsx @@ -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; +type ImportResult = Result & { all?: Result["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[]; + response: SubmitNewProductsResponse; +} + +const isRecord = (value: unknown): value is Record => + 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).created), + errored: toList((data as Record).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).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; - type ImportResult = Result & { all?: Result["validData"] }; - - const [importedData, setImportedData] = useState(null); + const [importOutcome, setImportOutcome] = useState(null); + const [isDebugDataVisible, setIsDebugDataVisible] = useState(false); + const [resumeStepState, setResumeStepState] = useState(); const [selectedCompany, setSelectedCompany] = useState(null); const [selectedLine, setSelectedLine] = useState(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[]; const formattedRows: NormalizedProduct[] = rows.map((row) => { const baseValues = importFields.reduce((acc, field) => { const rawRow = row as Record; @@ -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; + } + } + + const matchedProduct = + productIndex >= 0 ? importOutcome.submittedProducts[productIndex] : undefined; + const fallbackData: Record = {}; + 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) : null; + }) + .filter((row): row is Data => 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 (
@@ -355,21 +628,141 @@ export function Import() { Import Data - - {importedData && ( + {importOutcome && ( - - Preview Imported Data + +
+ Import Results + {summaryMessage && {summaryMessage}} +
+ {hasDebugPermission && ( + + )}
- - - {JSON.stringify(importedData, null, 2)} - + +

+ Created {createdProducts.length} of {totalSubmitted} product{totalSubmitted === 1 ? "" : "s"}. + {erroredProducts.length > 0 + ? ` ${erroredProducts.length} product${erroredProducts.length === 1 ? "" : "s"} need attention.` + : ""} +

+ {erroredProducts.length > 0 && hasErroredRowsForEditing && ( + + )} + + {createdProducts.length > 0 && ( +
+

Created Products

+
+ {createdProducts.map((product, index) => { + const key = product.pid ?? product.upc ?? product.itemNumber ?? index; + const imageContent = product.imageUrl ? ( + {product.name} + ) : ( +
+ No Image +
+ ); + + return ( +
+ {product.url ? ( + + {imageContent} + + ) : ( +
+ {imageContent} +
+ )} +
+ {product.url ? ( + + {product.name} + + ) : ( + {product.name} + )} + + {product.pid ? `PID: ${product.pid} · ` : ""} + UPC: {product.upc} + + Item #: {product.itemNumber} +
+
+ ); + })} +
+
+ )} + + {erroredProducts.length > 0 && ( +
+

Errored Products

+
+ {erroredProducts.map((product, index) => ( +
+
+ {product.imageUrl ? ( + {product.name} + ) : ( +
+ No Image +
+ )} +
+
+ {product.name} + UPC: {product.upc} + Item #: {product.itemNumber} + {product.errorDetails && ( + {product.errorDetails} + )} +
+
+ ))} +
+
+ )} + + {hasDebugPermission && isDebugDataVisible && ( +
+

Submitted Payload

+ + {JSON.stringify(importOutcome.submittedProducts, null, 2)} + +
+ )}
)} @@ -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) + } /> );