Files
stockfill/src/screens/ManageProductsScreen.tsx
T

303 lines
9.5 KiB
TypeScript

import {
Alert,
AlertColor,
Button,
Container,
Dialog,
DialogContent,
DialogTitle,
InputAdornment,
MenuItem,
Stack,
TextField,
Typography,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import { Link as RouterLink, useLocation } from 'react-router-dom';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { ProductRow } from '../components/ProductRow';
import { useCategories, useProducts } from '../hooks/dataHooks';
import { useDatabase } from '../context/DBProvider';
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE } from '../models/Product';
import { BarcodeScannerView } from '../components/BarcodeScannerView';
import { ExternalProductInfo, fetchProductFromOFF } from '../modules/openFoodFacts';
export const ManageProductsScreen = () => {
const db = useDatabase();
const products = useProducts();
const categories = useCategories();
const location = useLocation();
const [search, setSearch] = useState('');
const [name, setName] = useState('');
const [category, setCategory] = useState('');
const [barcode, setBarcode] = useState('');
const [barcodeError, setBarcodeError] = useState('');
const [scannerOpen, setScannerOpen] = useState(false);
const [lookupStatus, setLookupStatus] = useState<'idle' | 'loading' | 'found' | 'notfound' | 'offline'>(
'idle',
);
const [externalProduct, setExternalProduct] = useState<ExternalProductInfo | null>(null);
const [feedback, setFeedback] = useState<{ text: string; severity: AlertColor } | null>(null);
const 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');
}
}, []);
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 findBarcodeConflict = useCallback(
(value?: string, productId?: string) =>
value
? products.find((product) => product.barcode === value && product.id !== productId)
: undefined,
[products],
);
const assertUniqueBarcode = useCallback(
async (value?: string, productId?: string) => {
if (!value) return;
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';
throw error;
}
},
[db.products, findBarcodeConflict],
);
useEffect(() => {
if (categoryOptions.length === 0) return;
if (!categoryOptions.includes(category)) {
setCategory(categoryOptions[0]);
}
}, [category, categoryOptions]);
const filtered = useMemo(
() =>
products.filter((p) =>
`${p.name} ${p.category}`.toLowerCase().includes(search.toLowerCase()),
),
[products, search],
);
useEffect(() => {
const state = location.state as { newBarcode?: string } | null;
if (state?.newBarcode) {
setBarcode(state.newBarcode);
void lookupBarcode(state.newBarcode);
}
}, [location.state, lookupBarcode]);
useEffect(() => {
if (!barcode) {
setLookupStatus('idle');
setExternalProduct(null);
}
setBarcodeError('');
}, [barcode]);
const addProduct = async () => {
if (!name || !category) return;
try {
await assertUniqueBarcode(barcode || undefined);
} catch (error) {
if (error instanceof Error && error.name === 'DuplicateBarcodeError') {
setBarcodeError(error.message);
return;
}
throw error;
}
await db.products.add({
id: uuidv4(),
name,
category,
unit_type: DEFAULT_UNIT_TYPE,
bulk_name: DEFAULT_BULK_NAME,
barcode: barcode || undefined,
archived: false,
created_at: Date.now(),
updated_at: Date.now(),
});
setName('');
setBarcode('');
setFeedback({ text: 'Product added.', severity: 'success' });
};
const updateProduct = async (
productId: string,
updates: {
name: string;
category: string;
barcode?: string;
},
) => {
await assertUniqueBarcode(updates.barcode, productId);
await db.products.update(productId, {
...updates,
unit_type: DEFAULT_UNIT_TYPE,
bulk_name: DEFAULT_BULK_NAME,
updated_at: Date.now(),
});
setFeedback({ text: 'Product updated.', severity: 'success' });
};
const deleteProduct = async (productId: string) => {
const usageCount = await db.pickItems.where('product_id').equals(productId).count();
if (usageCount > 0) {
setFeedback({
text: `Cannot delete this product while ${usageCount} pick item(s) reference it. Remove those items first.`,
severity: 'error',
});
return;
}
await db.products.delete(productId);
setFeedback({ text: 'Product deleted.', severity: 'success' });
};
return (
<Container sx={{ py: 4 }}>
<Typography variant="h5" gutterBottom>
Manage Products
</Typography>
<Stack spacing={2}>
{feedback ? <Alert severity={feedback.severity}>{feedback.text}</Alert> : null}
<Button component={RouterLink} to="/categories" variant="outlined" sx={{ alignSelf: 'flex-start' }}>
Edit Categories
</Button>
<TextField
placeholder="Search"
value={search}
onChange={(event) => setSearch(event.target.value)}
InputProps={{ startAdornment: <InputAdornment position="start">{<SearchIcon />}</InputAdornment> }}
/>
<Stack spacing={1}>
<Typography variant="subtitle1">Add Product</Typography>
<TextField
label="Name"
value={name}
onChange={(event) => setName(event.target.value)}
InputProps={
name
? {
endAdornment: (
<Button onClick={() => setName('')} size="small">
Clear
</Button>
),
}
: undefined
}
/>
<TextField
select
label="Category"
value={category}
onChange={(event) => setCategory(event.target.value)}
disabled={categoryOptions.length === 0}
>
{categoryOptions.map((cat) => (
<MenuItem key={cat} value={cat}>
{cat}
</MenuItem>
))}
</TextField>
{barcode ? (
<Stack spacing={1}>
<TextField
label="Barcode"
value={barcode}
onChange={(event) => setBarcode(event.target.value)}
error={Boolean(barcodeError)}
helperText={barcodeError || undefined}
InputProps={{
endAdornment: (
<Button onClick={() => setBarcode('')} size="small">
Clear
</Button>
),
}}
/>
{lookupStatus === 'loading' ? <Typography variant="body2">Looking up product</Typography> : null}
{lookupStatus === 'found' && externalProduct ? (
<Typography variant="body2" color="text.secondary">
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
</Button>
</Stack>
{filtered.map((product) => (
<ProductRow
key={product.id}
product={product}
categories={categoryOptions}
onSave={updateProduct}
onDelete={deleteProduct}
/>
))}
</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>
);
};