Merge pull request #1 from beatz174-bit/codex/create-full-specification-for-stockfill

Replace PWA icons with SVG asset
This commit is contained in:
beatz174-bit
2025-11-21 15:38:19 +10:00
committed by GitHub
48 changed files with 5594 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
app/node_modules
app/dist
app/.vite
+29
View File
@@ -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"
}
}
}
+14
View File
@@ -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;"]
+7
View File
@@ -0,0 +1,7 @@
version: '3.9'
services:
stockfill:
build: .
ports:
- "8080:80"
restart: unless-stopped
+13
View File
@@ -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>
+11
View File
@@ -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;
}
}
+4213
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -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"
}
}
+15
View File
@@ -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

+16
View File
@@ -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"
}
]
}
+48
View File
@@ -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');
}),
);
});
+48
View File
@@ -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>
);
+27
View File
@@ -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>
);
};
+20
View File
@@ -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>
);
};
+34
View File
@@ -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>
);
+52
View File
@@ -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>
);
};
+26
View File
@@ -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>
);
+18
View File
@@ -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>
);
};
+30
View File
@@ -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;
};
+32
View File
@@ -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);
};
+6
View File
@@ -0,0 +1,6 @@
import { StockFillDB } from './index';
export const applyMigrations = async (db: StockFillDB) => {
// Future migrations can be added here.
await db.open();
};
+54
View File
@@ -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(),
},
]);
}
};
+90
View File
@@ -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;
};
+68
View File
@@ -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 };
};
+44
View File
@@ -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],
);
};
+16
View File
@@ -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;
};
+34
View File
@@ -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,
}),
[],
);
};
+15
View File
@@ -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>,
);
+6
View File
@@ -0,0 +1,6 @@
export interface Area {
id: string;
name: string;
created_at: number;
updated_at: number;
}
+12
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
export interface PickList {
id: string;
area_id: string;
created_at: number;
completed_at?: number;
notes?: string;
}
+12
View File
@@ -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;
}
+16
View File
@@ -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"
}
]
}
+48
View File
@@ -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');
}),
);
});
+84
View File
@@ -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>
);
};
+84
View File
@@ -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>
);
};
+21
View File
@@ -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>
);
};
+27
View File
@@ -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>
);
+40
View File
@@ -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>
);
};
+93
View File
@@ -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>
);
};
+32
View File
@@ -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>
);
};
+46
View File
@@ -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>
);
};
+1
View File
@@ -0,0 +1 @@
export { useBarcodeScanner } from '../hooks/useBarcodeScanner';
+1
View File
@@ -0,0 +1 @@
export { useLongPress } from '../hooks/useLongPress';
+1
View File
@@ -0,0 +1 @@
export { useSwipe } from '../hooks/useSwipe';
+18
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+12
View File
@@ -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',
},
});