From 6d363d1c43bd892f862935fabbb82f41f0fa805b Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Fri, 21 Nov 2025 16:31:57 +1000 Subject: [PATCH] Add category management screen --- src/App.tsx | 2 + src/components/NavigationBar.tsx | 1 + src/db/index.ts | 30 +++++ src/db/seed.ts | 10 ++ src/hooks/dataHooks.ts | 14 ++ src/models/Category.ts | 6 + src/screens/ManageCategoriesScreen.tsx | 172 +++++++++++++++++++++++++ src/screens/ManageProductsScreen.tsx | 42 ++++-- 8 files changed, 267 insertions(+), 10 deletions(-) create mode 100644 src/models/Category.ts create mode 100644 src/screens/ManageCategoriesScreen.tsx 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) => ( {cat} @@ -97,7 +119,7 @@ export const ManageProductsScreen = () => { value={unitsPerBulk} onChange={(event) => setUnitsPerBulk(Number(event.target.value))} /> - @@ -105,7 +127,7 @@ export const ManageProductsScreen = () => {