Add barcode scanning to manage products

This commit is contained in:
beatz174-bit
2025-11-21 18:17:01 +10:00
parent e361af2a1c
commit b81ac51242
6 changed files with 100 additions and 34 deletions
+6 -3
View File
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { Alert, Card, CardContent, Typography } from '@mui/material';
import { useBarcodeScanner } from '../hooks/useBarcodeScanner';
@@ -8,9 +9,11 @@ interface BarcodeScannerViewProps {
export const BarcodeScannerView = ({ onDetected }: BarcodeScannerViewProps) => {
const { videoRef, result } = useBarcodeScanner();
if (result.code && onDetected) {
onDetected(result.code);
}
useEffect(() => {
if (result.code && onDetected) {
onDetected(result.code);
}
}, [onDetected, result.code]);
return (
<Card variant="outlined">
+43 -14
View File
@@ -2,18 +2,23 @@ import {
Card,
CardActions,
CardContent,
Dialog,
DialogContent,
DialogTitle,
IconButton,
MenuItem,
Stack,
TextField,
Typography,
Button,
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import { ChangeEvent, useEffect, useState } from 'react';
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE, Product } from '../models/Product';
import { DEFAULT_UNIT_TYPE, Product } from '../models/Product';
import { BarcodeScannerView } from './BarcodeScannerView';
interface ProductRowProps {
product: Product;
@@ -23,7 +28,7 @@ interface ProductRowProps {
updates: {
name: string;
category: string;
units_per_bulk?: number;
barcode?: string;
},
) => Promise<void> | void;
onDelete: (productId: string) => Promise<void> | void;
@@ -32,18 +37,19 @@ interface ProductRowProps {
interface ProductFormState {
name: string;
category: string;
unitsPerBulk: string;
barcode: string;
}
const getInitialFormState = (product: Product): ProductFormState => ({
name: product.name,
category: product.category,
unitsPerBulk: product.units_per_bulk?.toString() ?? '',
barcode: product.barcode ?? '',
});
export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRowProps) => {
const [isEditing, setIsEditing] = useState(false);
const [formState, setFormState] = useState<ProductFormState>(() => getInitialFormState(product));
const [isScannerOpen, setIsScannerOpen] = useState(false);
useEffect(() => {
setFormState(getInitialFormState(product));
@@ -58,7 +64,7 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
await onSave(product.id, {
name: formState.name,
category: formState.category,
units_per_bulk: formState.unitsPerBulk ? Number(formState.unitsPerBulk) : undefined,
barcode: formState.barcode || undefined,
});
setIsEditing(false);
};
@@ -87,13 +93,25 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
</MenuItem>
))}
</TextField>
<TextField
label="Units per Bulk"
type="number"
value={formState.unitsPerBulk}
onChange={handleChange('unitsPerBulk')}
size="small"
/>
{formState.barcode ? (
<TextField
label="Barcode"
value={formState.barcode}
onChange={handleChange('barcode')}
size="small"
InputProps={{
endAdornment: (
<Button size="small" onClick={() => setFormState((prev) => ({ ...prev, barcode: '' }))}>
Clear
</Button>
),
}}
/>
) : (
<Button variant="outlined" onClick={() => setIsScannerOpen(true)}>
Scan Barcode
</Button>
)}
</Stack>
) : (
<Stack direction="row" justifyContent="space-between" alignItems="center">
@@ -103,9 +121,9 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
{product.category} {product.unit_type || DEFAULT_UNIT_TYPE}
</Typography>
</div>
{product.units_per_bulk ? (
{product.barcode ? (
<Typography variant="caption" color="text.secondary">
{product.units_per_bulk} per {product.bulk_name || DEFAULT_BULK_NAME}
Barcode: {product.barcode}
</Typography>
) : null}
</Stack>
@@ -132,6 +150,17 @@ export const ProductRow = ({ product, categories, onSave, onDelete }: ProductRow
</>
)}
</CardActions>
<Dialog open={isScannerOpen} onClose={() => setIsScannerOpen(false)} fullWidth>
<DialogTitle>Scan Barcode</DialogTitle>
<DialogContent>
<BarcodeScannerView
onDetected={(code) => {
setFormState((prev) => ({ ...prev, barcode: code }));
setIsScannerOpen(false);
}}
/>
</DialogContent>
</Dialog>
</Card>
);
};
-3
View File
@@ -33,7 +33,6 @@ export const seedDatabase = async (db: StockFillDB) => {
category: 'Drinks',
unit_type: DEFAULT_UNIT_TYPE,
bulk_name: DEFAULT_BULK_NAME,
units_per_bulk: 12,
archived: false,
created_at: now(),
updated_at: now(),
@@ -44,7 +43,6 @@ export const seedDatabase = async (db: StockFillDB) => {
category: 'Snacks',
unit_type: DEFAULT_UNIT_TYPE,
bulk_name: DEFAULT_BULK_NAME,
units_per_bulk: 24,
archived: false,
created_at: now(),
updated_at: now(),
@@ -55,7 +53,6 @@ export const seedDatabase = async (db: StockFillDB) => {
category: 'Confectionery',
unit_type: DEFAULT_UNIT_TYPE,
bulk_name: DEFAULT_BULK_NAME,
units_per_bulk: 32,
archived: false,
created_at: now(),
updated_at: now(),
+13 -4
View File
@@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { BrowserMultiFormatReader } from '@zxing/browser';
type BarcodeDetection = { rawValue: string };
type BarcodeDetectorClass = new () => { detect: (source: ImageBitmapSource) => Promise<BarcodeDetection[]> };
export interface BarcodeResult {
code?: string;
error?: string;
@@ -14,15 +17,20 @@ export const useBarcodeScanner = () => {
const reader = new BrowserMultiFormatReader();
let active = true;
let stop: (() => void) | undefined;
let currentVideoElement: HTMLVideoElement | null = null;
const start = async () => {
try {
if ('BarcodeDetector' in window) {
const detector = new (window as typeof window & { BarcodeDetector: any }).BarcodeDetector();
const detectorClass = (
window as typeof window & { BarcodeDetector?: BarcodeDetectorClass }
).BarcodeDetector;
if (detectorClass) {
const detector = new detectorClass();
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
currentVideoElement = videoRef.current;
}
const scan = async () => {
if (!active || !videoRef.current) return;
@@ -58,8 +66,9 @@ export const useBarcodeScanner = () => {
return () => {
active = false;
stop?.();
if (videoRef.current?.srcObject) {
(videoRef.current.srcObject as MediaStream).getTracks().forEach((track) => track.stop());
const stream = currentVideoElement?.srcObject as MediaStream | null;
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
};
}, []);
-1
View File
@@ -4,7 +4,6 @@ export interface Product {
category: string;
unit_type: string;
bulk_name?: string;
units_per_bulk?: number;
barcode?: string;
archived: boolean;
created_at: number;
+38 -9
View File
@@ -6,6 +6,9 @@ import {
TextField,
Typography,
InputAdornment,
Dialog,
DialogTitle,
DialogContent,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import { Link as RouterLink } from 'react-router-dom';
@@ -15,6 +18,7 @@ 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';
export const ManageProductsScreen = () => {
const db = useDatabase();
@@ -23,7 +27,8 @@ export const ManageProductsScreen = () => {
const [search, setSearch] = useState('');
const [name, setName] = useState('');
const [category, setCategory] = useState('');
const [unitsPerBulk, setUnitsPerBulk] = useState(6);
const [barcode, setBarcode] = useState('');
const [scannerOpen, setScannerOpen] = useState(false);
const categoryOptions = useMemo(() => {
const categoryNames = categories.map((item) => item.name);
@@ -54,12 +59,13 @@ export const ManageProductsScreen = () => {
category,
unit_type: DEFAULT_UNIT_TYPE,
bulk_name: DEFAULT_BULK_NAME,
units_per_bulk: unitsPerBulk,
barcode: barcode || undefined,
archived: false,
created_at: Date.now(),
updated_at: Date.now(),
});
setName('');
setBarcode('');
};
const updateProduct = async (
@@ -67,7 +73,7 @@ export const ManageProductsScreen = () => {
updates: {
name: string;
category: string;
units_per_bulk?: number;
barcode?: string;
},
) => {
await db.products.update(productId, {
@@ -113,12 +119,24 @@ export const ManageProductsScreen = () => {
</MenuItem>
))}
</TextField>
<TextField
label="Units per Bulk"
type="number"
value={unitsPerBulk}
onChange={(event) => setUnitsPerBulk(Number(event.target.value))}
/>
{barcode ? (
<TextField
label="Barcode"
value={barcode}
onChange={(event) => setBarcode(event.target.value)}
InputProps={{
endAdornment: (
<Button onClick={() => setBarcode('')} size="small">
Clear
</Button>
),
}}
/>
) : (
<Button variant="outlined" onClick={() => setScannerOpen(true)}>
Scan Barcode
</Button>
)}
<Button variant="contained" onClick={addProduct} disabled={!name || !category}>
Save Product
</Button>
@@ -133,6 +151,17 @@ export const ManageProductsScreen = () => {
/>
))}
</Stack>
<Dialog open={scannerOpen} onClose={() => setScannerOpen(false)} fullWidth>
<DialogTitle>Scan Barcode</DialogTitle>
<DialogContent>
<BarcodeScannerView
onDetected={(code) => {
setBarcode(code);
setScannerOpen(false);
}}
/>
</DialogContent>
</Dialog>
</Container>
);
};