diff --git a/src/App.tsx b/src/App.tsx
index 5d2ee4e..2bf67d6 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -9,6 +9,7 @@ import { ActivePickListScreen } from './screens/ActivePickListScreen';
import { AddItemScreen } from './screens/AddItemScreen';
import { ManageProductsScreen } from './screens/ManageProductsScreen';
import { ManageAreasScreen } from './screens/ManageAreasScreen';
+import { ManageCategoriesScreen } from './screens/ManageCategoriesScreen';
import { BarcodeScannerScreen } from './screens/BarcodeScannerScreen';
import { useServiceWorker } from './hooks/useServiceWorker';
@@ -32,6 +33,7 @@ const AppRoutes = () => {
} />
} />
} />
+ } />
} />
} />
diff --git a/src/components/NavigationBar.tsx b/src/components/NavigationBar.tsx
index b7cc3fe..b3811ff 100644
--- a/src/components/NavigationBar.tsx
+++ b/src/components/NavigationBar.tsx
@@ -6,6 +6,7 @@ const navLinks = [
{ to: '/start', label: 'Start' },
{ to: '/pick-lists', label: 'Pick Lists' },
{ to: '/products', label: 'Products' },
+ { to: '/categories', label: 'Categories' },
{ to: '/areas', label: 'Areas' },
{ to: '/scan', label: 'Scan' },
];
diff --git a/src/db/index.ts b/src/db/index.ts
index 4038369..b5d50ac 100644
--- a/src/db/index.ts
+++ b/src/db/index.ts
@@ -1,5 +1,7 @@
import Dexie, { Table } from 'dexie';
+import { v4 as uuidv4 } from 'uuid';
import { Area } from '../models/Area';
+import { Category } from '../models/Category';
import { PickItem } from '../models/PickItem';
import { PickList } from '../models/PickList';
import { Product } from '../models/Product';
@@ -11,6 +13,7 @@ export class StockFillDB extends Dexie {
areas!: Table;
pickLists!: Table;
pickItems!: Table;
+ categories!: Table;
constructor() {
super('stockfill');
@@ -21,6 +24,33 @@ export class StockFillDB extends Dexie {
pickLists: 'id, area_id, created_at, completed_at',
pickItems: 'id, pick_list_id, product_id, status, created_at, updated_at',
});
+ this.version(2)
+ .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',
+ categories: 'id, name, created_at, updated_at',
+ })
+ .upgrade(async (tx) => {
+ const existingCategories = await tx.table('categories').count();
+ if (existingCategories > 0) return;
+
+ const products = await tx.table('products').toArray();
+ const uniqueCategories = Array.from(new Set(products.map((product) => product.category)));
+ if (uniqueCategories.length === 0) return;
+
+ const now = Date.now();
+ await tx.table('categories').bulkAdd(
+ uniqueCategories.map((name: string) => ({
+ id: uuidv4(),
+ name,
+ created_at: now,
+ updated_at: now,
+ })),
+ );
+ });
}
}
diff --git a/src/db/seed.ts b/src/db/seed.ts
index 4114d77..57cc4cd 100644
--- a/src/db/seed.ts
+++ b/src/db/seed.ts
@@ -13,6 +13,16 @@ export const seedDatabase = async (db: StockFillDB) => {
]);
}
+ const categoryCount = await db.categories.count();
+ if (categoryCount === 0) {
+ await db.categories.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() },
+ { id: uuidv4(), name: 'Confectionery', created_at: now(), updated_at: now() },
+ ]);
+ }
+
const productCount = await db.products.count();
if (productCount === 0) {
await db.products.bulkAdd([
diff --git a/src/hooks/dataHooks.ts b/src/hooks/dataHooks.ts
index f4e1c85..a5bc4c1 100644
--- a/src/hooks/dataHooks.ts
+++ b/src/hooks/dataHooks.ts
@@ -2,6 +2,7 @@ import { liveQuery } from 'dexie';
import { useEffect, useState } from 'react';
import { useDatabase } from '../context/DBProvider';
import { Area } from '../models/Area';
+import { Category } from '../models/Category';
import { PickItem } from '../models/PickItem';
import { PickList } from '../models/PickList';
import { Product } from '../models/Product';
@@ -46,6 +47,19 @@ export const useAreas = () => {
return areas;
};
+export const useCategories = () => {
+ const db = useDatabase();
+ const [categories, setCategories] = useState([]);
+
+ useEffect(() => {
+ const subscription = liveQuery(() => db.categories.toArray()).subscribe({
+ next: setCategories,
+ });
+ return () => subscription.unsubscribe();
+ }, [db]);
+ return categories;
+};
+
export const usePickLists = () => {
const db = useDatabase();
const [lists, setLists] = useState([]);
diff --git a/src/models/Category.ts b/src/models/Category.ts
new file mode 100644
index 0000000..408a8cc
--- /dev/null
+++ b/src/models/Category.ts
@@ -0,0 +1,6 @@
+export interface Category {
+ id: string;
+ name: string;
+ created_at: number;
+ updated_at: number;
+}
diff --git a/src/screens/ManageCategoriesScreen.tsx b/src/screens/ManageCategoriesScreen.tsx
new file mode 100644
index 0000000..9ea838a
--- /dev/null
+++ b/src/screens/ManageCategoriesScreen.tsx
@@ -0,0 +1,172 @@
+import {
+ Alert,
+ AlertColor,
+ Button,
+ Container,
+ IconButton,
+ List,
+ ListItem,
+ ListItemText,
+ Stack,
+ TextField,
+ Typography,
+} from '@mui/material';
+import CheckIcon from '@mui/icons-material/Check';
+import CloseIcon from '@mui/icons-material/Close';
+import DeleteIcon from '@mui/icons-material/Delete';
+import EditIcon from '@mui/icons-material/Edit';
+import { useMemo, useState } from 'react';
+import { v4 as uuidv4 } from 'uuid';
+import { useDatabase } from '../context/DBProvider';
+import { useCategories, useProducts } from '../hooks/dataHooks';
+
+export const ManageCategoriesScreen = () => {
+ const db = useDatabase();
+ const categories = useCategories();
+ const products = useProducts();
+ const [name, setName] = useState('');
+ const [editingCategoryId, setEditingCategoryId] = useState(null);
+ const [editName, setEditName] = useState('');
+ const [feedback, setFeedback] = useState<{ text: string; severity: AlertColor } | null>(null);
+
+ const usageByCategory = useMemo(() => {
+ return products.reduce>((acc, product) => {
+ acc[product.category] = (acc[product.category] ?? 0) + 1;
+ return acc;
+ }, {});
+ }, [products]);
+
+ const addCategory = async () => {
+ const trimmed = name.trim();
+ if (!trimmed) return;
+ const exists = categories.some((category) => category.name.toLowerCase() === trimmed.toLowerCase());
+ if (exists) {
+ setFeedback({ text: 'A category with this name already exists.', severity: 'error' });
+ return;
+ }
+ await db.categories.add({ id: uuidv4(), name: trimmed, created_at: Date.now(), updated_at: Date.now() });
+ setName('');
+ setFeedback({ text: 'Category added.', severity: 'success' });
+ };
+
+ const startEditing = (categoryId: string, currentName: string) => {
+ setEditingCategoryId(categoryId);
+ setEditName(currentName);
+ setFeedback(null);
+ };
+
+ const saveCategory = async () => {
+ if (!editingCategoryId) return;
+ const trimmed = editName.trim();
+ if (!trimmed) return;
+
+ const category = categories.find((item) => item.id === editingCategoryId);
+ if (!category) return;
+
+ const nameExists = categories.some(
+ (item) => item.id !== editingCategoryId && item.name.toLowerCase() === trimmed.toLowerCase(),
+ );
+ if (nameExists) {
+ setFeedback({ text: 'A category with this name already exists.', severity: 'error' });
+ return;
+ }
+
+ await db.transaction('rw', db.categories, db.products, async () => {
+ await db.categories.update(editingCategoryId, { name: trimmed, updated_at: Date.now() });
+ await db.products
+ .where('category')
+ .equals(category.name)
+ .modify({ category: trimmed, updated_at: Date.now() });
+ });
+
+ setEditingCategoryId(null);
+ setEditName('');
+ setFeedback({ text: 'Category updated. Linked products were refreshed.', severity: 'success' });
+ };
+
+ const cancelEditing = () => {
+ setEditingCategoryId(null);
+ setEditName('');
+ setFeedback(null);
+ };
+
+ const deleteCategory = async (categoryId: string, categoryName: string) => {
+ const usageCount = usageByCategory[categoryName] ?? 0;
+ if (usageCount > 0) {
+ setFeedback({
+ text: `Cannot delete '${categoryName}' while ${usageCount} product(s) use it. Update those products first.`,
+ severity: 'error',
+ });
+ return;
+ }
+ await db.categories.delete(categoryId);
+ if (editingCategoryId === categoryId) {
+ cancelEditing();
+ }
+ setFeedback({ text: 'Category deleted.', severity: 'success' });
+ };
+
+ return (
+
+
+ Manage Categories
+
+
+ {feedback ? {feedback.text} : null}
+
+ setName(event.target.value)}
+ />
+
+
+
+ {categories.map((category) => (
+
+ {editingCategoryId === category.id ? (
+
+ setEditName(event.target.value)}
+ />
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ startEditing(category.id, category.name)}
+ aria-label={`Edit ${category.name}`}
+ size="small"
+ >
+
+
+ deleteCategory(category.id, category.name)}
+ aria-label={`Delete ${category.name}`}
+ size="small"
+ >
+
+
+
+
+ )}
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/screens/ManageProductsScreen.tsx b/src/screens/ManageProductsScreen.tsx
index 451fd15..b9b4dbe 100644
--- a/src/screens/ManageProductsScreen.tsx
+++ b/src/screens/ManageProductsScreen.tsx
@@ -8,24 +8,37 @@ import {
InputAdornment,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
-import { useMemo, useState } from 'react';
+import { Link as RouterLink } from 'react-router-dom';
+import { useEffect, useMemo, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { ProductRow } from '../components/ProductRow';
-import { useProducts } from '../hooks/dataHooks';
+import { useCategories, 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 categories = useCategories();
const [search, setSearch] = useState('');
const [name, setName] = useState('');
- const [category, setCategory] = useState(categories[0]);
+ const [category, setCategory] = useState('');
const [unitType, setUnitType] = useState('unit');
const [bulkName, setBulkName] = useState('case');
const [unitsPerBulk, setUnitsPerBulk] = useState(6);
+ const categoryOptions = useMemo(() => {
+ const categoryNames = categories.map((item) => item.name);
+ const productCategories = products.map((product) => product.category);
+ return Array.from(new Set([...categoryNames, ...productCategories]));
+ }, [categories, products]);
+
+ useEffect(() => {
+ if (categoryOptions.length === 0) return;
+ if (!categoryOptions.includes(category)) {
+ setCategory(categoryOptions[0]);
+ }
+ }, [category, categoryOptions]);
+
const filtered = useMemo(
() =>
products.filter((p) =>
@@ -35,7 +48,7 @@ export const ManageProductsScreen = () => {
);
const addProduct = async () => {
- if (!name) return;
+ if (!name || !category) return;
await db.products.add({
id: uuidv4(),
name,
@@ -73,6 +86,9 @@ export const ManageProductsScreen = () => {
Manage Products
+
{
Add Product
setName(event.target.value)} />
- setCategory(event.target.value)}>
- {categories.map((cat) => (
+ setCategory(event.target.value)}
+ disabled={categoryOptions.length === 0}
+ >
+ {categoryOptions.map((cat) => (
@@ -97,7 +119,7 @@ export const ManageProductsScreen = () => {
value={unitsPerBulk}
onChange={(event) => setUnitsPerBulk(Number(event.target.value))}
/>
-
@@ -105,7 +127,7 @@ export const ManageProductsScreen = () => {