From 79561c16d8223d2ac02032248cfe9a6d2f01e1b3 Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Mon, 1 Dec 2025 15:21:54 +1000 Subject: [PATCH] Refactor area and category management UI --- src/components/EditableEntityList.tsx | 194 +++++++++++++++++++ src/screens/ManageAreasScreen.tsx | 129 +++---------- src/screens/ManageCategoriesScreen.tsx | 246 ++++++++----------------- 3 files changed, 300 insertions(+), 269 deletions(-) create mode 100644 src/components/EditableEntityList.tsx diff --git a/src/components/EditableEntityList.tsx b/src/components/EditableEntityList.tsx new file mode 100644 index 0000000..582a125 --- /dev/null +++ b/src/components/EditableEntityList.tsx @@ -0,0 +1,194 @@ +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 { + Alert, + AlertColor, + Button, + IconButton, + List, + ListItem, + ListItemText, + Stack, + TextField, +} from '@mui/material'; +import { useMemo, useState } from 'react'; + +export interface EditableEntity { + id: string; + name: string; + secondaryText?: string; +} + +export interface ActionOutcome { + text?: string; + severity?: AlertColor; + success?: boolean; +} + +interface EditableEntityListProps { + nameLabel: string; + addButtonLabel?: string; + entityLabel?: string; + addPlaceholder?: string; + entities: EditableEntity[]; + validateName?: (name: string, entityId?: string | null) => boolean; + onAdd: (name: string) => Promise; + onUpdate: (id: string, name: string) => Promise; + onDelete: (id: string, name: string) => Promise; +} + +interface FeedbackState { + text: string; + severity: AlertColor; +} + +export const EditableEntityList = ({ + nameLabel, + addButtonLabel = 'Add', + entityLabel = 'Item', + addPlaceholder, + entities, + validateName, + onAdd, + onUpdate, + onDelete, +}: EditableEntityListProps) => { + const [newName, setNewName] = useState(''); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const [feedback, setFeedback] = useState(null); + + const isNameValid = useMemo(() => { + return (value: string, entityId: string | null) => { + const trimmed = value.trim(); + if (!trimmed) return false; + if (validateName) return validateName(trimmed, entityId); + return true; + }; + }, [validateName]); + + const applyOutcome = (outcome: ActionOutcome | void, defaultText: string, defaultSeverity: AlertColor = 'success') => { + const success = outcome?.success ?? outcome?.severity !== 'error'; + const text = outcome?.text ?? defaultText; + const severity = outcome?.severity ?? defaultSeverity; + setFeedback({ text, severity }); + return success; + }; + + const handleAdd = async () => { + const trimmed = newName.trim(); + if (!isNameValid(newName, null)) return; + try { + const success = applyOutcome(await onAdd(trimmed), `${entityLabel} added.`); + if (success) { + setNewName(''); + } + } catch (error) { + setFeedback({ text: `Unable to add ${entityLabel.toLowerCase()}.`, severity: 'error' }); + } + }; + + const startEditing = (id: string, currentName: string) => { + setEditingId(id); + setEditName(currentName); + setFeedback(null); + }; + + const cancelEditing = () => { + setEditingId(null); + setEditName(''); + }; + + const handleSave = async () => { + if (!editingId) return; + const trimmed = editName.trim(); + if (!isNameValid(editName, editingId)) return; + + try { + const success = applyOutcome(await onUpdate(editingId, trimmed), `${entityLabel} updated.`); + if (success) { + cancelEditing(); + } + } catch (error) { + setFeedback({ text: `Unable to update ${entityLabel.toLowerCase()}.`, severity: 'error' }); + } + }; + + const handleDelete = async (id: string, name: string) => { + try { + const success = applyOutcome(await onDelete(id, name), `${entityLabel} deleted.`); + if (success && editingId === id) { + cancelEditing(); + } + } catch (error) { + setFeedback({ text: `Unable to delete ${entityLabel.toLowerCase()}.`, severity: 'error' }); + } + }; + + const canAdd = isNameValid(newName, null); + const canSave = editingId ? isNameValid(editName, editingId) : false; + + return ( + + {feedback ? {feedback.text} : null} + + setNewName(event.target.value)} + /> + + + + {entities.map((entity) => ( + + {editingId === entity.id ? ( + + setEditName(event.target.value)} + /> + + + + + + + + ) : ( + + + + startEditing(entity.id, entity.name)} + aria-label={`Edit ${entity.name}`} + size="small" + > + + + handleDelete(entity.id, entity.name)} + aria-label={`Delete ${entity.name}`} + size="small" + > + + + + + )} + + ))} + + + ); +}; + +export default EditableEntityList; diff --git a/src/screens/ManageAreasScreen.tsx b/src/screens/ManageAreasScreen.tsx index f031bf8..911e823 100644 --- a/src/screens/ManageAreasScreen.tsx +++ b/src/screens/ManageAreasScreen.tsx @@ -1,72 +1,35 @@ -import { - Alert, - AlertColor, - Button, - Container, - IconButton, - List, - ListItem, - ListItemText, - Stack, - TextField, - Typography, -} from '@mui/material'; -import DeleteIcon from '@mui/icons-material/Delete'; -import EditIcon from '@mui/icons-material/Edit'; -import CheckIcon from '@mui/icons-material/Check'; -import CloseIcon from '@mui/icons-material/Close'; -import { useState } from 'react'; +import { AlertColor, Container, Typography } from '@mui/material'; import { v4 as uuidv4 } from 'uuid'; -import { useAreas } from '../hooks/dataHooks'; +import { EditableEntityList, ActionOutcome } from '../components/EditableEntityList'; import { useDatabase } from '../context/DBProvider'; +import { useAreas } from '../hooks/dataHooks'; const ManageAreasScreen = () => { const db = useDatabase(); const areas = useAreas(); - const [name, setName] = useState(''); - const [editingAreaId, setEditingAreaId] = useState(null); - const [editName, setEditName] = useState(''); - const [feedback, setFeedback] = useState<{ text: string; severity: AlertColor } | null>(null); - const addArea = async () => { - if (!name) return; + const addArea = async (name: string): Promise => { await db.areas.add({ id: uuidv4(), name, created_at: Date.now(), updated_at: Date.now() }); - setName(''); + return { text: 'Area added.', severity: 'success' }; }; - const startEditing = (areaId: string, currentName: string) => { - setEditingAreaId(areaId); - setEditName(currentName); - setFeedback(null); + const saveArea = async (areaId: string, updatedName: string): Promise => { + await db.areas.update(areaId, { name: updatedName, updated_at: Date.now() }); + return { text: 'Area updated.', severity: 'success' }; }; - const saveArea = async () => { - if (!editingAreaId || !editName) return; - await db.areas.update(editingAreaId, { name: editName, updated_at: Date.now() }); - setEditingAreaId(null); - setEditName(''); - setFeedback({ text: 'Area updated.', severity: 'success' }); - }; - - const cancelEditing = () => { - setEditingAreaId(null); - setEditName(''); - }; - - const deleteArea = async (areaId: string) => { + const deleteArea = async (areaId: string): Promise => { const usageCount = await db.pickLists.where('area_id').equals(areaId).count(); if (usageCount > 0) { - setFeedback({ + return { text: `Cannot delete this area while ${usageCount} pick list(s) use it. Remove those lists first.`, - severity: 'error', - }); - return; + severity: 'error' satisfies AlertColor, + success: false, + }; } + await db.areas.delete(areaId); - if (editingAreaId === areaId) { - cancelEditing(); - } - setFeedback({ text: 'Area deleted.', severity: 'success' }); + return { text: 'Area deleted.', severity: 'success' }; }; return ( @@ -74,59 +37,17 @@ const ManageAreasScreen = () => { Manage Areas - - {feedback ? {feedback.text} : null} - - setName(event.target.value)} /> - - - - {areas.map((area) => ( - - {editingAreaId === area.id ? ( - - setEditName(event.target.value)} - /> - - - - - - - - ) : ( - - - - startEditing(area.id, area.name)} - aria-label={`Edit ${area.name}`} - size="small" - > - - - deleteArea(area.id)} - aria-label={`Delete ${area.name}`} - size="small" - > - - - - - )} - - ))} - - + ({ id: area.id, name: area.name }))} + onAdd={addArea} + onUpdate={saveArea} + onDelete={(areaId, areaName) => deleteArea(areaId)} + /> ); }; -export default ManageAreasScreen \ No newline at end of file +export default ManageAreasScreen; diff --git a/src/screens/ManageCategoriesScreen.tsx b/src/screens/ManageCategoriesScreen.tsx index 3e33199..cf3e300 100644 --- a/src/screens/ManageCategoriesScreen.tsx +++ b/src/screens/ManageCategoriesScreen.tsx @@ -1,22 +1,7 @@ -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 { AlertColor, Container, Typography } from '@mui/material'; +import { useMemo } from 'react'; import { v4 as uuidv4 } from 'uuid'; +import { ActionOutcome, EditableEntityList } from '../components/EditableEntityList'; import { useDatabase } from '../context/DBProvider'; import { useCategories, useProducts } from '../hooks/dataHooks'; @@ -24,179 +9,110 @@ 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 categoriesById = useMemo(() => new Map(categories.map((c) => [c.id, c.name])), [categories]); const usageByCategory = useMemo(() => { - return products.reduce>((acc, product) => { - const categoryName = categoriesById.get(product.category) ?? product.category ?? ''; - if (!categoryName) return acc; - acc[categoryName] = (acc[categoryName] ?? 0) + 1; - return acc; - }, {}); -}, [products, categoriesById]); + const categoriesById = new Map(categories.map((category) => [category.id, category.name])); + return products.reduce>((acc, product) => { + const categoryName = categoriesById.get(product.category) ?? product.category ?? ''; + if (!categoryName) return acc; + acc[categoryName] = (acc[categoryName] ?? 0) + 1; + return acc; + }, {}); + }, [categories, products]); + const validateCategoryName = (value: string, editingId?: string | null) => { + const trimmed = value.trim(); + if (!trimmed) return false; + return !categories.some( + (category) => category.id !== editingId && category.name.toLowerCase() === trimmed.toLowerCase(), + ); + }; - const addCategory = async () => { + const addCategory = async (name: string): Promise => { 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; + return { text: 'A category with this name already exists.', severity: 'error', success: false }; } + await db.categories.add({ id: uuidv4(), name: trimmed, created_at: Date.now(), updated_at: Date.now() }); - setName(''); - setFeedback({ text: 'Category added.', severity: 'success' }); + return { text: 'Category added.', severity: 'success' }; }; - const startEditing = (categoryId: string, currentName: string) => { - setEditingCategoryId(categoryId); - setEditName(currentName); - setFeedback(null); - }; + const saveCategory = async (categoryId: string, updatedName: string): Promise => { + const trimmed = updatedName.trim(); + const category = categories.find((item) => item.id === categoryId); + if (!category) return { text: 'Category not found.', severity: 'error', success: false }; -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, db.pickLists, async () => { - // Update category name - await db.categories.update(editingCategoryId, { name: trimmed, updated_at: Date.now() }); - - // For backward-compat products that stored category as the old name, - // update them to reference the new name OR ideally, to the id. - // We map products that still have category === oldName to the new name value. - // (If you ran the normalization migration earlier, most products will already have ids.) - await db.products - .where('category') - .equals(category.name) - .modify({ category: trimmed, updated_at: Date.now() }); - - // Update pickLists that used the old category name - const pickLists = await db.pickLists.toArray(); - await Promise.all( - pickLists.map(async (pl) => { - if (!Array.isArray((pl as any).categories)) return; - const needs = (pl as any).categories.includes(category.name); - if (!needs) return; - const updated = (pl as any).categories.map((c: string) => (c === category.name ? trimmed : c)); - await db.pickLists.update(pl.id, { categories: updated }); - }), + const nameExists = categories.some( + (item) => item.id !== categoryId && item.name.toLowerCase() === trimmed.toLowerCase(), ); - }); + if (nameExists) { + return { text: 'A category with this name already exists.', severity: 'error', success: false }; + } - setEditingCategoryId(null); - setEditName(''); - setFeedback({ text: 'Category updated. Linked products were refreshed.', severity: 'success' }); -}; + await db.transaction('rw', db.categories, db.products, db.pickLists, async () => { + await db.categories.update(categoryId, { name: trimmed, updated_at: Date.now() }); - const cancelEditing = () => { - setEditingCategoryId(null); - setEditName(''); - setFeedback(null); + await db.products.where('category').equals(category.name).modify({ category: trimmed, updated_at: Date.now() }); + + const pickLists = await db.pickLists.toArray(); + await Promise.all( + pickLists.map(async (pickList) => { + if (!Array.isArray((pickList as any).categories)) return; + const needsUpdate = (pickList as any).categories.includes(category.name); + if (!needsUpdate) return; + const updatedCategories = (pickList as any).categories.map((existing: string) => + existing === category.name ? trimmed : existing, + ); + await db.pickLists.update(pickList.id, { categories: updatedCategories }); + }), + ); + }); + + return { text: 'Category updated. Linked products were refreshed.', severity: 'success' }; }; -const deleteCategory = async (categoryId: string, categoryName: string) => { - // Count products referencing either the id or the name (legacy) - const [countById, countByName] = await Promise.all([ - db.products.where('category').equals(categoryId).count(), - db.products.where('category').equals(categoryName).count(), - ]); - const usageCount = countById + countByName; + const deleteCategory = async (categoryId: string, categoryName: string): Promise => { + const [countById, countByName] = await Promise.all([ + db.products.where('category').equals(categoryId).count(), + db.products.where('category').equals(categoryName).count(), + ]); + const usageCount = countById + countByName; - 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' }); -}; + if (usageCount > 0) { + return { + text: `Cannot delete '${categoryName}' while ${usageCount} product(s) use it. Update those products first.`, + severity: 'error' satisfies AlertColor, + success: false, + }; + } + await db.categories.delete(categoryId); + return { 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" - > - - - - - )} - - ))} - - + ({ + id: category.id, + name: category.name, + secondaryText: `Used by ${usageByCategory[category.name] ?? 0} product(s)`, + }))} + validateName={validateCategoryName} + onAdd={addCategory} + onUpdate={saveCategory} + onDelete={deleteCategory} + /> ); }; -export default ManageCategoriesScreen \ No newline at end of file +export default ManageCategoriesScreen;