Merge remote-tracking branch 'origin/codex/refactor-add-product-feature-to-popup'
This commit is contained in:
@@ -0,0 +1,339 @@
|
|||||||
|
// 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 { BarcodeScannerView } from './BarcodeScannerView';
|
||||||
|
import { ExternalProductInfo, fetchProductFromOFF } from '../modules/openFoodFacts';
|
||||||
|
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE, Product } from '../models/Product';
|
||||||
|
|
||||||
|
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 [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 [externalProduct, setExternalProduct] = useState<ExternalProductInfo | null>(null);
|
||||||
|
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
|
setName('');
|
||||||
|
setCategory('');
|
||||||
|
setBarcode('');
|
||||||
|
setBarcodeError('');
|
||||||
|
setNameError('');
|
||||||
|
setLookupStatus('idle');
|
||||||
|
setExternalProduct(null);
|
||||||
|
setScannerOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (categoryOptions.length > 0 && !categoryOptions.includes(category)) {
|
||||||
|
setCategory(categoryOptions[0]);
|
||||||
|
}
|
||||||
|
if (initialBarcode) {
|
||||||
|
setBarcode(initialBarcode);
|
||||||
|
void lookupBarcode(initialBarcode);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, categoryOptions, initialBarcode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!barcode) {
|
||||||
|
setLookupStatus('idle');
|
||||||
|
setExternalProduct(null);
|
||||||
|
}
|
||||||
|
setBarcodeError('');
|
||||||
|
}, [barcode]);
|
||||||
|
|
||||||
|
const categoryMap = useMemo(() => new Map(categoryOptions.map((c) => [c, c])), [categoryOptions]);
|
||||||
|
|
||||||
|
const findBarcodeConflict = useCallback(
|
||||||
|
async (value?: string) => {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const conflict = await db.products.where('barcode').equals(value).first();
|
||||||
|
return conflict ?? undefined;
|
||||||
|
},
|
||||||
|
[db.products],
|
||||||
|
);
|
||||||
|
|
||||||
|
const findNameConflict = useCallback(
|
||||||
|
async (value?: string) => {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const normalizedValue = value.trim().toLowerCase();
|
||||||
|
const conflict = await db.products.filter((product) => product.name.trim().toLowerCase() === normalizedValue).first();
|
||||||
|
return conflict ?? undefined;
|
||||||
|
},
|
||||||
|
[db.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],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function lookupBarcode(code: string) {
|
||||||
|
if (!code) return;
|
||||||
|
if (typeof navigator !== 'undefined' && 'onLine' in navigator && !navigator.onLine) {
|
||||||
|
setLookupStatus('offline');
|
||||||
|
setExternalProduct(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLookupStatus('loading');
|
||||||
|
const result = await fetchProductFromOFF(code);
|
||||||
|
if (result) {
|
||||||
|
setExternalProduct(result);
|
||||||
|
setLookupStatus('found');
|
||||||
|
if (result.name) {
|
||||||
|
setName(result.name || '');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setExternalProduct(null);
|
||||||
|
setLookupStatus('notfound');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: any) {
|
||||||
|
if (err?.name === 'DuplicateNameError') {
|
||||||
|
setNameError(err.message || 'A product with this name already exists.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (err?.name === 'DuplicateBarcodeError') {
|
||||||
|
setBarcodeError(err.message || 'This barcode is already assigned to another product.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onFeedback?.({ text: `Failed to add product: ${err?.message ?? String(err)}`, severity: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleDialogClose} aria-label="Add product dialog" fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle sx={{ pr: 6 }}>
|
||||||
|
Add product
|
||||||
|
<IconButton
|
||||||
|
aria-label="Close add product"
|
||||||
|
onClick={handleDialogClose}
|
||||||
|
sx={{ position: 'absolute', right: 8, top: 8 }}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} mt={1}>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
error={!!nameError}
|
||||||
|
helperText={nameError || ' '}
|
||||||
|
fullWidth
|
||||||
|
data-testid="select-add-product-category"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField label="Category" value={category} onChange={(e) => setCategory(e.target.value)} select fullWidth>
|
||||||
|
{categoryOptions.map((opt) => (
|
||||||
|
<MenuItem key={opt} value={categoryMap.get(opt) ?? opt}>
|
||||||
|
{opt}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<TextField
|
||||||
|
label="Barcode"
|
||||||
|
value={barcode}
|
||||||
|
onChange={(e) => setBarcode(e.target.value)}
|
||||||
|
inputProps={{ 'data-testid': 'product-barcode-input' }}
|
||||||
|
error={!!barcodeError}
|
||||||
|
helperText={barcodeError || ' '}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Button onClick={() => setScannerOpen(true)}>Scan barcode</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{lookupStatus === 'offline' ? (
|
||||||
|
<Alert severity="warning" data-testid="barcode-offline">
|
||||||
|
You are offline. Enter details manually.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} justifyContent="flex-end">
|
||||||
|
<Button onClick={handleDialogClose} variant="outlined">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" onClick={handleSubmit} disabled={!name || !category}>
|
||||||
|
Save product
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<Dialog open={scannerOpen} onClose={() => setScannerOpen(false)} aria-label="Scan barcode">
|
||||||
|
<DialogTitle>Scan barcode</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<BarcodeScannerView
|
||||||
|
onDetected={async (code) => {
|
||||||
|
setScannerOpen(false);
|
||||||
|
setBarcode(code);
|
||||||
|
try {
|
||||||
|
await lookupBarcode(code);
|
||||||
|
} catch {
|
||||||
|
// lookupBarcode handles errors
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddProductDialog;
|
||||||
@@ -131,6 +131,14 @@ beforeEach(() => {
|
|||||||
|
|
||||||
mockDb.categories.toArray.mockResolvedValue([]);
|
mockDb.categories.toArray.mockResolvedValue([]);
|
||||||
mockDb.categories.where.mockImplementation(() => ({ equals: () => ({ first: async () => undefined }) }));
|
mockDb.categories.where.mockImplementation(() => ({ equals: () => ({ first: async () => undefined }) }));
|
||||||
|
|
||||||
|
mockDb.products.filter.mockImplementation((predicate?: (product: any) => boolean) => ({
|
||||||
|
first: async () => {
|
||||||
|
const items = mockUseProducts();
|
||||||
|
return predicate ? items.find((item: any) => predicate(item)) : undefined;
|
||||||
|
},
|
||||||
|
delete: vi.fn(),
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
function findSaveButton() {
|
function findSaveButton() {
|
||||||
@@ -145,6 +153,10 @@ function findSaveButton() {
|
|||||||
return allButtons.length ? allButtons[0] : null;
|
return allButtons.length ? allButtons[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openAddProductDialog(user: ReturnType<typeof userEvent.setup>) {
|
||||||
|
await user.click(screen.getByRole('button', { name: /add product/i }));
|
||||||
|
}
|
||||||
|
|
||||||
describe('ManageProductsScreen barcode lookup', () => {
|
describe('ManageProductsScreen barcode lookup', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockUseProducts.mockReturnValue([]);
|
mockUseProducts.mockReturnValue([]);
|
||||||
@@ -170,6 +182,8 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await openAddProductDialog(user);
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /scan barcode/i }));
|
await user.click(screen.getByRole('button', { name: /scan barcode/i }));
|
||||||
await user.click(screen.getByRole('button', { name: /mock scan/i }));
|
await user.click(screen.getByRole('button', { name: /mock scan/i }));
|
||||||
|
|
||||||
@@ -200,6 +214,8 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await openAddProductDialog(user);
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/name/i), 'New Product');
|
await user.type(screen.getByLabelText(/name/i), 'New Product');
|
||||||
await user.click(screen.getByRole('button', { name: /scan barcode/i }));
|
await user.click(screen.getByRole('button', { name: /scan barcode/i }));
|
||||||
await user.click(screen.getByRole('button', { name: /mock scan/i }));
|
await user.click(screen.getByRole('button', { name: /mock scan/i }));
|
||||||
@@ -235,6 +251,8 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await openAddProductDialog(user);
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/name/i), 'existing product');
|
await user.type(screen.getByLabelText(/name/i), 'existing product');
|
||||||
|
|
||||||
const saveBtn = findSaveButton();
|
const saveBtn = findSaveButton();
|
||||||
@@ -260,6 +278,8 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await openAddProductDialog(user);
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: /scan barcode/i }));
|
await user.click(screen.getByRole('button', { name: /scan barcode/i }));
|
||||||
await user.click(screen.getByRole('button', { name: /mock scan/i }));
|
await user.click(screen.getByRole('button', { name: /mock scan/i }));
|
||||||
|
|
||||||
@@ -272,6 +292,34 @@ describe('ManageProductsScreen barcode lookup', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('closes the add product dialog with the close icon and backdrop', async () => {
|
||||||
|
mockUseProducts.mockReturnValue([]);
|
||||||
|
mockUseCategories.mockReturnValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ManageProductsScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await openAddProductDialog(user);
|
||||||
|
await user.click(screen.getByRole('button', { name: /close add product/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('dialog', { name: /add product dialog/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await openAddProductDialog(user);
|
||||||
|
const backdrop = document.querySelector('[role="presentation"]');
|
||||||
|
expect(backdrop).toBeTruthy();
|
||||||
|
await user.click(backdrop as HTMLElement);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('dialog', { name: /add product dialog/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('prevents updating a product to use an existing barcode', async () => {
|
it('prevents updating a product to use an existing barcode', async () => {
|
||||||
mockUseProducts.mockReturnValue([
|
mockUseProducts.mockReturnValue([
|
||||||
{
|
{
|
||||||
@@ -438,6 +486,8 @@ describe('ManageProductsScreen auto-adding products to pick lists', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await openAddProductDialog(user);
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/name/i), 'Granola Bar');
|
await user.type(screen.getByLabelText(/name/i), 'Granola Bar');
|
||||||
const saveBtn = findSaveButton();
|
const saveBtn = findSaveButton();
|
||||||
expect(saveBtn).toBeTruthy();
|
expect(saveBtn).toBeTruthy();
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import {
|
|||||||
AlertColor,
|
AlertColor,
|
||||||
Button,
|
Button,
|
||||||
Container,
|
Container,
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Snackbar,
|
Snackbar,
|
||||||
@@ -21,9 +18,8 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import { ProductRow } from '../components/ProductRow';
|
import { ProductRow } from '../components/ProductRow';
|
||||||
import { useCategories, useProducts } from '../hooks/dataHooks';
|
import { useCategories, useProducts } from '../hooks/dataHooks';
|
||||||
import { useDatabase } from '../context/DBProvider';
|
import { useDatabase } from '../context/DBProvider';
|
||||||
import { BarcodeScannerView } from '../components/BarcodeScannerView';
|
|
||||||
import { ExternalProductInfo, fetchProductFromOFF } from '../modules/openFoodFacts';
|
|
||||||
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE, Product } from '../models/Product';
|
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE, Product } from '../models/Product';
|
||||||
|
import { AddProductDialog } from '../components/AddProductDialog';
|
||||||
|
|
||||||
const ManageProductsScreen = () => {
|
const ManageProductsScreen = () => {
|
||||||
const db = useDatabase();
|
const db = useDatabase();
|
||||||
@@ -32,15 +28,9 @@ const ManageProductsScreen = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||||
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 [externalProduct, setExternalProduct] = useState<ExternalProductInfo | null>(null);
|
|
||||||
const [feedback, setFeedback] = useState<{ text: string; severity: AlertColor } | null>(null);
|
const [feedback, setFeedback] = useState<{ text: string; severity: AlertColor } | null>(null);
|
||||||
|
const [addProductDialogOpen, setAddProductDialogOpen] = useState(false);
|
||||||
|
const [pendingBarcode, setPendingBarcode] = useState<string | null>(null);
|
||||||
|
|
||||||
// Map category id -> name
|
// Map category id -> name
|
||||||
const categoriesById = useMemo(() => new Map(categories.map((c) => [c.id, c.name])), [categories]);
|
const categoriesById = useMemo(() => new Map(categories.map((c) => [c.id, c.name])), [categories]);
|
||||||
@@ -94,55 +84,6 @@ const ManageProductsScreen = () => {
|
|||||||
[findNameConflict],
|
[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) => {
|
|
||||||
// catRef can be an id or a name — resolve both
|
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (categoryOptions.length === 0) return;
|
|
||||||
if (!categoryOptions.includes(category)) {
|
|
||||||
setCategory(categoryOptions[0]);
|
|
||||||
}
|
|
||||||
}, [category, categoryOptions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedCategory !== 'all' && !categoryOptions.includes(selectedCategory)) {
|
if (selectedCategory !== 'all' && !categoryOptions.includes(selectedCategory)) {
|
||||||
setSelectedCategory('all');
|
setSelectedCategory('all');
|
||||||
@@ -168,113 +109,12 @@ const ManageProductsScreen = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const state = location.state as { newBarcode?: string } | null;
|
const state = location.state as { newBarcode?: string } | null;
|
||||||
if (state?.newBarcode) {
|
if (state?.newBarcode) {
|
||||||
setBarcode(state.newBarcode);
|
setPendingBarcode(state.newBarcode);
|
||||||
void lookupBarcode(state.newBarcode);
|
setAddProductDialogOpen(true);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [location.state]);
|
}, [location.state]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!barcode) {
|
|
||||||
setLookupStatus('idle');
|
|
||||||
setExternalProduct(null);
|
|
||||||
}
|
|
||||||
setBarcodeError('');
|
|
||||||
}, [barcode]);
|
|
||||||
|
|
||||||
async function lookupBarcode(code: string) {
|
|
||||||
if (!code) return;
|
|
||||||
if (typeof navigator !== 'undefined' && 'onLine' in navigator && !navigator.onLine) {
|
|
||||||
setLookupStatus('offline');
|
|
||||||
setExternalProduct(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLookupStatus('loading');
|
|
||||||
const result = await fetchProductFromOFF(code);
|
|
||||||
if (result) {
|
|
||||||
setExternalProduct(result);
|
|
||||||
setLookupStatus('found');
|
|
||||||
// TEST-FRIENDLY CHANGE: always set the name when a result is found
|
|
||||||
if (result.name) {
|
|
||||||
setName(result.name || '');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setExternalProduct(null);
|
|
||||||
setLookupStatus('notfound');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addProduct = async () => {
|
|
||||||
setNameError('');
|
|
||||||
setBarcodeError('');
|
|
||||||
|
|
||||||
if (!name || !category) {
|
|
||||||
setFeedback({ 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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
setName('');
|
|
||||||
setBarcode('');
|
|
||||||
setNameError('');
|
|
||||||
setBarcodeError('');
|
|
||||||
setFeedback({ text: 'Product added.', severity: 'success' });
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to add product', err);
|
|
||||||
if (err?.name === 'DuplicateNameError') {
|
|
||||||
setNameError(err.message || 'A product with this name already exists.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (err?.name === 'DuplicateBarcodeError') {
|
|
||||||
setBarcodeError(err.message || 'This barcode is already assigned to another product.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFeedback({ text: `Failed to add product: ${err?.message ?? String(err)}`, severity: 'error' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateProduct = async (
|
const updateProduct = async (
|
||||||
productId: string,
|
productId: string,
|
||||||
updates: {
|
updates: {
|
||||||
@@ -283,9 +123,6 @@ const ManageProductsScreen = () => {
|
|||||||
barcode?: string;
|
barcode?: string;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
setNameError('');
|
|
||||||
setBarcodeError('');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await assertUniqueName(updates.name, productId);
|
await assertUniqueName(updates.name, productId);
|
||||||
await assertUniqueBarcode(updates.barcode, productId);
|
await assertUniqueBarcode(updates.barcode, productId);
|
||||||
@@ -338,13 +175,7 @@ const ManageProductsScreen = () => {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to update product', err);
|
console.error('Failed to update product', err);
|
||||||
|
|
||||||
if (err?.name === 'DuplicateNameError') {
|
if (err?.name === 'DuplicateNameError' || err?.name === 'DuplicateBarcodeError') {
|
||||||
// keep parent-level state for visibility, but re-throw so ProductRow can set field errors
|
|
||||||
setNameError(err.message || 'A product with this name already exists.');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
if (err?.name === 'DuplicateBarcodeError') {
|
|
||||||
setBarcodeError(err.message || 'This barcode is already assigned to another product.');
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,96 +194,64 @@ const ManageProductsScreen = () => {
|
|||||||
setFeedback({ text: 'Product deleted.', severity: 'success' });
|
setFeedback({ text: 'Product deleted.', severity: 'success' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddProductClose = () => {
|
||||||
|
setAddProductDialogOpen(false);
|
||||||
|
setPendingBarcode(null);
|
||||||
|
};
|
||||||
|
|
||||||
// ---------- RENDER ----------
|
// ---------- RENDER ----------
|
||||||
return (
|
return (
|
||||||
<Container sx={{ py: 4 }}>
|
<Container sx={{ py: 4 }}>
|
||||||
<Stack spacing={2} mb={2}>
|
<Stack spacing={2} mb={2}>
|
||||||
<Typography variant="h5">Manage Products</Typography>
|
<Typography variant="h5">Manage Products</Typography>
|
||||||
|
|
||||||
<Stack spacing={1}>
|
<Stack
|
||||||
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
|
spacing={2}
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems={{ xs: 'stretch', sm: 'center' }}
|
||||||
|
>
|
||||||
<Button component={RouterLink} to="/categories" variant="outlined">
|
<Button component={RouterLink} to="/categories" variant="outlined">
|
||||||
Edit Categories
|
Edit Categories
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
<Stack direction="row" spacing={2}>
|
variant="contained"
|
||||||
<TextField
|
onClick={() => setAddProductDialogOpen(true)}
|
||||||
placeholder="Search"
|
sx={{ alignSelf: { xs: 'stretch', sm: 'flex-start' } }}
|
||||||
value={search}
|
>
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
Add product
|
||||||
InputProps={{
|
</Button>
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">
|
|
||||||
<SearchIcon />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
select
|
|
||||||
label="Filter by category"
|
|
||||||
value={selectedCategory}
|
|
||||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
|
||||||
sx={{ minWidth: 200 }}
|
|
||||||
data-testid="select-filter-by-category"
|
|
||||||
>
|
|
||||||
<MenuItem value="all">All categories</MenuItem>
|
|
||||||
{categoryOptions.map((opt) => (
|
|
||||||
<MenuItem key={opt} value={opt}>
|
|
||||||
{opt}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack spacing={1}>
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Name"
|
placeholder="Search"
|
||||||
value={name}
|
value={search}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
error={!!nameError}
|
InputProps={{
|
||||||
data-testid="select-add-product-category"
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
/>
|
/>
|
||||||
{nameError ? <div data-testid="name-error">{nameError}</div> : null}
|
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Category"
|
|
||||||
value={category}
|
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
|
||||||
select
|
select
|
||||||
|
label="Filter by category"
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
|
sx={{ minWidth: 200 }}
|
||||||
|
data-testid="select-filter-by-category"
|
||||||
>
|
>
|
||||||
|
<MenuItem value="all">All categories</MenuItem>
|
||||||
{categoryOptions.map((opt) => (
|
{categoryOptions.map((opt) => (
|
||||||
<MenuItem key={opt} value={opt}>
|
<MenuItem key={opt} value={opt}>
|
||||||
{opt}
|
{opt}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
|
||||||
<Stack direction="row" spacing={1} alignItems="center">
|
|
||||||
<TextField
|
|
||||||
label="Barcode"
|
|
||||||
value={barcode}
|
|
||||||
onChange={(e) => setBarcode(e.target.value)}
|
|
||||||
inputProps={{ 'data-testid': 'product-barcode-input' }}
|
|
||||||
error={!!barcodeError}
|
|
||||||
/>
|
|
||||||
<Button onClick={() => setScannerOpen(true)}>Scan barcode</Button>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{barcodeError ? <div data-testid="barcode-error">{barcodeError}</div> : null}
|
|
||||||
|
|
||||||
{lookupStatus === 'offline' ? (
|
|
||||||
<Alert severity="warning" data-testid="barcode-offline">
|
|
||||||
You are offline. Enter details manually.
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
<Button variant="contained" onClick={addProduct} disabled={!name || !category}>
|
|
||||||
Save product
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -469,22 +268,13 @@ const ManageProductsScreen = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={scannerOpen} onClose={() => setScannerOpen(false)} aria-label="Scan barcode">
|
<AddProductDialog
|
||||||
<DialogTitle>Scan barcode</DialogTitle>
|
open={addProductDialogOpen}
|
||||||
<DialogContent>
|
onClose={handleAddProductClose}
|
||||||
<BarcodeScannerView
|
categoryOptions={categoryOptions}
|
||||||
onDetected={async (code) => {
|
onFeedback={setFeedback}
|
||||||
setScannerOpen(false);
|
initialBarcode={pendingBarcode}
|
||||||
setBarcode(code);
|
/>
|
||||||
try {
|
|
||||||
await lookupBarcode(code);
|
|
||||||
} catch {
|
|
||||||
// lookupBarcode handles errors
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Snackbar open={!!feedback} autoHideDuration={3000} onClose={() => setFeedback(null)}>
|
<Snackbar open={!!feedback} autoHideDuration={3000} onClose={() => setFeedback(null)}>
|
||||||
{feedback ? <Alert severity={feedback.severity}>{feedback.text}</Alert> : undefined}
|
{feedback ? <Alert severity={feedback.severity}>{feedback.text}</Alert> : undefined}
|
||||||
|
|||||||
Reference in New Issue
Block a user