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:
@@ -1,2 +1,4 @@
|
||||
id,name,category,unit_type,bulk_name,barcode,archived,created_at,updated_at
|
||||
"Blue T-Shirt","Clothing","unit","carton","0123456789012",false,,
|
||||
name,barcode,category
|
||||
Running Shorts,0123456789,Clothing
|
||||
Crop Top,9876543210,Clothing
|
||||
Yoga Pants,,Bottoms
|
||||
|
||||
|
@@ -17,12 +17,21 @@ const product: Product = {
|
||||
|
||||
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', () => {
|
||||
it('shows name, category, and barcode without unit text in read-only mode', () => {
|
||||
render(
|
||||
<ProductRow
|
||||
product={product}
|
||||
categories={categories}
|
||||
categoriesById={categoriesById}
|
||||
onSave={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
@@ -39,7 +48,7 @@ describe('ProductRow', () => {
|
||||
const onSave = vi.fn();
|
||||
|
||||
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));
|
||||
@@ -59,12 +68,12 @@ describe('ProductRow', () => {
|
||||
|
||||
it('surfaces validation errors from duplicate constraints', async () => {
|
||||
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' }));
|
||||
|
||||
render(
|
||||
<ProductRow product={product} categories={categories} onSave={onSave} onDelete={vi.fn()} />,
|
||||
);
|
||||
render(<ProductRow product={product} categories={categories} categoriesById={categoriesById} onSave={onSave} onDelete={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByLabelText(/edit sparkling water/i));
|
||||
await user.clear(screen.getByLabelText(/name/i));
|
||||
@@ -82,9 +91,7 @@ describe('ProductRow', () => {
|
||||
it('allows clearing and scanning a new barcode', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProductRow product={product} categories={categories} onSave={vi.fn()} onDelete={vi.fn()} />,
|
||||
);
|
||||
render(<ProductRow product={product} categories={categories} categoriesById={categoriesById} onSave={vi.fn()} onDelete={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByLabelText(/edit sparkling water/i));
|
||||
await user.click(screen.getByRole('button', { name: /clear/i }));
|
||||
|
||||
@@ -21,12 +21,15 @@ import { BarcodeScannerView } from './BarcodeScannerView';
|
||||
|
||||
interface ProductRowProps {
|
||||
product: Product;
|
||||
// list of category display names for the select
|
||||
categories: string[];
|
||||
// map of category id -> category name, used to resolve ids to names
|
||||
categoriesById: Map<string, string>;
|
||||
onSave: (
|
||||
productId: string,
|
||||
updates: {
|
||||
name: string;
|
||||
category: string;
|
||||
category: string; // this is the *name* when passed back to parent
|
||||
barcode?: string;
|
||||
},
|
||||
) => Promise<void> | void;
|
||||
@@ -39,22 +42,23 @@ interface ProductFormState {
|
||||
barcode: string;
|
||||
}
|
||||
|
||||
const getInitialFormState = (product: Product): ProductFormState => ({
|
||||
const getInitialFormState = (product: Product, categoriesById: Map<string, string>): ProductFormState => ({
|
||||
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 ?? '',
|
||||
});
|
||||
|
||||
export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRowProps) => {
|
||||
export const ProductRow = ({ product, categories, categoriesById, onSave, onDelete }: ProductRowProps) => {
|
||||
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 [fieldErrors, setFieldErrors] = useState<{ name?: string; barcode?: string }>({});
|
||||
|
||||
useEffect(() => {
|
||||
setFormState(getInitialFormState(product));
|
||||
setFormState(getInitialFormState(product, categoriesById));
|
||||
setFieldErrors({});
|
||||
}, [product]);
|
||||
}, [product, categoriesById]);
|
||||
|
||||
const handleChange = (field: keyof ProductFormState) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setFormState((prev) => ({ ...prev, [field]: event.target.value }));
|
||||
@@ -86,7 +90,7 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setFormState(getInitialFormState(product));
|
||||
setFormState(getInitialFormState(product, categoriesById));
|
||||
setFieldErrors({});
|
||||
};
|
||||
|
||||
@@ -103,14 +107,7 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
|
||||
error={Boolean(fieldErrors.name)}
|
||||
helperText={fieldErrors.name || undefined}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
SelectProps={{ native: true }}
|
||||
label="Category"
|
||||
value={formState.category}
|
||||
onChange={handleChange('category')}
|
||||
size="small"
|
||||
>
|
||||
<TextField select SelectProps={{ native: true }} label="Category" value={formState.category} onChange={handleChange('category')} size="small">
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat}
|
||||
@@ -145,12 +142,7 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
|
||||
</Button>
|
||||
)}
|
||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end" alignItems="center">
|
||||
<IconButton
|
||||
aria-label={`Delete ${product.name}`}
|
||||
onClick={() => onDelete(product.id)}
|
||||
size="small"
|
||||
color="error"
|
||||
>
|
||||
<IconButton aria-label={`Delete ${product.name}`} onClick={() => onDelete(product.id)} size="small" color="error">
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<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}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{product.category}
|
||||
{/* Resolve id -> name for display */}
|
||||
{categoriesById.get(product.category) ?? product.category ?? ''}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{product.barcode ? (
|
||||
|
||||
@@ -3,9 +3,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Checkbox,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
@@ -16,28 +14,13 @@ import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { useDatabase } from '../context/DBProvider';
|
||||
import { downloadLog, exportData, importFiles } from '../services/importExportService';
|
||||
import { DataType, ImportOptions } from '../services/importExportService';
|
||||
import { ImportOptions } from '../services/importExportService';
|
||||
import { ImportExportLog } from '../models/ImportExportLog';
|
||||
|
||||
const templateFiles: { label: string; file: string }[] = [
|
||||
{ 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' },
|
||||
];
|
||||
const templateFiles: { label: string; file: string }[] = [{ label: 'Products', file: 'products_template.csv' }];
|
||||
|
||||
export const ImportExportScreen = () => {
|
||||
const db = useDatabase();
|
||||
const [selectedTypes, setSelectedTypes] = useState<DataType[]>(dataTypes.map((d) => d.key));
|
||||
const [logLines, setLogLines] = useState<string[]>([]);
|
||||
const [history, setHistory] = useState<ImportExportLog[]>([]);
|
||||
const [allowAutoCreateMissing, setAllowAutoCreateMissing] = useState(true);
|
||||
@@ -52,22 +35,11 @@ export const ImportExportScreen = () => {
|
||||
return () => subscription.unsubscribe();
|
||||
}, [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 () => {
|
||||
setLogLines([]);
|
||||
try {
|
||||
await exportData(db, selectedTypes, appendLog);
|
||||
// always export products only
|
||||
await exportData(db, ['products'], appendLog);
|
||||
appendLog('Export complete');
|
||||
} catch (error) {
|
||||
appendLog(`Export failed: ${(error as Error).message}`);
|
||||
@@ -130,30 +102,6 @@ export const ImportExportScreen = () => {
|
||||
</Card>
|
||||
</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' } }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
@@ -161,8 +109,8 @@ export const ImportExportScreen = () => {
|
||||
Actions
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<Button variant="contained" disabled={selectedTypes.length === 0} onClick={handleExport}>
|
||||
Export Selected
|
||||
<Button variant="contained" onClick={handleExport}>
|
||||
Export Products
|
||||
</Button>
|
||||
<Button variant="outlined" component="label">
|
||||
Select CSV or ZIP
|
||||
@@ -171,15 +119,16 @@ export const ImportExportScreen = () => {
|
||||
<Button variant="contained" color="secondary" onClick={handleImport}>
|
||||
Import Files
|
||||
</Button>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={allowAutoCreateMissing}
|
||||
onChange={(event) => setAllowAutoCreateMissing(event.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Allow auto-create missing referenced entities"
|
||||
/>
|
||||
<Divider />
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
setAllowAutoCreateMissing((prev) => !prev);
|
||||
appendLog(`Allow auto-create missing referenced entities: ${!allowAutoCreateMissing}`);
|
||||
}}
|
||||
>
|
||||
Allow auto-create missing referenced entities: {allowAutoCreateMissing ? 'On' : 'Off'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -191,10 +140,7 @@ export const ImportExportScreen = () => {
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Log
|
||||
</Typography>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{ backgroundColor: '#f6f6f6', p: 2, borderRadius: 1, maxHeight: 240, overflow: 'auto' }}
|
||||
>
|
||||
<Box component="pre" sx={{ backgroundColor: '#f6f6f6', p: 2, borderRadius: 1, maxHeight: 240, overflow: 'auto' }}>
|
||||
{logLines.length === 0 ? 'No log entries yet' : logLines.join('\n')}
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} mt={1}>
|
||||
|
||||
@@ -37,59 +37,31 @@ export const ManageProductsScreen = () => {
|
||||
const [barcodeError, setBarcodeError] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
const [lookupStatus, setLookupStatus] = useState<'idle' | 'loading' | 'found' | 'notfound' | 'offline'>(
|
||||
'idle',
|
||||
);
|
||||
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 lookupBarcode = useCallback(async (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');
|
||||
}
|
||||
}, []);
|
||||
// Map category id -> name
|
||||
const categoriesById = useMemo(() => new Map(categories.map((c) => [c.id, c.name])), [categories]);
|
||||
|
||||
// category options are display names (union of known category names and product-resolved names)
|
||||
const categoryOptions = useMemo(() => {
|
||||
const categoryNames = categories.map((item) => item.name);
|
||||
const productCategories = products.map((product) => product.category);
|
||||
return Array.from(new Set([...categoryNames, ...productCategories]));
|
||||
}, [categories, products]);
|
||||
const productCategories = products.map((product) => categoriesById.get(product.category) ?? product.category ?? '');
|
||||
return Array.from(new Set([...categoryNames, ...productCategories].filter(Boolean)));
|
||||
}, [categories, products, categoriesById]);
|
||||
|
||||
const findBarcodeConflict = useCallback(
|
||||
(value?: string, productId?: string) =>
|
||||
value
|
||||
? products.find((product) => product.barcode === value && product.id !== productId)
|
||||
: undefined,
|
||||
value ? products.find((product) => product.barcode === value && product.id !== productId) : undefined,
|
||||
[products],
|
||||
);
|
||||
|
||||
const findNameConflict = useCallback(
|
||||
(value?: string, productId?: string) => {
|
||||
if (!value) return undefined;
|
||||
|
||||
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],
|
||||
);
|
||||
@@ -97,10 +69,7 @@ export const ManageProductsScreen = () => {
|
||||
const assertUniqueBarcode = useCallback(
|
||||
async (value?: string, productId?: string) => {
|
||||
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) {
|
||||
const error = new Error('This barcode is already assigned to another product.');
|
||||
error.name = 'DuplicateBarcodeError';
|
||||
@@ -114,9 +83,7 @@ export const ManageProductsScreen = () => {
|
||||
async (value: string, productId?: string) => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return;
|
||||
|
||||
const conflict = findNameConflict(value, productId);
|
||||
|
||||
if (conflict) {
|
||||
const error = new Error('A product with this name already exists.');
|
||||
error.name = 'DuplicateNameError';
|
||||
@@ -129,15 +96,10 @@ export const ManageProductsScreen = () => {
|
||||
const addProductToAutoLists = useCallback(
|
||||
async (product: Product, timestamp: number) => {
|
||||
const pickLists = await db.pickLists.toArray();
|
||||
const eligibleLists = pickLists.filter(
|
||||
(pickList) =>
|
||||
pickList.auto_add_new_products && Array.isArray(pickList.categories)
|
||||
? pickList.categories.includes(product.category)
|
||||
: false,
|
||||
const eligibleLists = pickLists.filter((pickList) =>
|
||||
pickList.auto_add_new_products && Array.isArray(pickList.categories) ? pickList.categories.includes(product.category) : false,
|
||||
);
|
||||
|
||||
if (eligibleLists.length === 0) return;
|
||||
|
||||
await Promise.all(
|
||||
eligibleLists.map(async (pickList) => {
|
||||
const existing = await db.pickItems
|
||||
@@ -145,9 +107,7 @@ export const ManageProductsScreen = () => {
|
||||
.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,
|
||||
@@ -180,13 +140,12 @@ export const ManageProductsScreen = () => {
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
products.filter((p) => {
|
||||
const matchesSearch = `${p.name} ${p.category}`
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'all' || p.category === selectedCategory;
|
||||
const pCategoryName = categoriesById.get(p.category) ?? p.category ?? '';
|
||||
const matchesSearch = `${p.name} ${pCategoryName}`.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'all' || pCategoryName === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
}),
|
||||
[products, search, selectedCategory],
|
||||
[products, search, selectedCategory, categoriesById],
|
||||
);
|
||||
|
||||
const sortedFiltered = useMemo(
|
||||
@@ -200,7 +159,8 @@ export const ManageProductsScreen = () => {
|
||||
setBarcode(state.newBarcode);
|
||||
void lookupBarcode(state.newBarcode);
|
||||
}
|
||||
}, [location.state, lookupBarcode]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!barcode) {
|
||||
@@ -210,9 +170,29 @@ export const ManageProductsScreen = () => {
|
||||
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');
|
||||
if (result.name) {
|
||||
setName((prev) => prev || result.name || '');
|
||||
}
|
||||
} else {
|
||||
setExternalProduct(null);
|
||||
setLookupStatus('notfound');
|
||||
}
|
||||
}
|
||||
|
||||
const addProduct = async () => {
|
||||
if (!name || !category) return;
|
||||
|
||||
try {
|
||||
await assertUniqueName(name);
|
||||
await assertUniqueBarcode(barcode || undefined);
|
||||
@@ -229,10 +209,15 @@ export const ManageProductsScreen = () => {
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
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 = {
|
||||
id: productId,
|
||||
name,
|
||||
category,
|
||||
category: categoryIdToSave,
|
||||
unit_type: DEFAULT_UNIT_TYPE,
|
||||
bulk_name: DEFAULT_BULK_NAME,
|
||||
barcode: barcode || undefined,
|
||||
@@ -265,12 +250,18 @@ export const ManageProductsScreen = () => {
|
||||
const existing = await db.products.get(productId);
|
||||
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 oldNameKey = existing.name.trim().toLowerCase();
|
||||
const updatedProduct: Product = {
|
||||
...existing,
|
||||
...updates,
|
||||
name: normalizedName,
|
||||
category: categoryIdToSave,
|
||||
unit_type: DEFAULT_UNIT_TYPE,
|
||||
bulk_name: DEFAULT_BULK_NAME,
|
||||
updated_at: Date.now(),
|
||||
@@ -302,17 +293,13 @@ export const ManageProductsScreen = () => {
|
||||
Manage Products
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<Snackbar
|
||||
open={Boolean(feedback)}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setFeedback(null)}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<Snackbar open={Boolean(feedback)} autoHideDuration={4000} onClose={() => setFeedback(null)} anchorOrigin={{ vertical: 'top', horizontal: 'center' }}>
|
||||
{feedback ? <Alert severity={feedback.severity}>{feedback.text}</Alert> : undefined}
|
||||
</Snackbar>
|
||||
<Button component={RouterLink} to="/categories" variant="outlined" sx={{ alignSelf: 'flex-start' }}>
|
||||
Edit Categories
|
||||
</Button>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>
|
||||
<TextField
|
||||
placeholder="Search"
|
||||
@@ -337,6 +324,7 @@ export const ManageProductsScreen = () => {
|
||||
))}
|
||||
</TextField>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle1">Add Product</Typography>
|
||||
<TextField
|
||||
@@ -366,13 +354,7 @@ export const ManageProductsScreen = () => {
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="Add product category"
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value)}
|
||||
disabled={categoryOptions.length === 0}
|
||||
>
|
||||
<TextField select label="Add product category" value={category} onChange={(event) => setCategory(event.target.value)} disabled={categoryOptions.length === 0}>
|
||||
{categoryOptions.map((cat) => (
|
||||
<MenuItem key={cat} value={cat}>
|
||||
{cat}
|
||||
@@ -401,54 +383,34 @@ export const ManageProductsScreen = () => {
|
||||
Found {externalProduct.name ?? 'product'} via Open Food Facts. Please confirm details.
|
||||
</Typography>
|
||||
) : 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>
|
||||
) : (
|
||||
<Button variant="outlined" onClick={() => setScannerOpen(true)}>
|
||||
Scan Barcode
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="contained" onClick={addProduct} disabled={!name || !category}>
|
||||
Save Product
|
||||
<Dialog open={scannerOpen} onClose={() => setScannerOpen(false)} fullWidth>
|
||||
<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>
|
||||
</Stack>
|
||||
{sortedFiltered.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No products match your search and category filter.
|
||||
</Typography>
|
||||
) : (
|
||||
sortedFiltered.map((product) => (
|
||||
<ProductRow
|
||||
key={product.id}
|
||||
product={product}
|
||||
categories={categoryOptions}
|
||||
onSave={updateProduct}
|
||||
onDelete={deleteProduct}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
<Stack spacing={1}>
|
||||
{sortedFiltered.map((product) => (
|
||||
<ProductRow key={product.id} product={product} categories={categoryOptions} categoriesById={categoriesById} 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -156,23 +156,18 @@ export const exportData = async (
|
||||
fileEntries.push({ name: 'categories.csv', content: serializeCsv(rows) });
|
||||
}
|
||||
|
||||
if (selectedTypes.includes('products')) {
|
||||
const products = await db.products.toArray();
|
||||
const rows = products.map((product) => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
category: categoriesById.get(product.category) ?? '',
|
||||
unit_type: product.unit_type,
|
||||
bulk_name: product.bulk_name,
|
||||
barcode: product.barcode,
|
||||
archived: product.archived,
|
||||
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('products')) {
|
||||
const products = await db.products.toArray();
|
||||
const rows = products.map((product) => ({
|
||||
name: product.name,
|
||||
barcode: product.barcode ?? '',
|
||||
category: categoriesById.get(product.category) ?? '',
|
||||
}));
|
||||
inserted += rows.length;
|
||||
addDetail(`Exported ${rows.length} products`);
|
||||
fileEntries.push({ name: 'products.csv', content: serializeCsv(rows) });
|
||||
}
|
||||
|
||||
|
||||
if (selectedTypes.includes('pick-lists')) {
|
||||
const pickLists = await db.pickLists.toArray();
|
||||
@@ -283,18 +278,34 @@ export const importFiles = async (
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const rows = await parseCsv(file.content);
|
||||
parsedRows[type] = rows;
|
||||
selectedTypes.push(type);
|
||||
addDetail(`Parsed ${rows.length} rows from ${file.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
@@ -331,223 +342,78 @@ export const importFiles = async (
|
||||
try {
|
||||
await db.transaction(
|
||||
'rw',
|
||||
[db.areas, db.categories, db.products, db.pickLists, db.pickItems],
|
||||
[db.categories, db.products],
|
||||
async () => {
|
||||
const parseTimestamp = (value?: string) =>
|
||||
value && value.trim() !== '' ? Number(value) || now : now;
|
||||
if (productRows.length > 0) {
|
||||
// 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
|
||||
if (parsedRows['areas']) {
|
||||
for (const row of parsedRows['areas']) {
|
||||
const name = normalizeName(row.name);
|
||||
if (!name) {
|
||||
addDetail('Skipped area with empty name');
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (areaNameToId.has(name)) {
|
||||
addDetail(`Area "${row.name}" exists, skipping`);
|
||||
skipped += 1;
|
||||
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}"`);
|
||||
}
|
||||
}
|
||||
// 2) Create missing categories in DB
|
||||
for (const [norm, raw] of uniqueCategoryNames) {
|
||||
if (!categoryNameToId.has(norm)) {
|
||||
const newId = uuidv4();
|
||||
categoryNameToId.set(norm, newId);
|
||||
await db.categories.add({
|
||||
id: newId,
|
||||
name: raw,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
addDetail(`Created category "${raw}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Categories
|
||||
if (parsedRows['categories']) {
|
||||
for (const row of parsedRows['categories']) {
|
||||
const name = normalizeName(row.name);
|
||||
if (!name) {
|
||||
addDetail('Skipped category with empty name');
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (categoryNameToId.has(name)) {
|
||||
addDetail(`Category "${row.name}" exists, skipping`);
|
||||
skipped += 1;
|
||||
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}"`);
|
||||
}
|
||||
}
|
||||
// 3) Create products, linking to categories
|
||||
for (const row of productRows) {
|
||||
const nameRaw = (row.product_name ?? row.name ?? '').trim();
|
||||
const name = normalizeName(nameRaw);
|
||||
if (!name) {
|
||||
addDetail('Skipped product with empty name');
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (productNameToId.has(name)) {
|
||||
addDetail(`Product "${nameRaw}" exists, skipping`);
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Products
|
||||
if (parsedRows['products']) {
|
||||
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 catRaw = (row.category ?? '').trim();
|
||||
const categoryId = catRaw ? categoryNameToId.get(normalizeName(catRaw)) : undefined;
|
||||
|
||||
const categoryName = normalizeName(row.category);
|
||||
let categoryId = categoryName ? categoryNameToId.get(categoryName) : undefined;
|
||||
if (!categoryId && categoryName) {
|
||||
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();
|
||||
if (barcode && barcodeToProductId.has(barcode)) {
|
||||
addDetail(`Barcode ${barcode} already exists, clearing for product "${nameRaw}"`);
|
||||
}
|
||||
|
||||
const barcode = row.barcode?.trim();
|
||||
if (barcode && barcodeToProductId.has(barcode)) {
|
||||
addDetail(`Barcode ${barcode} already exists, clearing for product "${row.name}"`);
|
||||
}
|
||||
const id = uuidv4();
|
||||
const product: Product = {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user