modified: public/templates/products_template.csv

modified:   src/components/ProductRow.test.tsx
	modified:   src/components/ProductRow.tsx
	modified:   src/screens/ImportExportScreen.tsx
	modified:   src/screens/ManageProductsScreen.tsx
	modified:   src/services/importExportService.ts
This commit is contained in:
2025-11-25 11:00:12 +10:00
parent ed923b9363
commit f9a093a39d
6 changed files with 224 additions and 448 deletions
+4 -2
View File
@@ -1,2 +1,4 @@
id,name,category,unit_type,bulk_name,barcode,archived,created_at,updated_at name,barcode,category
"Blue T-Shirt","Clothing","unit","carton","0123456789012",false,, Running Shorts,0123456789,Clothing
Crop Top,9876543210,Clothing
Yoga Pants,,Bottoms
1 id name barcode category unit_type bulk_name archived created_at updated_at
2 Blue T-Shirt Clothing Running Shorts false 0123456789 unit Clothing carton 0123456789012
3 Crop Top 9876543210 Clothing
4 Yoga Pants Bottoms
+15 -8
View File
@@ -17,12 +17,21 @@ const product: Product = {
const categories = ['Drinks', 'Snacks']; const categories = ['Drinks', 'Snacks'];
// Provide a categoriesById Map for the new required prop.
// The test product uses category 'Drinks' (a name), so mapping name->name is fine.
// If you had an ID in the product, this map could be id->name.
const categoriesById = new Map<string, string>([
['Drinks', 'Drinks'],
['Snacks', 'Snacks'],
]);
describe('ProductRow', () => { describe('ProductRow', () => {
it('shows name, category, and barcode without unit text in read-only mode', () => { it('shows name, category, and barcode without unit text in read-only mode', () => {
render( render(
<ProductRow <ProductRow
product={product} product={product}
categories={categories} categories={categories}
categoriesById={categoriesById}
onSave={vi.fn()} onSave={vi.fn()}
onDelete={vi.fn()} onDelete={vi.fn()}
/>, />,
@@ -39,7 +48,7 @@ describe('ProductRow', () => {
const onSave = vi.fn(); const onSave = vi.fn();
render( render(
<ProductRow product={product} categories={categories} onSave={onSave} onDelete={vi.fn()} />, <ProductRow product={product} categories={categories} categoriesById={categoriesById} onSave={onSave} onDelete={vi.fn()} />,
); );
await user.click(screen.getByLabelText(/edit sparkling water/i)); await user.click(screen.getByLabelText(/edit sparkling water/i));
@@ -59,12 +68,12 @@ describe('ProductRow', () => {
it('surfaces validation errors from duplicate constraints', async () => { it('surfaces validation errors from duplicate constraints', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onSave = vi.fn().mockRejectedValueOnce(Object.assign(new Error('dup'), { name: 'DuplicateNameError' })) const onSave = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error('dup'), { name: 'DuplicateNameError' }))
.mockRejectedValueOnce(Object.assign(new Error('dup'), { name: 'DuplicateBarcodeError' })); .mockRejectedValueOnce(Object.assign(new Error('dup'), { name: 'DuplicateBarcodeError' }));
render( render(<ProductRow product={product} categories={categories} categoriesById={categoriesById} onSave={onSave} onDelete={vi.fn()} />);
<ProductRow product={product} categories={categories} onSave={onSave} onDelete={vi.fn()} />,
);
await user.click(screen.getByLabelText(/edit sparkling water/i)); await user.click(screen.getByLabelText(/edit sparkling water/i));
await user.clear(screen.getByLabelText(/name/i)); await user.clear(screen.getByLabelText(/name/i));
@@ -82,9 +91,7 @@ describe('ProductRow', () => {
it('allows clearing and scanning a new barcode', async () => { it('allows clearing and scanning a new barcode', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(<ProductRow product={product} categories={categories} categoriesById={categoriesById} onSave={vi.fn()} onDelete={vi.fn()} />);
<ProductRow product={product} categories={categories} onSave={vi.fn()} onDelete={vi.fn()} />,
);
await user.click(screen.getByLabelText(/edit sparkling water/i)); await user.click(screen.getByLabelText(/edit sparkling water/i));
await user.click(screen.getByRole('button', { name: /clear/i })); await user.click(screen.getByRole('button', { name: /clear/i }));
+16 -23
View File
@@ -21,12 +21,15 @@ import { BarcodeScannerView } from './BarcodeScannerView';
interface ProductRowProps { interface ProductRowProps {
product: Product; product: Product;
// list of category display names for the select
categories: string[]; categories: string[];
// map of category id -> category name, used to resolve ids to names
categoriesById: Map<string, string>;
onSave: ( onSave: (
productId: string, productId: string,
updates: { updates: {
name: string; name: string;
category: string; category: string; // this is the *name* when passed back to parent
barcode?: string; barcode?: string;
}, },
) => Promise<void> | void; ) => Promise<void> | void;
@@ -39,22 +42,23 @@ interface ProductFormState {
barcode: string; barcode: string;
} }
const getInitialFormState = (product: Product): ProductFormState => ({ const getInitialFormState = (product: Product, categoriesById: Map<string, string>): ProductFormState => ({
name: product.name, name: product.name,
category: product.category, // If product.category is an id, resolve to name; otherwise assume it is already a name
category: categoriesById.get(product.category) ?? product.category ?? '',
barcode: product.barcode ?? '', barcode: product.barcode ?? '',
}); });
export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRowProps) => { export const ProductRow = ({ product, categories, categoriesById, onSave, onDelete }: ProductRowProps) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [formState, setFormState] = useState<ProductFormState>(() => getInitialFormState(product)); const [formState, setFormState] = useState<ProductFormState>(() => getInitialFormState(product, categoriesById));
const [isScannerOpen, setIsScannerOpen] = useState(false); const [isScannerOpen, setIsScannerOpen] = useState(false);
const [fieldErrors, setFieldErrors] = useState<{ name?: string; barcode?: string }>({}); const [fieldErrors, setFieldErrors] = useState<{ name?: string; barcode?: string }>({});
useEffect(() => { useEffect(() => {
setFormState(getInitialFormState(product)); setFormState(getInitialFormState(product, categoriesById));
setFieldErrors({}); setFieldErrors({});
}, [product]); }, [product, categoriesById]);
const handleChange = (field: keyof ProductFormState) => (event: ChangeEvent<HTMLInputElement>) => { const handleChange = (field: keyof ProductFormState) => (event: ChangeEvent<HTMLInputElement>) => {
setFormState((prev) => ({ ...prev, [field]: event.target.value })); setFormState((prev) => ({ ...prev, [field]: event.target.value }));
@@ -86,7 +90,7 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
const handleCancel = () => { const handleCancel = () => {
setIsEditing(false); setIsEditing(false);
setFormState(getInitialFormState(product)); setFormState(getInitialFormState(product, categoriesById));
setFieldErrors({}); setFieldErrors({});
}; };
@@ -103,14 +107,7 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
error={Boolean(fieldErrors.name)} error={Boolean(fieldErrors.name)}
helperText={fieldErrors.name || undefined} helperText={fieldErrors.name || undefined}
/> />
<TextField <TextField select SelectProps={{ native: true }} label="Category" value={formState.category} onChange={handleChange('category')} size="small">
select
SelectProps={{ native: true }}
label="Category"
value={formState.category}
onChange={handleChange('category')}
size="small"
>
{categories.map((cat) => ( {categories.map((cat) => (
<option key={cat} value={cat}> <option key={cat} value={cat}>
{cat} {cat}
@@ -145,12 +142,7 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
</Button> </Button>
)} )}
<Stack direction="row" spacing={0.5} justifyContent="flex-end" alignItems="center"> <Stack direction="row" spacing={0.5} justifyContent="flex-end" alignItems="center">
<IconButton <IconButton aria-label={`Delete ${product.name}`} onClick={() => onDelete(product.id)} size="small" color="error">
aria-label={`Delete ${product.name}`}
onClick={() => onDelete(product.id)}
size="small"
color="error"
>
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
<IconButton aria-label="Save product" onClick={handleSave} disabled={!formState.name} color="primary"> <IconButton aria-label="Save product" onClick={handleSave} disabled={!formState.name} color="primary">
@@ -169,7 +161,8 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
{product.name} {product.name}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary" noWrap> <Typography variant="caption" color="text.secondary" noWrap>
{product.category} {/* Resolve id -> name for display */}
{categoriesById.get(product.category) ?? product.category ?? ''}
</Typography> </Typography>
</Stack> </Stack>
{product.barcode ? ( {product.barcode ? (
+17 -71
View File
@@ -3,9 +3,7 @@ import {
Button, Button,
Card, Card,
CardContent, CardContent,
Checkbox,
Divider, Divider,
FormControlLabel,
List, List,
ListItem, ListItem,
ListItemText, ListItemText,
@@ -16,28 +14,13 @@ import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { liveQuery } from 'dexie'; import { liveQuery } from 'dexie';
import { useDatabase } from '../context/DBProvider'; import { useDatabase } from '../context/DBProvider';
import { downloadLog, exportData, importFiles } from '../services/importExportService'; import { downloadLog, exportData, importFiles } from '../services/importExportService';
import { DataType, ImportOptions } from '../services/importExportService'; import { ImportOptions } from '../services/importExportService';
import { ImportExportLog } from '../models/ImportExportLog'; import { ImportExportLog } from '../models/ImportExportLog';
const templateFiles: { label: string; file: string }[] = [ const templateFiles: { label: string; file: string }[] = [{ label: 'Products', file: 'products_template.csv' }];
{ label: 'Areas', file: 'areas_template.csv' },
{ label: 'Categories', file: 'categories_template.csv' },
{ label: 'Products', file: 'products_template.csv' },
{ label: 'Pick Lists', file: 'picklists_template.csv' },
{ label: 'Pick Items', file: 'pickitems_template.csv' },
];
const dataTypes: { key: DataType; label: string }[] = [
{ key: 'areas', label: 'Areas' },
{ key: 'categories', label: 'Categories' },
{ key: 'products', label: 'Products' },
{ key: 'pick-lists', label: 'Pick Lists' },
{ key: 'pick-items', label: 'Pick Items' },
];
export const ImportExportScreen = () => { export const ImportExportScreen = () => {
const db = useDatabase(); const db = useDatabase();
const [selectedTypes, setSelectedTypes] = useState<DataType[]>(dataTypes.map((d) => d.key));
const [logLines, setLogLines] = useState<string[]>([]); const [logLines, setLogLines] = useState<string[]>([]);
const [history, setHistory] = useState<ImportExportLog[]>([]); const [history, setHistory] = useState<ImportExportLog[]>([]);
const [allowAutoCreateMissing, setAllowAutoCreateMissing] = useState(true); const [allowAutoCreateMissing, setAllowAutoCreateMissing] = useState(true);
@@ -52,22 +35,11 @@ export const ImportExportScreen = () => {
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [db]); }, [db]);
const handleToggleType = (key: DataType) => {
setSelectedTypes((prev) => (prev.includes(key) ? prev.filter((item) => item !== key) : [...prev, key]));
};
const handleSelectAll = () => {
if (selectedTypes.length === dataTypes.length) {
setSelectedTypes([]);
} else {
setSelectedTypes(dataTypes.map((d) => d.key));
}
};
const handleExport = async () => { const handleExport = async () => {
setLogLines([]); setLogLines([]);
try { try {
await exportData(db, selectedTypes, appendLog); // always export products only
await exportData(db, ['products'], appendLog);
appendLog('Export complete'); appendLog('Export complete');
} catch (error) { } catch (error) {
appendLog(`Export failed: ${(error as Error).message}`); appendLog(`Export failed: ${(error as Error).message}`);
@@ -130,30 +102,6 @@ export const ImportExportScreen = () => {
</Card> </Card>
</Box> </Box>
<Box sx={{ gridColumn: { xs: '1 / -1', md: 'span 5' } }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Data Types
</Typography>
<Stack spacing={1}>
<FormControlLabel
control={<Checkbox checked={selectedTypes.length === dataTypes.length} onChange={handleSelectAll} />}
label="Select all"
/>
<Divider />
{dataTypes.map((type) => (
<FormControlLabel
key={type.key}
control={<Checkbox checked={selectedTypes.includes(type.key)} onChange={() => handleToggleType(type.key)} />}
label={type.label}
/>
))}
</Stack>
</CardContent>
</Card>
</Box>
<Box sx={{ gridColumn: { xs: '1 / -1', md: 'span 4' } }}> <Box sx={{ gridColumn: { xs: '1 / -1', md: 'span 4' } }}>
<Card> <Card>
<CardContent> <CardContent>
@@ -161,8 +109,8 @@ export const ImportExportScreen = () => {
Actions Actions
</Typography> </Typography>
<Stack spacing={2}> <Stack spacing={2}>
<Button variant="contained" disabled={selectedTypes.length === 0} onClick={handleExport}> <Button variant="contained" onClick={handleExport}>
Export Selected Export Products
</Button> </Button>
<Button variant="outlined" component="label"> <Button variant="outlined" component="label">
Select CSV or ZIP Select CSV or ZIP
@@ -171,15 +119,16 @@ export const ImportExportScreen = () => {
<Button variant="contained" color="secondary" onClick={handleImport}> <Button variant="contained" color="secondary" onClick={handleImport}>
Import Files Import Files
</Button> </Button>
<FormControlLabel <Divider />
control={ <Button
<Checkbox variant="text"
checked={allowAutoCreateMissing} onClick={() => {
onChange={(event) => setAllowAutoCreateMissing(event.target.checked)} setAllowAutoCreateMissing((prev) => !prev);
/> appendLog(`Allow auto-create missing referenced entities: ${!allowAutoCreateMissing}`);
} }}
label="Allow auto-create missing referenced entities" >
/> Allow auto-create missing referenced entities: {allowAutoCreateMissing ? 'On' : 'Off'}
</Button>
</Stack> </Stack>
</CardContent> </CardContent>
</Card> </Card>
@@ -191,10 +140,7 @@ export const ImportExportScreen = () => {
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Log Log
</Typography> </Typography>
<Box <Box component="pre" sx={{ backgroundColor: '#f6f6f6', p: 2, borderRadius: 1, maxHeight: 240, overflow: 'auto' }}>
component="pre"
sx={{ backgroundColor: '#f6f6f6', p: 2, borderRadius: 1, maxHeight: 240, overflow: 'auto' }}
>
{logLines.length === 0 ? 'No log entries yet' : logLines.join('\n')} {logLines.length === 0 ? 'No log entries yet' : logLines.join('\n')}
</Box> </Box>
<Stack direction="row" spacing={1} mt={1}> <Stack direction="row" spacing={1} mt={1}>
+74 -112
View File
@@ -37,59 +37,31 @@ export const ManageProductsScreen = () => {
const [barcodeError, setBarcodeError] = useState(''); const [barcodeError, setBarcodeError] = useState('');
const [nameError, setNameError] = useState(''); const [nameError, setNameError] = useState('');
const [scannerOpen, setScannerOpen] = useState(false); const [scannerOpen, setScannerOpen] = useState(false);
const [lookupStatus, setLookupStatus] = useState<'idle' | 'loading' | 'found' | 'notfound' | 'offline'>( const [lookupStatus, setLookupStatus] = useState<'idle' | 'loading' | 'found' | 'notfound' | 'offline'>('idle');
'idle',
);
const [externalProduct, setExternalProduct] = useState<ExternalProductInfo | null>(null); 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 lookupBarcode = useCallback(async (code: string) => { // Map category id -> name
if (!code) return; const categoriesById = useMemo(() => new Map(categories.map((c) => [c.id, c.name])), [categories]);
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((prev) => prev || result.name || '');
}
} else {
setExternalProduct(null);
setLookupStatus('notfound');
}
}, []);
// category options are display names (union of known category names and product-resolved names)
const categoryOptions = useMemo(() => { const categoryOptions = useMemo(() => {
const categoryNames = categories.map((item) => item.name); const categoryNames = categories.map((item) => item.name);
const productCategories = products.map((product) => product.category); const productCategories = products.map((product) => categoriesById.get(product.category) ?? product.category ?? '');
return Array.from(new Set([...categoryNames, ...productCategories])); return Array.from(new Set([...categoryNames, ...productCategories].filter(Boolean)));
}, [categories, products]); }, [categories, products, categoriesById]);
const findBarcodeConflict = useCallback( const findBarcodeConflict = useCallback(
(value?: string, productId?: string) => (value?: string, productId?: string) =>
value value ? products.find((product) => product.barcode === value && product.id !== productId) : undefined,
? products.find((product) => product.barcode === value && product.id !== productId)
: undefined,
[products], [products],
); );
const findNameConflict = useCallback( const findNameConflict = useCallback(
(value?: string, productId?: string) => { (value?: string, productId?: string) => {
if (!value) return undefined; if (!value) return undefined;
const normalizedValue = value.trim().toLowerCase(); const normalizedValue = value.trim().toLowerCase();
return products.find((product) => product.id !== productId && product.name.trim().toLowerCase() === normalizedValue);
return products.find(
(product) => product.id !== productId && product.name.trim().toLowerCase() === normalizedValue,
);
}, },
[products], [products],
); );
@@ -97,10 +69,7 @@ export const ManageProductsScreen = () => {
const assertUniqueBarcode = useCallback( const assertUniqueBarcode = useCallback(
async (value?: string, productId?: string) => { async (value?: string, productId?: string) => {
if (!value) return; if (!value) return;
const conflict = findBarcodeConflict(value, productId) ?? (await db.products.where('barcode').equals(value).first());
const conflict =
findBarcodeConflict(value, productId) ?? (await db.products.where('barcode').equals(value).first());
if (conflict && conflict.id !== productId) { if (conflict && conflict.id !== productId) {
const error = new Error('This barcode is already assigned to another product.'); const error = new Error('This barcode is already assigned to another product.');
error.name = 'DuplicateBarcodeError'; error.name = 'DuplicateBarcodeError';
@@ -114,9 +83,7 @@ export const ManageProductsScreen = () => {
async (value: string, productId?: string) => { async (value: string, productId?: string) => {
const normalized = value.trim().toLowerCase(); const normalized = value.trim().toLowerCase();
if (!normalized) return; if (!normalized) return;
const conflict = findNameConflict(value, productId); const conflict = findNameConflict(value, productId);
if (conflict) { if (conflict) {
const error = new Error('A product with this name already exists.'); const error = new Error('A product with this name already exists.');
error.name = 'DuplicateNameError'; error.name = 'DuplicateNameError';
@@ -129,15 +96,10 @@ export const ManageProductsScreen = () => {
const addProductToAutoLists = useCallback( const addProductToAutoLists = useCallback(
async (product: Product, timestamp: number) => { async (product: Product, timestamp: number) => {
const pickLists = await db.pickLists.toArray(); const pickLists = await db.pickLists.toArray();
const eligibleLists = pickLists.filter( const eligibleLists = pickLists.filter((pickList) =>
(pickList) => pickList.auto_add_new_products && Array.isArray(pickList.categories) ? pickList.categories.includes(product.category) : false,
pickList.auto_add_new_products && Array.isArray(pickList.categories)
? pickList.categories.includes(product.category)
: false,
); );
if (eligibleLists.length === 0) return; if (eligibleLists.length === 0) return;
await Promise.all( await Promise.all(
eligibleLists.map(async (pickList) => { eligibleLists.map(async (pickList) => {
const existing = await db.pickItems const existing = await db.pickItems
@@ -145,9 +107,7 @@ export const ManageProductsScreen = () => {
.equals(pickList.id) .equals(pickList.id)
.filter((item) => item.product_id === product.id) .filter((item) => item.product_id === product.id)
.first(); .first();
if (existing) return undefined; if (existing) return undefined;
return db.pickItems.add({ return db.pickItems.add({
id: uuidv4(), id: uuidv4(),
pick_list_id: pickList.id, pick_list_id: pickList.id,
@@ -180,13 +140,12 @@ export const ManageProductsScreen = () => {
const filtered = useMemo( const filtered = useMemo(
() => () =>
products.filter((p) => { products.filter((p) => {
const matchesSearch = `${p.name} ${p.category}` const pCategoryName = categoriesById.get(p.category) ?? p.category ?? '';
.toLowerCase() const matchesSearch = `${p.name} ${pCategoryName}`.toLowerCase().includes(search.toLowerCase());
.includes(search.toLowerCase()); const matchesCategory = selectedCategory === 'all' || pCategoryName === selectedCategory;
const matchesCategory = selectedCategory === 'all' || p.category === selectedCategory;
return matchesSearch && matchesCategory; return matchesSearch && matchesCategory;
}), }),
[products, search, selectedCategory], [products, search, selectedCategory, categoriesById],
); );
const sortedFiltered = useMemo( const sortedFiltered = useMemo(
@@ -200,7 +159,8 @@ export const ManageProductsScreen = () => {
setBarcode(state.newBarcode); setBarcode(state.newBarcode);
void lookupBarcode(state.newBarcode); void lookupBarcode(state.newBarcode);
} }
}, [location.state, lookupBarcode]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.state]);
useEffect(() => { useEffect(() => {
if (!barcode) { if (!barcode) {
@@ -210,9 +170,29 @@ export const ManageProductsScreen = () => {
setBarcodeError(''); setBarcodeError('');
}, [barcode]); }, [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');
if (result.name) {
setName((prev) => prev || result.name || '');
}
} else {
setExternalProduct(null);
setLookupStatus('notfound');
}
}
const addProduct = async () => { const addProduct = async () => {
if (!name || !category) return; if (!name || !category) return;
try { try {
await assertUniqueName(name); await assertUniqueName(name);
await assertUniqueBarcode(barcode || undefined); await assertUniqueBarcode(barcode || undefined);
@@ -229,10 +209,15 @@ export const ManageProductsScreen = () => {
} }
const timestamp = Date.now(); const timestamp = Date.now();
const productId = uuidv4(); const productId = uuidv4();
// Resolve selected category name -> id if possible
const chosenCategoryObj = categories.find((c) => c.name === category);
const categoryIdToSave = chosenCategoryObj ? chosenCategoryObj.id : category || '';
const newProduct: Product = { const newProduct: Product = {
id: productId, id: productId,
name, name,
category, category: categoryIdToSave,
unit_type: DEFAULT_UNIT_TYPE, unit_type: DEFAULT_UNIT_TYPE,
bulk_name: DEFAULT_BULK_NAME, bulk_name: DEFAULT_BULK_NAME,
barcode: barcode || undefined, barcode: barcode || undefined,
@@ -265,12 +250,18 @@ export const ManageProductsScreen = () => {
const existing = await db.products.get(productId); const existing = await db.products.get(productId);
if (!existing) return; if (!existing) return;
// Map the provided category name back to the id (if it exists)
let categoryIdToSave = updates.category;
const matchingCategory = categories.find((c) => c.name === updates.category);
if (matchingCategory) categoryIdToSave = matchingCategory.id;
const normalizedName = updates.name.trim(); const normalizedName = updates.name.trim();
const oldNameKey = existing.name.trim().toLowerCase(); const oldNameKey = existing.name.trim().toLowerCase();
const updatedProduct: Product = { const updatedProduct: Product = {
...existing, ...existing,
...updates, ...updates,
name: normalizedName, name: normalizedName,
category: categoryIdToSave,
unit_type: DEFAULT_UNIT_TYPE, unit_type: DEFAULT_UNIT_TYPE,
bulk_name: DEFAULT_BULK_NAME, bulk_name: DEFAULT_BULK_NAME,
updated_at: Date.now(), updated_at: Date.now(),
@@ -302,17 +293,13 @@ export const ManageProductsScreen = () => {
Manage Products Manage Products
</Typography> </Typography>
<Stack spacing={2}> <Stack spacing={2}>
<Snackbar <Snackbar open={Boolean(feedback)} autoHideDuration={4000} onClose={() => setFeedback(null)} anchorOrigin={{ vertical: 'top', horizontal: 'center' }}>
open={Boolean(feedback)}
autoHideDuration={4000}
onClose={() => setFeedback(null)}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
>
{feedback ? <Alert severity={feedback.severity}>{feedback.text}</Alert> : undefined} {feedback ? <Alert severity={feedback.severity}>{feedback.text}</Alert> : undefined}
</Snackbar> </Snackbar>
<Button component={RouterLink} to="/categories" variant="outlined" sx={{ alignSelf: 'flex-start' }}> <Button component={RouterLink} to="/categories" variant="outlined" sx={{ alignSelf: 'flex-start' }}>
Edit Categories Edit Categories
</Button> </Button>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>
<TextField <TextField
placeholder="Search" placeholder="Search"
@@ -337,6 +324,7 @@ export const ManageProductsScreen = () => {
))} ))}
</TextField> </TextField>
</Stack> </Stack>
<Stack spacing={1}> <Stack spacing={1}>
<Typography variant="subtitle1">Add Product</Typography> <Typography variant="subtitle1">Add Product</Typography>
<TextField <TextField
@@ -366,13 +354,7 @@ export const ManageProductsScreen = () => {
: undefined : undefined
} }
/> />
<TextField <TextField select label="Add product category" value={category} onChange={(event) => setCategory(event.target.value)} disabled={categoryOptions.length === 0}>
select
label="Add product category"
value={category}
onChange={(event) => setCategory(event.target.value)}
disabled={categoryOptions.length === 0}
>
{categoryOptions.map((cat) => ( {categoryOptions.map((cat) => (
<MenuItem key={cat} value={cat}> <MenuItem key={cat} value={cat}>
{cat} {cat}
@@ -401,54 +383,34 @@ export const ManageProductsScreen = () => {
Found {externalProduct.name ?? 'product'} via Open Food Facts. Please confirm details. Found {externalProduct.name ?? 'product'} via Open Food Facts. Please confirm details.
</Typography> </Typography>
) : null} ) : null}
{lookupStatus === 'notfound' ? (
<Typography variant="body2" color="text.secondary">
Product not found. Add it manually.
</Typography>
) : null}
{lookupStatus === 'offline' ? (
<Typography variant="body2" color="text.secondary">
You are offline. Enter details manually.
</Typography>
) : null}
</Stack> </Stack>
) : ( ) : (
<Button variant="outlined" onClick={() => setScannerOpen(true)}> <Button variant="outlined" onClick={() => setScannerOpen(true)}>
Scan Barcode Scan Barcode
</Button> </Button>
)} )}
<Button variant="contained" onClick={addProduct} disabled={!name || !category}> <Dialog open={scannerOpen} onClose={() => setScannerOpen(false)} fullWidth>
Save Product <DialogTitle>Scan Barcode</DialogTitle>
<DialogContent>
<BarcodeScannerView
onDetected={(code) => {
setBarcode(code);
setScannerOpen(false);
}}
/>
</DialogContent>
</Dialog>
<Button variant="contained" onClick={() => void addProduct()} disabled={!name || !category}>
Add product
</Button> </Button>
</Stack> </Stack>
{sortedFiltered.length === 0 ? (
<Typography variant="body2" color="text.secondary"> <Stack spacing={1}>
No products match your search and category filter. {sortedFiltered.map((product) => (
</Typography> <ProductRow key={product.id} product={product} categories={categoryOptions} categoriesById={categoriesById} onSave={updateProduct} onDelete={deleteProduct} />
) : ( ))}
sortedFiltered.map((product) => ( </Stack>
<ProductRow
key={product.id}
product={product}
categories={categoryOptions}
onSave={updateProduct}
onDelete={deleteProduct}
/>
))
)}
</Stack> </Stack>
<Dialog open={scannerOpen} onClose={() => setScannerOpen(false)} fullWidth>
<DialogTitle>Scan Barcode</DialogTitle>
<DialogContent>
<BarcodeScannerView
onDetected={(code) => {
setBarcode(code);
void lookupBarcode(code);
setScannerOpen(false);
}}
/>
</DialogContent>
</Dialog>
</Container> </Container>
); );
}; };
+98 -232
View File
@@ -156,23 +156,18 @@ export const exportData = async (
fileEntries.push({ name: 'categories.csv', content: serializeCsv(rows) }); fileEntries.push({ name: 'categories.csv', content: serializeCsv(rows) });
} }
if (selectedTypes.includes('products')) { if (selectedTypes.includes('products')) {
const products = await db.products.toArray(); const products = await db.products.toArray();
const rows = products.map((product) => ({ const rows = products.map((product) => ({
id: product.id, name: product.name,
name: product.name, barcode: product.barcode ?? '',
category: categoriesById.get(product.category) ?? '', category: categoriesById.get(product.category) ?? '',
unit_type: product.unit_type, }));
bulk_name: product.bulk_name, inserted += rows.length;
barcode: product.barcode, addDetail(`Exported ${rows.length} products`);
archived: product.archived, fileEntries.push({ name: 'products.csv', content: serializeCsv(rows) });
created_at: product.created_at, }
updated_at: product.updated_at,
}));
inserted += rows.length;
addDetail(`Exported ${rows.length} products`);
fileEntries.push({ name: 'products.csv', content: serializeCsv(rows) });
}
if (selectedTypes.includes('pick-lists')) { if (selectedTypes.includes('pick-lists')) {
const pickLists = await db.pickLists.toArray(); const pickLists = await db.pickLists.toArray();
@@ -283,18 +278,34 @@ export const importFiles = async (
const parsedRows: Partial<Record<DataType, Record<string, string>[]>> = {}; const parsedRows: Partial<Record<DataType, Record<string, string>[]>> = {};
for (const file of parsedFiles) { let productRows: Record<string, string>[] = [];
for (const file of parsedFiles) {
const rows = await parseCsv(file.content);
addDetail(`Parsed ${rows.length} rows from ${file.name}`);
if (!rows || rows.length === 0) continue;
// Look at headers of first row to decide if it's product-centric
const headerKeys = Object.keys(rows[0]).map(h => h.trim().toLowerCase());
const isProductCentric =
headerKeys.includes('category') && (headerKeys.includes('name') || headerKeys.includes('product_name'));
if (isProductCentric) {
// Collect product rows for product-centric import
productRows.push(...rows);
// record that we detected products (for logging later)
if (!selectedTypes.includes('products')) selectedTypes.push('products');
} else {
// Fallback to filename-based inference for older templates
const type = inferTypeFromName(file.name); const type = inferTypeFromName(file.name);
if (!type) { if (!type) {
addDetail(`Skipped ${file.name}: could not infer data type`); addDetail(`Skipped ${file.name}: not product-centric and could not infer data type`);
continue; continue;
} }
// eslint-disable-next-line no-await-in-loop
const rows = await parseCsv(file.content);
parsedRows[type] = rows; parsedRows[type] = rows;
selectedTypes.push(type); selectedTypes.push(type);
addDetail(`Parsed ${rows.length} rows from ${file.name}`);
} }
}
let inserted = 0; let inserted = 0;
let skipped = 0; let skipped = 0;
@@ -331,223 +342,78 @@ export const importFiles = async (
try { try {
await db.transaction( await db.transaction(
'rw', 'rw',
[db.areas, db.categories, db.products, db.pickLists, db.pickItems], [db.categories, db.products],
async () => { async () => {
const parseTimestamp = (value?: string) => if (productRows.length > 0) {
value && value.trim() !== '' ? Number(value) || now : now; // 1) Make a unique list of category names from CSV (normalized)
const uniqueCategoryNames = new Map<string, string>(); // normalized -> raw
for (const row of productRows) {
const rawCat = (row.category ?? '').trim();
if (!rawCat) continue;
const norm = normalizeName(rawCat);
if (norm && !uniqueCategoryNames.has(norm)) uniqueCategoryNames.set(norm, rawCat);
}
// Areas // 2) Create missing categories in DB
if (parsedRows['areas']) { for (const [norm, raw] of uniqueCategoryNames) {
for (const row of parsedRows['areas']) { if (!categoryNameToId.has(norm)) {
const name = normalizeName(row.name); const newId = uuidv4();
if (!name) { categoryNameToId.set(norm, newId);
addDetail('Skipped area with empty name'); await db.categories.add({
skipped += 1; id: newId,
continue; name: raw,
} created_at: now,
if (areaNameToId.has(name)) { updated_at: now,
addDetail(`Area "${row.name}" exists, skipping`); });
skipped += 1; addDetail(`Created category "${raw}"`);
continue; }
} }
const id = row.id && !(await db.areas.get(row.id)) ? row.id : uuidv4();
const area: Area = {
id,
name: row.name.trim(),
created_at: parseTimestamp(row.created_at),
updated_at: parseTimestamp(row.updated_at),
};
await db.areas.add(area);
areaNameToId.set(name, id);
inserted += 1;
addDetail(`Created area "${area.name}"`);
}
}
// Categories // 3) Create products, linking to categories
if (parsedRows['categories']) { for (const row of productRows) {
for (const row of parsedRows['categories']) { const nameRaw = (row.product_name ?? row.name ?? '').trim();
const name = normalizeName(row.name); const name = normalizeName(nameRaw);
if (!name) { if (!name) {
addDetail('Skipped category with empty name'); addDetail('Skipped product with empty name');
skipped += 1; skipped += 1;
continue; continue;
} }
if (categoryNameToId.has(name)) { if (productNameToId.has(name)) {
addDetail(`Category "${row.name}" exists, skipping`); addDetail(`Product "${nameRaw}" exists, skipping`);
skipped += 1; skipped += 1;
continue; continue;
} }
const id = row.id && !(await db.categories.get(row.id)) ? row.id : uuidv4();
const category: Category = {
id,
name: row.name.trim(),
created_at: parseTimestamp(row.created_at),
updated_at: parseTimestamp(row.updated_at),
};
await db.categories.add(category);
categoryNameToId.set(name, id);
inserted += 1;
addDetail(`Created category "${category.name}"`);
}
}
// Products const catRaw = (row.category ?? '').trim();
if (parsedRows['products']) { const categoryId = catRaw ? categoryNameToId.get(normalizeName(catRaw)) : undefined;
for (const row of parsedRows['products']) {
const name = normalizeName(row.name);
if (!name) {
addDetail('Skipped product with empty name');
skipped += 1;
continue;
}
if (productNameToId.has(name)) {
addDetail(`Product "${row.name}" exists, skipping`);
skipped += 1;
continue;
}
const categoryName = normalizeName(row.category); const barcode = row.barcode?.trim();
let categoryId = categoryName ? categoryNameToId.get(categoryName) : undefined; if (barcode && barcodeToProductId.has(barcode)) {
if (!categoryId && categoryName) { addDetail(`Barcode ${barcode} already exists, clearing for product "${nameRaw}"`);
if (options.allowAutoCreateMissing) { }
const newId = uuidv4();
categoryId = newId;
categoryNameToId.set(categoryName, newId);
await db.categories.add({
id: newId,
name: row.category.trim(),
created_at: now,
updated_at: now,
});
addDetail(`Auto-created category "${row.category}" for product "${row.name}"`);
} else {
addDetail(`Missing category for product "${row.name}", skipping`);
skipped += 1;
continue;
}
}
const barcode = row.barcode?.trim(); const id = uuidv4();
if (barcode && barcodeToProductId.has(barcode)) { const product: Product = {
addDetail(`Barcode ${barcode} already exists, clearing for product "${row.name}"`); id,
} name: nameRaw,
category: categoryId ?? '',
unit_type: DEFAULT_UNIT_TYPE,
bulk_name: DEFAULT_BULK_NAME,
barcode: barcode && !barcodeToProductId.has(barcode) ? barcode : undefined,
archived: false,
created_at: now,
updated_at: now,
};
await db.products.add(product);
productNameToId.set(name, id);
if (product.barcode) {
barcodeToProductId.set(product.barcode, id);
}
inserted += 1;
addDetail(`Created product "${product.name}"`);
}
}
const id = row.id && !(await db.products.get(row.id)) ? row.id : uuidv4();
const product: Product = {
id,
name: row.name.trim(),
category: categoryId ?? '',
unit_type: row.unit_type?.trim() || DEFAULT_UNIT_TYPE,
bulk_name: row.bulk_name?.trim() || DEFAULT_BULK_NAME,
barcode: barcode && !barcodeToProductId.has(barcode) ? barcode : undefined,
archived: coerceBoolean(row.archived),
created_at: parseTimestamp(row.created_at),
updated_at: parseTimestamp(row.updated_at),
};
await db.products.add(product);
productNameToId.set(name, id);
if (product.barcode) {
barcodeToProductId.set(product.barcode, id);
}
inserted += 1;
addDetail(`Created product "${product.name}"`);
}
}
// Pick lists
if (parsedRows['pick-lists']) {
for (const row of parsedRows['pick-lists']) {
const inferredAreaName = normalizeName(row.area_name);
const areaId = row.area_id || (inferredAreaName ? areaNameToId.get(inferredAreaName) : undefined);
if (!areaId && !options.allowAutoCreateMissing) {
addDetail(`Missing area for pick list, skipping`);
skipped += 1;
continue;
}
let resolvedAreaId = areaId;
if (!resolvedAreaId && inferredAreaName) {
const newAreaId = uuidv4();
resolvedAreaId = newAreaId;
areaNameToId.set(inferredAreaName, newAreaId);
await db.areas.add({
id: newAreaId,
name: row.area_name.trim(),
created_at: now,
updated_at: now,
});
addDetail(`Auto-created area "${row.area_name}" for pick list`);
}
const categoriesFromRow = (row.categories || '')
.split(';')
.map((name) => normalizeName(name))
.filter(Boolean);
const categoryIds: string[] = [];
for (const catName of categoriesFromRow) {
let catId = categoryNameToId.get(catName);
if (!catId && options.allowAutoCreateMissing) {
const newId = uuidv4();
catId = newId;
categoryNameToId.set(catName, newId);
await db.categories.add({
id: newId,
name: catName,
created_at: now,
updated_at: now,
});
addDetail(`Auto-created category "${catName}" for pick list`);
}
if (catId) categoryIds.push(catId);
}
const id = row.id && !(await db.pickLists.get(row.id)) ? row.id : uuidv4();
const pickList: PickList = {
id,
area_id: resolvedAreaId ?? uuidv4(),
created_at: parseTimestamp(row.created_at),
completed_at: row.completed_at ? Number(row.completed_at) : undefined,
notes: row.notes ?? '',
categories: categoryIds,
auto_add_new_products: coerceBoolean(row.auto_add_new_products),
};
await db.pickLists.add(pickList);
if (pickList.notes) {
pickListNameToId.set(normalizeName(pickList.notes), pickList.id);
}
inserted += 1;
addDetail(`Created pick list ${pickList.id}`);
}
}
// Pick items
if (parsedRows['pick-items']) {
for (const row of parsedRows['pick-items']) {
const pickListId =
row.pick_list_id || pickListNameToId.get(normalizeName(row.pick_list_name));
const productId = row.product_id || productNameToId.get(normalizeName(row.product_name));
if (!pickListId || !productId) {
addDetail(`Missing pick list or product for pick item, skipping`);
skipped += 1;
continue;
}
const id = row.id && !(await db.pickItems.get(row.id)) ? row.id : uuidv4();
const pickItem: PickItem = {
id,
pick_list_id: pickListId,
product_id: productId,
quantity: coerceNumber(row.quantity) || 1,
is_carton: coerceBoolean(row.is_carton),
status: (row.status as PickItemStatus) || 'pending',
created_at: parseTimestamp(row.created_at),
updated_at: parseTimestamp(row.updated_at),
};
await db.pickItems.add(pickItem);
inserted += 1;
addDetail(`Added pick item for product ${row.product_name ?? pickItem.product_id}`);
}
}
}, },
); );
} catch (error) { } catch (error) {