Replace binary PWA icons with SVG
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
app/node_modules
|
||||||
|
app/dist
|
||||||
|
app/.vite
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2021": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended"
|
||||||
|
],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"jsx": true
|
||||||
|
},
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": ["@typescript-eslint", "react-refresh", "react-hooks"],
|
||||||
|
"rules": {
|
||||||
|
"react-refresh/only-export-components": ["warn", { "allowConstantExport": true }],
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "warn"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "detect"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* tsconfig*.json vite.config.ts ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY public ./public
|
||||||
|
RUN npm ci --no-audit --no-fund && npm run build
|
||||||
|
|
||||||
|
# Serve stage
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
version: '3.9'
|
||||||
|
services:
|
||||||
|
stockfill:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>StockFill</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4213
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "stockfill",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && cp src/pwa/manifest.json public/manifest.json && cp src/pwa/service-worker.js public/service-worker.js && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.13.0",
|
||||||
|
"@emotion/styled": "^11.13.0",
|
||||||
|
"@mui/icons-material": "^5.16.4",
|
||||||
|
"@mui/material": "^5.16.4",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@zxing/browser": "^0.1.4",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"dexie": "^4.0.7",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.0",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.7",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" role="img" aria-label="StockFill logo">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0d6efd" />
|
||||||
|
<stop offset="100%" stop-color="#0abf9e" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="200" height="200" rx="24" fill="url(#grad)" />
|
||||||
|
<g fill="#ffffff">
|
||||||
|
<rect x="44" y="54" width="112" height="18" rx="9" opacity="0.9" />
|
||||||
|
<rect x="44" y="91" width="112" height="18" rx="9" opacity="0.9" />
|
||||||
|
<rect x="44" y="128" width="92" height="18" rx="9" opacity="0.9" />
|
||||||
|
<circle cx="144" cy="137" r="16" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 661 B |
@@ -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,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';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user