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
|
name,barcode,category
|
||||||
"Blue T-Shirt","Clothing","unit","carton","0123456789012",false,,
|
Running Shorts,0123456789,Clothing
|
||||||
|
Crop Top,9876543210,Clothing
|
||||||
|
Yoga Pants,,Bottoms
|
||||||
|
|||||||
|
@@ -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 }));
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user