// src/components/AddProductDialog.tsx import { useCallback, useEffect, useMemo, useState } from 'react'; import { Alert, AlertColor, Button, Dialog, DialogContent, DialogTitle, IconButton, MenuItem, Stack, TextField, } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; import { v4 as uuidv4 } from 'uuid'; import { useDatabase } from '../context/DBProvider'; import { useProducts } from '../hooks/dataHooks'; import { BarcodeScannerView } from './BarcodeScannerView'; import { fetchProductFromOFF } from '../modules/openFoodFacts'; import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE, Product } from '../models/Product'; import type { BackdropProps } from '@mui/material/Backdrop'; import type { FormHelperTextProps } from '@mui/material/FormHelperText'; type WithTestId = Partial & { 'data-testid'?: string }; export type AddProductDialogProps = { open: boolean; onClose: () => void; categoryOptions: string[]; onFeedback?: (feedback: { text: string; severity: AlertColor }) => void; initialBarcode?: string | null; }; export const AddProductDialog = ({ open, onClose, categoryOptions, onFeedback, initialBarcode, }: AddProductDialogProps) => { const db = useDatabase(); const products = useProducts(); const [name, setName] = useState(''); const [category, setCategory] = useState(''); const [barcode, setBarcode] = useState(''); const [barcodeError, setBarcodeError] = useState(''); const [nameError, setNameError] = useState(''); const [scannerOpen, setScannerOpen] = useState(false); const [lookupStatus, setLookupStatus] = useState<'idle' | 'loading' | 'found' | 'notfound' | 'offline'>('idle'); const resetForm = useCallback(() => { setName(''); setCategory(''); setBarcode(''); setBarcodeError(''); setNameError(''); setLookupStatus('idle'); setScannerOpen(false); }, []); const categoryMap = useMemo(() => new Map(categoryOptions.map((c) => [c, c])), [categoryOptions]); const findBarcodeConflict = useCallback( async (value?: string) => { if (!value) return undefined; const conflict = products.find((product) => product.barcode === value); if (conflict) return conflict; const dbConflict = await db.products.where('barcode').equals(value).first(); return dbConflict ?? undefined; }, [db.products, products], ); const findNameConflict = useCallback( async (value?: string) => { if (!value) return undefined; const normalizedValue = value.trim().toLowerCase(); const conflict = products.find((product) => product.name.trim().toLowerCase() === normalizedValue); if (conflict) return conflict; const dbConflict = await db.products .filter((product) => product.name.trim().toLowerCase() === normalizedValue) .first(); return dbConflict ?? undefined; }, [db.products, products], ); const assertUniqueBarcode = useCallback( async (value?: string) => { if (!value) return; const conflict = await findBarcodeConflict(value); if (conflict) { const error = new Error('This barcode is already assigned to another product.'); error.name = 'DuplicateBarcodeError'; throw error; } }, [findBarcodeConflict], ); const assertUniqueName = useCallback( async (value: string) => { const normalized = value.trim().toLowerCase(); if (!normalized) return; const conflict = await findNameConflict(value); if (conflict) { const error = new Error('A product with this name already exists.'); error.name = 'DuplicateNameError'; throw error; } }, [findNameConflict], ); const addProductToAutoLists = useCallback( async (product: Product, timestamp: number) => { const pickLists = await db.pickLists.toArray(); const categoriesAll = await db.categories.toArray(); const categoriesByName = new Map(categoriesAll.map((c) => [c.name, c.id])); const eligibleLists = pickLists.filter((pickList) => pickList.auto_add_new_products && Array.isArray(pickList.categories) ? pickList.categories.some((catRef: string) => { if (catRef === product.category) return true; const resolvedId = categoriesByName.get(catRef); return resolvedId === product.category; }) : false, ); if (eligibleLists.length === 0) return; await Promise.all( eligibleLists.map(async (pickList) => { const existing = await db.pickItems .where('pick_list_id') .equals(pickList.id) .filter((item) => item.product_id === product.id) .first(); if (existing) return undefined; return db.pickItems.add({ id: uuidv4(), pick_list_id: pickList.id, product_id: product.id, quantity: 1, is_carton: false, status: 'pending', created_at: timestamp, updated_at: timestamp, }); }), ); }, [db.pickItems, db.pickLists, db.categories], ); const lookupBarcode = useCallback(async (code: string) => { if (!code) return; if (typeof navigator !== 'undefined' && 'onLine' in navigator && navigator.onLine === false) { setLookupStatus('offline'); return; } setLookupStatus('loading'); const result = await fetchProductFromOFF(code); if (result) { setLookupStatus('found'); if (result.name) { setName(result.name || ''); } } else { setLookupStatus('notfound'); } }, []); useEffect(() => { if (open) { if (categoryOptions.length > 0 && !categoryOptions.includes(category)) { setCategory(categoryOptions[0]); } if (initialBarcode) { setBarcode(initialBarcode); void lookupBarcode(initialBarcode); } } else { resetForm(); } }, [category, categoryOptions, initialBarcode, lookupBarcode, open, resetForm]); useEffect(() => { if (!open || !barcode) return; void lookupBarcode(barcode); }, [barcode, lookupBarcode, open]); useEffect(() => { if (!barcode) { setLookupStatus('idle'); } setBarcodeError(''); }, [barcode]); const handleSubmit = async () => { setNameError(''); setBarcodeError(''); if (!name || !category) { onFeedback?.({ text: 'Name and category are required.', severity: 'error' }); return; } const timestamp = Date.now(); const productId = uuidv4(); try { await assertUniqueName(name); await assertUniqueBarcode(barcode); await db.transaction('rw', db.categories, db.products, db.pickLists, db.pickItems, async () => { let categoryIdToSave: string; const existingCategory = await db.categories.where('name').equals(category).first(); if (existingCategory) { categoryIdToSave = existingCategory.id; } else { const newCatId = uuidv4(); const now = Date.now(); await db.categories.add({ id: newCatId, name: category, created_at: now, updated_at: now }); categoryIdToSave = newCatId; } const newProduct: Product = { id: productId, name: name.trim(), category: categoryIdToSave, unit_type: DEFAULT_UNIT_TYPE, bulk_name: DEFAULT_BULK_NAME, barcode: barcode || undefined, archived: false, created_at: timestamp, updated_at: timestamp, }; await db.products.add(newProduct); await addProductToAutoLists(newProduct, timestamp); }); onFeedback?.({ text: 'Product added.', severity: 'success' }); resetForm(); onClose(); } catch (err: unknown) { if (err instanceof Error && err.name === 'DuplicateNameError') { setNameError(err.message || 'A product with this name already exists.'); return; } if (err instanceof Error && err.name === 'DuplicateBarcodeError') { setBarcodeError(err.message || 'This barcode is already assigned to another product.'); return; } const fallbackMessage = err instanceof Error ? err.message : String(err); onFeedback?.({ text: `Failed to add product: ${fallbackMessage}`, severity: 'error' }); } }; const handleDialogClose = () => { resetForm(); onClose(); }; const transitionProps = process.env.NODE_ENV === 'test' ? { timeout: 0 } : undefined; return ( } > Add product setName(e.target.value)} error={!!nameError} helperText={nameError || ' '} fullWidth data-testid="select-add-product-category" /> setCategory(e.target.value)} select fullWidth> {categoryOptions.map((opt) => ( {opt} ))} setBarcode(e.target.value)} inputProps={{ 'data-testid': 'product-barcode-input' }} error={!!barcodeError} helperText={barcodeError || ' '} FormHelperTextProps={{ 'data-testid': 'barcode-error' } as WithTestId} fullWidth /> {lookupStatus === 'offline' ? ( You are offline. Enter details manually. ) : null} setScannerOpen(false)} aria-label="Scan barcode" data-testid="scan-barcode-dialog" BackdropProps={{ 'data-testid': 'scan-barcode-backdrop' } as WithTestId} > Scan barcode { setScannerOpen(false); setBarcode(code); try { await lookupBarcode(code); } catch { // lookupBarcode handles errors } }} /> ); }; export default AddProductDialog;