Replace binary PWA icons with SVG
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { CssBaseline, ThemeProvider, createTheme } from '@mui/material';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { DBProvider } from './context/DBProvider';
|
||||
import { HomeScreen } from './screens/HomeScreen';
|
||||
import { StartPickListScreen } from './screens/StartPickListScreen';
|
||||
import { PickListsScreen } from './screens/PickListsScreen';
|
||||
import { ActivePickListScreen } from './screens/ActivePickListScreen';
|
||||
import { AddItemScreen } from './screens/AddItemScreen';
|
||||
import { ManageProductsScreen } from './screens/ManageProductsScreen';
|
||||
import { ManageAreasScreen } from './screens/ManageAreasScreen';
|
||||
import { BarcodeScannerScreen } from './screens/BarcodeScannerScreen';
|
||||
import { useServiceWorker } from './hooks/useServiceWorker';
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#0d6efd',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const AppRoutes = () => {
|
||||
useServiceWorker();
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeScreen />} />
|
||||
<Route path="/start" element={<StartPickListScreen />} />
|
||||
<Route path="/pick-lists" element={<PickListsScreen />} />
|
||||
<Route path="/pick-lists/:id" element={<ActivePickListScreen />} />
|
||||
<Route path="/pick-lists/:id/add-item" element={<AddItemScreen />} />
|
||||
<Route path="/products" element={<ManageProductsScreen />} />
|
||||
<Route path="/areas" element={<ManageAreasScreen />} />
|
||||
<Route path="/scan" element={<BarcodeScannerScreen />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<BrowserRouter>
|
||||
<DBProvider>
|
||||
<AppRoutes />
|
||||
</DBProvider>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Alert, Card, CardContent, Typography } from '@mui/material';
|
||||
import { useBarcodeScanner } from '../hooks/useBarcodeScanner';
|
||||
|
||||
interface BarcodeScannerViewProps {
|
||||
onDetected?: (code: string) => void;
|
||||
}
|
||||
|
||||
export const BarcodeScannerView = ({ onDetected }: BarcodeScannerViewProps) => {
|
||||
const { videoRef, result } = useBarcodeScanner();
|
||||
|
||||
if (result.code && onDetected) {
|
||||
onDetected(result.code);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Scan Barcode
|
||||
</Typography>
|
||||
<video ref={videoRef} style={{ width: '100%', borderRadius: 8 }} />
|
||||
{result.code ? <Alert severity="success">Detected {result.code}</Alert> : null}
|
||||
{result.error ? <Alert severity="error">{result.error}</Alert> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Button, ButtonProps } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useLongPress } from '../hooks/useLongPress';
|
||||
|
||||
interface LongPressButtonProps extends ButtonProps {
|
||||
onLongPress: () => void;
|
||||
}
|
||||
|
||||
export const LongPressButton = ({ onLongPress, onClick, children, ...rest }: LongPressButtonProps) => {
|
||||
const gestureHandlers = useLongPress({
|
||||
onLongPress,
|
||||
onClick: onClick ? () => onClick({} as React.MouseEvent<HTMLButtonElement>) : undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<Button {...rest} {...gestureHandlers} onClick={undefined}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IconButton, Stack, TextField } from '@mui/material';
|
||||
import RemoveIcon from '@mui/icons-material/Remove';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
|
||||
interface NumericStepperProps {
|
||||
label?: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
}
|
||||
|
||||
export const NumericStepper = ({ label, value, onChange, min = 0 }: NumericStepperProps) => (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<IconButton
|
||||
aria-label={`decrease ${label ?? 'value'}`}
|
||||
onClick={() => onChange(Math.max(min, value - 1))}
|
||||
size="small"
|
||||
>
|
||||
<RemoveIcon />
|
||||
</IconButton>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
inputProps={{ min }}
|
||||
sx={{ width: 120 }}
|
||||
/>
|
||||
<IconButton aria-label={`increase ${label ?? 'value'}`} onClick={() => onChange(value + 1)} size="small">
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Chip, Stack, Typography } from '@mui/material';
|
||||
import { PickItem, PickItemStatus } from '../models/PickItem';
|
||||
import { Product } from '../models/Product';
|
||||
import { useLongPress } from '../hooks/useLongPress';
|
||||
import { useSwipe } from '../hooks/useSwipe';
|
||||
|
||||
interface PickItemRowProps {
|
||||
item: PickItem;
|
||||
product?: Product | null;
|
||||
onIncrementUnit: () => void;
|
||||
onIncrementBulk: () => void;
|
||||
onSwipeLeft: () => void;
|
||||
onSwipeRight: () => void;
|
||||
}
|
||||
|
||||
const statusColor: Record<PickItemStatus, 'default' | 'success' | 'warning'> = {
|
||||
pending: 'default',
|
||||
picked: 'success',
|
||||
skipped: 'warning',
|
||||
};
|
||||
|
||||
export const PickItemRow = ({
|
||||
item,
|
||||
product,
|
||||
onIncrementUnit,
|
||||
onIncrementBulk,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
}: PickItemRowProps) => {
|
||||
const longPressHandlers = useLongPress({ onLongPress: onIncrementBulk, onClick: onIncrementUnit });
|
||||
const swipeHandlers = useSwipe({ onSwipeLeft, onSwipeRight });
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
spacing={1}
|
||||
sx={{ p: 1, borderRadius: 1, bgcolor: 'background.paper', boxShadow: 1 }}
|
||||
{...longPressHandlers}
|
||||
{...swipeHandlers}
|
||||
>
|
||||
<div>
|
||||
<Typography variant="subtitle1">{product?.name ?? 'Unknown product'}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.quantity_units} units / {item.quantity_bulk} bulk
|
||||
</Typography>
|
||||
</div>
|
||||
<Chip label={item.status} color={statusColor[item.status]} size="small" />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Card, CardContent, Stack, Typography } from '@mui/material';
|
||||
import { Product } from '../models/Product';
|
||||
|
||||
interface ProductRowProps {
|
||||
product: Product;
|
||||
}
|
||||
|
||||
export const ProductRow = ({ product }: ProductRowProps) => (
|
||||
<Card variant="outlined" sx={{ mb: 1 }}>
|
||||
<CardContent>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<div>
|
||||
<Typography variant="subtitle1">{product.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{product.category} • {product.unit_type}
|
||||
</Typography>
|
||||
</div>
|
||||
{product.bulk_name && product.units_per_bulk ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{product.units_per_bulk} per {product.bulk_name}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Box, Paper } from '@mui/material';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { useSwipe } from '../hooks/useSwipe';
|
||||
|
||||
interface SwipeableRowProps extends PropsWithChildren {
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
}
|
||||
|
||||
export const SwipeableRow = ({ children, onSwipeLeft, onSwipeRight }: SwipeableRowProps) => {
|
||||
const gestureHandlers = useSwipe({ onSwipeLeft, onSwipeRight });
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 1, mb: 1 }} {...gestureHandlers}>
|
||||
<Box>{children}</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
|
||||
import { db, initializeDatabase, StockFillDB } from '../db';
|
||||
|
||||
const DatabaseContext = createContext<StockFillDB | null>(null);
|
||||
|
||||
export const DBProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const setup = async () => {
|
||||
await initializeDatabase();
|
||||
setReady(true);
|
||||
};
|
||||
void setup();
|
||||
}, []);
|
||||
|
||||
if (!ready) {
|
||||
return <div>Loading database...</div>;
|
||||
}
|
||||
|
||||
return <DatabaseContext.Provider value={db}>{children}</DatabaseContext.Provider>;
|
||||
};
|
||||
|
||||
export const useDatabase = () => {
|
||||
const instance = useContext(DatabaseContext);
|
||||
if (!instance) {
|
||||
throw new Error('Database not available');
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import Dexie, { Table } from 'dexie';
|
||||
import { Area } from '../models/Area';
|
||||
import { PickItem } from '../models/PickItem';
|
||||
import { PickList } from '../models/PickList';
|
||||
import { Product } from '../models/Product';
|
||||
import { applyMigrations } from './migrations';
|
||||
import { seedDatabase } from './seed';
|
||||
|
||||
export class StockFillDB extends Dexie {
|
||||
products!: Table<Product>;
|
||||
areas!: Table<Area>;
|
||||
pickLists!: Table<PickList>;
|
||||
pickItems!: Table<PickItem>;
|
||||
|
||||
constructor() {
|
||||
super('stockfill');
|
||||
this.version(1).stores({
|
||||
products:
|
||||
'id, name, category, barcode, archived, created_at, updated_at',
|
||||
areas: 'id, name, created_at, updated_at',
|
||||
pickLists: 'id, area_id, created_at, completed_at',
|
||||
pickItems: 'id, pick_list_id, product_id, status, created_at, updated_at',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new StockFillDB();
|
||||
|
||||
export const initializeDatabase = async () => {
|
||||
await applyMigrations(db);
|
||||
await seedDatabase(db);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { StockFillDB } from './index';
|
||||
|
||||
export const applyMigrations = async (db: StockFillDB) => {
|
||||
// Future migrations can be added here.
|
||||
await db.open();
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { StockFillDB } from './index';
|
||||
|
||||
const now = () => Date.now();
|
||||
|
||||
export const seedDatabase = async (db: StockFillDB) => {
|
||||
const areaCount = await db.areas.count();
|
||||
if (areaCount === 0) {
|
||||
await db.areas.bulkAdd([
|
||||
{ id: uuidv4(), name: 'Drinks', created_at: now(), updated_at: now() },
|
||||
{ id: uuidv4(), name: 'Snacks', created_at: now(), updated_at: now() },
|
||||
{ id: uuidv4(), name: 'Dairy', created_at: now(), updated_at: now() },
|
||||
]);
|
||||
}
|
||||
|
||||
const productCount = await db.products.count();
|
||||
if (productCount === 0) {
|
||||
await db.products.bulkAdd([
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: 'Sparkling Water 500ml',
|
||||
category: 'Drinks',
|
||||
unit_type: 'bottle',
|
||||
bulk_name: 'case',
|
||||
units_per_bulk: 12,
|
||||
archived: false,
|
||||
created_at: now(),
|
||||
updated_at: now(),
|
||||
},
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: 'Salted Chips 50g',
|
||||
category: 'Snacks',
|
||||
unit_type: 'bag',
|
||||
bulk_name: 'box',
|
||||
units_per_bulk: 24,
|
||||
archived: false,
|
||||
created_at: now(),
|
||||
updated_at: now(),
|
||||
},
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: 'Chocolate Bar',
|
||||
category: 'Confectionery',
|
||||
unit_type: 'bar',
|
||||
bulk_name: 'slab',
|
||||
units_per_bulk: 32,
|
||||
archived: false,
|
||||
created_at: now(),
|
||||
updated_at: now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import { liveQuery } from 'dexie';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDatabase } from '../context/DBProvider';
|
||||
import { Area } from '../models/Area';
|
||||
import { PickItem } from '../models/PickItem';
|
||||
import { PickList } from '../models/PickList';
|
||||
import { Product } from '../models/Product';
|
||||
|
||||
export const useProducts = () => {
|
||||
const db = useDatabase();
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = liveQuery(() => db.products.toArray()).subscribe({
|
||||
next: setProducts,
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [db]);
|
||||
return products;
|
||||
};
|
||||
|
||||
export const useProduct = (id?: string) => {
|
||||
const db = useDatabase();
|
||||
const [product, setProduct] = useState<Product | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const subscription = liveQuery(() => db.products.get(id)).subscribe({
|
||||
next: (value) => setProduct(value ?? undefined),
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [db, id]);
|
||||
return product;
|
||||
};
|
||||
|
||||
export const useAreas = () => {
|
||||
const db = useDatabase();
|
||||
const [areas, setAreas] = useState<Area[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = liveQuery(() => db.areas.toArray()).subscribe({
|
||||
next: setAreas,
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [db]);
|
||||
return areas;
|
||||
};
|
||||
|
||||
export const usePickLists = () => {
|
||||
const db = useDatabase();
|
||||
const [lists, setLists] = useState<PickList[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = liveQuery(() => db.pickLists.toArray()).subscribe({
|
||||
next: setLists,
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [db]);
|
||||
return lists;
|
||||
};
|
||||
|
||||
export const usePickList = (id?: string) => {
|
||||
const db = useDatabase();
|
||||
const [list, setList] = useState<PickList | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const subscription = liveQuery(() => db.pickLists.get(id)).subscribe({
|
||||
next: (value) => setList(value ?? undefined),
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [db, id]);
|
||||
return list;
|
||||
};
|
||||
|
||||
export const usePickItems = (pickListId?: string) => {
|
||||
const db = useDatabase();
|
||||
const [items, setItems] = useState<PickItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickListId) return undefined;
|
||||
const subscription = liveQuery(() =>
|
||||
db.pickItems.where('pick_list_id').equals(pickListId).toArray(),
|
||||
).subscribe({
|
||||
next: setItems,
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [db, pickListId]);
|
||||
return items;
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { BrowserMultiFormatReader } from '@zxing/browser';
|
||||
|
||||
export interface BarcodeResult {
|
||||
code?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const useBarcodeScanner = () => {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [result, setResult] = useState<BarcodeResult>({});
|
||||
|
||||
useEffect(() => {
|
||||
const reader = new BrowserMultiFormatReader();
|
||||
let active = true;
|
||||
let stop: (() => void) | undefined;
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
if ('BarcodeDetector' in window) {
|
||||
const detector = new (window as typeof window & { BarcodeDetector: any }).BarcodeDetector();
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
}
|
||||
const scan = async () => {
|
||||
if (!active || !videoRef.current) return;
|
||||
const tracks = (videoRef.current.srcObject as MediaStream | null)?.getVideoTracks();
|
||||
if (!tracks || tracks.length === 0) return;
|
||||
const frame = await createImageBitmap(videoRef.current);
|
||||
const codes = await detector.detect(frame);
|
||||
if (codes.length > 0) {
|
||||
setResult({ code: codes[0].rawValue });
|
||||
} else {
|
||||
requestAnimationFrame(scan);
|
||||
}
|
||||
};
|
||||
void scan();
|
||||
} else {
|
||||
const controls = await reader.decodeFromVideoDevice(
|
||||
undefined,
|
||||
videoRef.current ?? undefined,
|
||||
(decoded) => {
|
||||
if (decoded) {
|
||||
setResult({ code: decoded.getText() });
|
||||
}
|
||||
},
|
||||
);
|
||||
stop = () => controls.stop();
|
||||
}
|
||||
} catch (error) {
|
||||
setResult({ error: (error as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
void start();
|
||||
return () => {
|
||||
active = false;
|
||||
stop?.();
|
||||
if (videoRef.current?.srcObject) {
|
||||
(videoRef.current.srcObject as MediaStream).getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { videoRef, result };
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
|
||||
interface LongPressOptions {
|
||||
delay?: number;
|
||||
onLongPress: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const useLongPress = ({ delay = 500, onLongPress, onClick }: LongPressOptions) => {
|
||||
const timerRef = useRef<number>();
|
||||
const handledRef = useRef(false);
|
||||
|
||||
const start = useCallback(() => {
|
||||
handledRef.current = false;
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
handledRef.current = true;
|
||||
onLongPress();
|
||||
}, delay);
|
||||
}, [delay, onLongPress]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onRelease = useCallback(() => {
|
||||
clear();
|
||||
if (!handledRef.current && onClick) {
|
||||
onClick();
|
||||
}
|
||||
}, [clear, onClick]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
onMouseDown: start,
|
||||
onTouchStart: start,
|
||||
onMouseUp: onRelease,
|
||||
onMouseLeave: clear,
|
||||
onTouchEnd: onRelease,
|
||||
}),
|
||||
[start, onRelease, clear],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useServiceWorker = () => {
|
||||
const [registered, setRegistered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker
|
||||
.register('/service-worker.js')
|
||||
.then(() => setRegistered(true))
|
||||
.catch(() => setRegistered(false));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return registered;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
interface SwipeConfig {
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export const useSwipe = ({ onSwipeLeft, onSwipeRight, threshold = 50 }: SwipeConfig) => {
|
||||
const startX = useRef<number | null>(null);
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
startX.current = e.changedTouches[0].screenX;
|
||||
};
|
||||
|
||||
const onTouchEnd = (e: React.TouchEvent) => {
|
||||
if (startX.current === null) return;
|
||||
const deltaX = e.changedTouches[0].screenX - startX.current;
|
||||
if (deltaX < -threshold) {
|
||||
onSwipeLeft?.();
|
||||
} else if (deltaX > threshold) {
|
||||
onSwipeRight?.();
|
||||
}
|
||||
startX.current = null;
|
||||
};
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
onTouchStart,
|
||||
onTouchEnd,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
|
||||
if (!root) {
|
||||
throw new Error('Root element not found');
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface Area {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export type PickItemStatus = 'pending' | 'picked' | 'skipped';
|
||||
|
||||
export interface PickItem {
|
||||
id: string;
|
||||
pick_list_id: string;
|
||||
product_id: string;
|
||||
quantity_units: number;
|
||||
quantity_bulk: number;
|
||||
status: PickItemStatus;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface PickList {
|
||||
id: string;
|
||||
area_id: string;
|
||||
created_at: number;
|
||||
completed_at?: number;
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
unit_type: string;
|
||||
bulk_name?: string;
|
||||
units_per_bulk?: number;
|
||||
barcode?: string;
|
||||
archived: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "StockFill",
|
||||
"short_name": "StockFill",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#0d6efd",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
const CACHE_NAME = 'stockfill-cache-v1';
|
||||
const OFFLINE_URLS = ['/', '/index.html'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(OFFLINE_URLS);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.map((key) => {
|
||||
if (key !== CACHE_NAME) {
|
||||
return caches.delete(key);
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
|
||||
return response;
|
||||
})
|
||||
.catch(async () => {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
if (request.mode === 'navigate') {
|
||||
const fallback = await caches.match('/index.html');
|
||||
if (fallback) return fallback;
|
||||
}
|
||||
throw new Error('Network error');
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Button, Container, Stack, Typography } from '@mui/material';
|
||||
import { useParams, useNavigate, Link as RouterLink } from 'react-router-dom';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useAreas, usePickItems, usePickList, useProducts } from '../hooks/dataHooks';
|
||||
import { useDatabase } from '../context/DBProvider';
|
||||
import { PickItemRow } from '../components/PickItemRow';
|
||||
|
||||
export const ActivePickListScreen = () => {
|
||||
const { id } = useParams();
|
||||
const pickList = usePickList(id);
|
||||
const items = usePickItems(id);
|
||||
const products = useProducts();
|
||||
const areas = useAreas();
|
||||
const db = useDatabase();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !pickList) return;
|
||||
}, [id, pickList]);
|
||||
|
||||
const areaName = useMemo(
|
||||
() => areas.find((area) => area.id === pickList?.area_id)?.name ?? 'Area',
|
||||
[areas, pickList?.area_id],
|
||||
);
|
||||
|
||||
const handleIncrementUnit = async (itemId: string) => {
|
||||
const existing = await db.pickItems.get(itemId);
|
||||
if (!existing) return;
|
||||
await db.pickItems.update(itemId, {
|
||||
quantity_units: existing.quantity_units + 1,
|
||||
updated_at: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleIncrementBulk = async (itemId: string) => {
|
||||
const existing = await db.pickItems.get(itemId);
|
||||
if (!existing) return;
|
||||
await db.pickItems.update(itemId, {
|
||||
quantity_bulk: existing.quantity_bulk + 1,
|
||||
updated_at: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSwipeLeft = async (itemId: string) => {
|
||||
await db.pickItems.update(itemId, { status: 'picked', updated_at: Date.now() });
|
||||
};
|
||||
|
||||
const handleSwipeRight = async (itemId: string) => {
|
||||
await db.pickItems.delete(itemId);
|
||||
};
|
||||
|
||||
const completeList = async () => {
|
||||
if (!id) return;
|
||||
await db.pickLists.update(id, { completed_at: Date.now() });
|
||||
navigate('/pick-lists');
|
||||
};
|
||||
|
||||
return (
|
||||
<Container sx={{ py: 4 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h5">{areaName} List</Typography>
|
||||
<Button component={RouterLink} to={`/pick-lists/${id}/add-item`} variant="contained">
|
||||
Add Item
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
{items.map((item) => (
|
||||
<PickItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
product={products.find((p) => p.id === item.product_id)}
|
||||
onIncrementUnit={() => handleIncrementUnit(item.id)}
|
||||
onIncrementBulk={() => handleIncrementBulk(item.id)}
|
||||
onSwipeLeft={() => handleSwipeLeft(item.id)}
|
||||
onSwipeRight={() => handleSwipeRight(item.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Button fullWidth sx={{ mt: 3 }} variant="outlined" onClick={completeList}>
|
||||
Complete List
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
MenuItem,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { NumericStepper } from '../components/NumericStepper';
|
||||
import { useProducts } from '../hooks/dataHooks';
|
||||
import { useDatabase } from '../context/DBProvider';
|
||||
|
||||
export const AddItemScreen = () => {
|
||||
const { id } = useParams();
|
||||
const db = useDatabase();
|
||||
const products = useProducts();
|
||||
const [productId, setProductId] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [units, setUnits] = useState(1);
|
||||
const [bulk, setBulk] = useState(0);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const filteredProducts = useMemo(
|
||||
() =>
|
||||
products.filter((product) =>
|
||||
`${product.name} ${product.category}`.toLowerCase().includes(query.toLowerCase()),
|
||||
),
|
||||
[products, query],
|
||||
);
|
||||
|
||||
const addItem = async () => {
|
||||
if (!id || !productId) return;
|
||||
await db.pickItems.add({
|
||||
id: uuidv4(),
|
||||
pick_list_id: id,
|
||||
product_id: productId,
|
||||
quantity_units: units,
|
||||
quantity_bulk: bulk,
|
||||
status: 'pending',
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
});
|
||||
navigate(`/pick-lists/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container sx={{ py: 4 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Add Item
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
placeholder="Search products"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">{<SearchIcon />}</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="Product"
|
||||
fullWidth
|
||||
value={productId}
|
||||
onChange={(event) => setProductId(event.target.value)}
|
||||
>
|
||||
{filteredProducts.map((product) => (
|
||||
<MenuItem key={product.id} value={product.id}>
|
||||
{product.name} ({product.category})
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<NumericStepper label="Units" value={units} onChange={setUnits} />
|
||||
<NumericStepper label="Bulk" value={bulk} onChange={setBulk} />
|
||||
<Button variant="contained" disabled={!productId} onClick={addItem}>
|
||||
Add to List
|
||||
</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Container, Typography } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { BarcodeScannerView } from '../components/BarcodeScannerView';
|
||||
|
||||
export const BarcodeScannerScreen = () => {
|
||||
const [lastCode, setLastCode] = useState('');
|
||||
|
||||
return (
|
||||
<Container sx={{ py: 4 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Barcode Scanner
|
||||
</Typography>
|
||||
<BarcodeScannerView onDetected={(code) => setLastCode(code)} />
|
||||
{lastCode ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
Last detected: {lastCode}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Button, Container, Stack, Typography } from '@mui/material';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
export const HomeScreen = () => (
|
||||
<Container sx={{ py: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
StockFill
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<Button component={RouterLink} to="/start" variant="contained">
|
||||
Start New Pick List
|
||||
</Button>
|
||||
<Button component={RouterLink} to="/pick-lists" variant="outlined">
|
||||
View Pick Lists
|
||||
</Button>
|
||||
<Button component={RouterLink} to="/products" variant="outlined">
|
||||
Manage Products
|
||||
</Button>
|
||||
<Button component={RouterLink} to="/areas" variant="outlined">
|
||||
Manage Areas
|
||||
</Button>
|
||||
<Button component={RouterLink} to="/scan" variant="outlined">
|
||||
Scan Barcode
|
||||
</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Button, Container, List, ListItem, ListItemText, Stack, TextField, Typography } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useAreas } from '../hooks/dataHooks';
|
||||
import { useDatabase } from '../context/DBProvider';
|
||||
|
||||
export const ManageAreasScreen = () => {
|
||||
const db = useDatabase();
|
||||
const areas = useAreas();
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const addArea = async () => {
|
||||
if (!name) return;
|
||||
await db.areas.add({ id: uuidv4(), name, created_at: Date.now(), updated_at: Date.now() });
|
||||
setName('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Container sx={{ py: 4 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Manage Areas
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField fullWidth label="Area name" value={name} onChange={(event) => setName(event.target.value)} />
|
||||
<Button variant="contained" onClick={addArea} disabled={!name}>
|
||||
Add
|
||||
</Button>
|
||||
</Stack>
|
||||
<List>
|
||||
{areas.map((area) => (
|
||||
<ListItem key={area.id} divider>
|
||||
<ListItemText primary={area.name} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
MenuItem,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
InputAdornment,
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ProductRow } from '../components/ProductRow';
|
||||
import { useProducts } from '../hooks/dataHooks';
|
||||
import { useDatabase } from '../context/DBProvider';
|
||||
|
||||
const categories = ['Drinks', 'Snacks', 'Dairy', 'Confectionery'];
|
||||
|
||||
export const ManageProductsScreen = () => {
|
||||
const db = useDatabase();
|
||||
const products = useProducts();
|
||||
const [search, setSearch] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [category, setCategory] = useState(categories[0]);
|
||||
const [unitType, setUnitType] = useState('unit');
|
||||
const [bulkName, setBulkName] = useState('case');
|
||||
const [unitsPerBulk, setUnitsPerBulk] = useState(6);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
products.filter((p) =>
|
||||
`${p.name} ${p.category}`.toLowerCase().includes(search.toLowerCase()),
|
||||
),
|
||||
[products, search],
|
||||
);
|
||||
|
||||
const addProduct = async () => {
|
||||
if (!name) return;
|
||||
await db.products.add({
|
||||
id: uuidv4(),
|
||||
name,
|
||||
category,
|
||||
unit_type: unitType,
|
||||
bulk_name: bulkName,
|
||||
units_per_bulk: unitsPerBulk,
|
||||
archived: false,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
});
|
||||
setName('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Container sx={{ py: 4 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Manage Products
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<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)} />
|
||||
<TextField select label="Category" value={category} onChange={(event) => setCategory(event.target.value)}>
|
||||
{categories.map((cat) => (
|
||||
<MenuItem key={cat} value={cat}>
|
||||
{cat}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField label="Unit Type" value={unitType} onChange={(event) => setUnitType(event.target.value)} />
|
||||
<TextField label="Bulk Name" value={bulkName} onChange={(event) => setBulkName(event.target.value)} />
|
||||
<TextField
|
||||
label="Units per Bulk"
|
||||
type="number"
|
||||
value={unitsPerBulk}
|
||||
onChange={(event) => setUnitsPerBulk(Number(event.target.value))}
|
||||
/>
|
||||
<Button variant="contained" onClick={addProduct} disabled={!name}>
|
||||
Save Product
|
||||
</Button>
|
||||
</Stack>
|
||||
{filtered.map((product) => (
|
||||
<ProductRow key={product.id} product={product} />
|
||||
))}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Button, Container, List, ListItemButton, ListItemText, Typography } from '@mui/material';
|
||||
import { format } from 'date-fns';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { usePickLists, useAreas } from '../hooks/dataHooks';
|
||||
|
||||
export const PickListsScreen = () => {
|
||||
const lists = usePickLists();
|
||||
const areas = useAreas();
|
||||
|
||||
const getAreaName = (areaId: string) => areas.find((a) => a.id === areaId)?.name ?? 'Unknown area';
|
||||
|
||||
return (
|
||||
<Container sx={{ py: 4 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Pick Lists
|
||||
</Typography>
|
||||
<Button component={RouterLink} to="/start" variant="contained" sx={{ mb: 2 }}>
|
||||
Start New
|
||||
</Button>
|
||||
<List>
|
||||
{lists.map((list) => (
|
||||
<ListItemButton key={list.id} component={RouterLink} to={`/pick-lists/${list.id}`} divider>
|
||||
<ListItemText
|
||||
primary={getAreaName(list.area_id)}
|
||||
secondary={format(list.created_at, 'PPpp')}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Button, Container, MenuItem, Stack, TextField, Typography } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useAreas } from '../hooks/dataHooks';
|
||||
import { useDatabase } from '../context/DBProvider';
|
||||
|
||||
export const StartPickListScreen = () => {
|
||||
const areas = useAreas();
|
||||
const db = useDatabase();
|
||||
const navigate = useNavigate();
|
||||
const [areaId, setAreaId] = useState('');
|
||||
|
||||
const start = async () => {
|
||||
if (!areaId) return;
|
||||
const pickListId = uuidv4();
|
||||
await db.pickLists.add({ id: pickListId, area_id: areaId, created_at: Date.now() });
|
||||
navigate(`/pick-lists/${pickListId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container sx={{ py: 4 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Start Pick List
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
select
|
||||
fullWidth
|
||||
label="Area"
|
||||
value={areaId}
|
||||
onChange={(event) => setAreaId(event.target.value)}
|
||||
>
|
||||
{areas.map((area) => (
|
||||
<MenuItem key={area.id} value={area.id}>
|
||||
{area.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<Button variant="contained" disabled={!areaId} onClick={start}>
|
||||
Start
|
||||
</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { useBarcodeScanner } from '../hooks/useBarcodeScanner';
|
||||
@@ -0,0 +1 @@
|
||||
export { useLongPress } from '../hooks/useLongPress';
|
||||
@@ -0,0 +1 @@
|
||||
export { useSwipe } from '../hooks/useSwipe';
|
||||
Reference in New Issue
Block a user