From 8d58458815fdc1c8dd8a46bd8e447d58eb96cad4 Mon Sep 17 00:00:00 2001 From: jonas Date: Tue, 10 Mar 2026 23:45:28 +0100 Subject: [PATCH] Manage categories and locations --- client/src/App.tsx | 20 ++++ client/src/api.ts | 28 ++++++ client/src/pages/CategoriesPage.tsx | 143 ++++++++++++++++++++++++++++ client/src/pages/LocationsPage.tsx | 143 ++++++++++++++++++++++++++++ server/src/index.ts | 4 + server/src/routes/categories.ts | 41 ++++++++ server/src/routes/locations.ts | 41 ++++++++ 7 files changed, 420 insertions(+) create mode 100644 client/src/pages/CategoriesPage.tsx create mode 100644 client/src/pages/LocationsPage.tsx create mode 100644 server/src/routes/categories.ts create mode 100644 server/src/routes/locations.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 0d1a21e..17a7c79 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,6 +2,8 @@ import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom'; import ItemsPage from './pages/ItemsPage'; import StatsPage from './pages/StatsPage'; import DeletedItemsPage from './pages/DeletedItemsPage'; +import LocationsPage from './pages/LocationsPage'; +import CategoriesPage from './pages/CategoriesPage'; import { useTheme } from './useTheme'; export default function App() { @@ -29,6 +31,22 @@ export default function App() { > Stats + + `text-sm font-medium px-3 py-1.5 rounded-md ${isActive ? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900' : 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}` + } + > + Categories + + + `text-sm font-medium px-3 py-1.5 rounded-md ${isActive ? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900' : 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}` + } + > + Locations + @@ -57,6 +75,8 @@ export default function App() { } /> } /> + } /> + } /> } /> diff --git a/client/src/api.ts b/client/src/api.ts index f757ed2..c79aec5 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -55,6 +55,34 @@ export const api = { return request('/suggestions/locations'); }, + // Locations + listLocations(): Promise<{ location: string; item_count: number }[]> { + return request('/locations'); + }, + renameLocation(oldName: string, newName: string): Promise<{ updated: number }> { + return request(`/locations/${encodeURIComponent(oldName)}`, { + method: 'PUT', + body: JSON.stringify({ name: newName }), + }); + }, + deleteLocation(name: string): Promise<{ updated: number }> { + return request(`/locations/${encodeURIComponent(name)}`, { method: 'DELETE' }); + }, + + // Categories + listCategories(): Promise<{ category: string; item_count: number }[]> { + return request('/categories'); + }, + renameCategory(oldName: string, newName: string): Promise<{ updated: number }> { + return request(`/categories/${encodeURIComponent(oldName)}`, { + method: 'PUT', + body: JSON.stringify({ name: newName }), + }); + }, + deleteCategory(name: string): Promise<{ updated: number }> { + return request(`/categories/${encodeURIComponent(name)}`, { method: 'DELETE' }); + }, + // Files async uploadFiles(itemId: string, files: File[], type: 'photo' | 'receipt'): Promise { const form = new FormData(); diff --git a/client/src/pages/CategoriesPage.tsx b/client/src/pages/CategoriesPage.tsx new file mode 100644 index 0000000..45f3649 --- /dev/null +++ b/client/src/pages/CategoriesPage.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState, useCallback } from 'react'; +import { api } from '../api'; + +interface CategoryRow { + category: string; + item_count: number; +} + +export default function CategoriesPage() { + const [categories, setCategories] = useState([]); + const [editingName, setEditingName] = useState(null); + const [newName, setNewName] = useState(''); + const [confirmDelete, setConfirmDelete] = useState(null); + + const fetchCategories = useCallback(() => { + api.listCategories().then(setCategories); + }, []); + + useEffect(() => { + fetchCategories(); + }, [fetchCategories]); + + const startRename = (category: string) => { + setEditingName(category); + setNewName(category); + setConfirmDelete(null); + }; + + const cancelRename = () => { + setEditingName(null); + setNewName(''); + }; + + const handleRename = async (oldName: string) => { + const trimmed = newName.trim(); + if (!trimmed || trimmed === oldName) { + cancelRename(); + return; + } + await api.renameCategory(oldName, trimmed); + setEditingName(null); + setNewName(''); + fetchCategories(); + }; + + const handleDelete = async (name: string) => { + if (confirmDelete !== name) { + setConfirmDelete(name); + setEditingName(null); + return; + } + await api.deleteCategory(name); + setConfirmDelete(null); + fetchCategories(); + }; + + return ( +
+

Categories

+ + {categories.length === 0 ? ( +
+ No categories found. Assign categories to items to see them here. +
+ ) : ( +
+ + + + + + + + + + {categories.map(cat => ( + + + + + + ))} + +
CategoryItemsActions
+ {editingName === cat.category ? ( + setNewName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') handleRename(cat.category); + if (e.key === 'Escape') cancelRename(); + }} + autoFocus + className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm" + /> + ) : ( + cat.category + )} + {cat.item_count} +
+ {editingName === cat.category ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+ )} +
+ ); +} diff --git a/client/src/pages/LocationsPage.tsx b/client/src/pages/LocationsPage.tsx new file mode 100644 index 0000000..2c24ec1 --- /dev/null +++ b/client/src/pages/LocationsPage.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState, useCallback } from 'react'; +import { api } from '../api'; + +interface LocationRow { + location: string; + item_count: number; +} + +export default function LocationsPage() { + const [locations, setLocations] = useState([]); + const [editingName, setEditingName] = useState(null); + const [newName, setNewName] = useState(''); + const [confirmDelete, setConfirmDelete] = useState(null); + + const fetchLocations = useCallback(() => { + api.listLocations().then(setLocations); + }, []); + + useEffect(() => { + fetchLocations(); + }, [fetchLocations]); + + const startRename = (location: string) => { + setEditingName(location); + setNewName(location); + setConfirmDelete(null); + }; + + const cancelRename = () => { + setEditingName(null); + setNewName(''); + }; + + const handleRename = async (oldName: string) => { + const trimmed = newName.trim(); + if (!trimmed || trimmed === oldName) { + cancelRename(); + return; + } + await api.renameLocation(oldName, trimmed); + setEditingName(null); + setNewName(''); + fetchLocations(); + }; + + const handleDelete = async (name: string) => { + if (confirmDelete !== name) { + setConfirmDelete(name); + setEditingName(null); + return; + } + await api.deleteLocation(name); + setConfirmDelete(null); + fetchLocations(); + }; + + return ( +
+

Locations

+ + {locations.length === 0 ? ( +
+ No locations found. Assign locations to items to see them here. +
+ ) : ( +
+ + + + + + + + + + {locations.map(loc => ( + + + + + + ))} + +
LocationItemsActions
+ {editingName === loc.location ? ( + setNewName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') handleRename(loc.location); + if (e.key === 'Escape') cancelRename(); + }} + autoFocus + className="w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm" + /> + ) : ( + loc.location + )} + {loc.item_count} +
+ {editingName === loc.location ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+ )} +
+ ); +} diff --git a/server/src/index.ts b/server/src/index.ts index be56a7f..4064ea7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -7,6 +7,8 @@ import itemsRouter from './routes/items.js'; import uploadsRouter, { createFileUploadRouter, createFileDeleteRouter } from './routes/uploads.js'; import statsRouter from './routes/stats.js'; import suggestionsRouter from './routes/suggestions.js'; +import locationsRouter from './routes/locations.js'; +import categoriesRouter from './routes/categories.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const app = express(); @@ -26,6 +28,8 @@ app.use('/api/files', createFileDeleteRouter()); app.use('/api/uploads', uploadsRouter); app.use('/api/stats', statsRouter); app.use('/api/suggestions', suggestionsRouter); +app.use('/api/locations', locationsRouter); +app.use('/api/categories', categoriesRouter); // Serve static client in production const clientDist = join(__dirname, '..', '..', 'client', 'dist'); diff --git a/server/src/routes/categories.ts b/server/src/routes/categories.ts new file mode 100644 index 0000000..2faab22 --- /dev/null +++ b/server/src/routes/categories.ts @@ -0,0 +1,41 @@ +import { Router } from 'express'; +import db from '../db.js'; + +const router = Router(); + +// List all categories with item counts +router.get('/', (_req, res) => { + const rows = db.prepare( + `SELECT category, COUNT(*) as item_count + FROM items WHERE deleted_at IS NULL AND category != '' + GROUP BY category ORDER BY category` + ).all() as { category: string; item_count: number }[]; + res.json(rows); +}); + +// Rename a category (bulk update all items) +router.put('/:name', (req, res) => { + const oldName = req.params.name; + const { name } = req.body; + if (!name || typeof name !== 'string' || !name.trim()) { + res.status(400).json({ error: 'New name is required' }); + return; + } + const result = db.prepare( + `UPDATE items SET category = ?, updated_at = datetime('now') + WHERE category = ? AND deleted_at IS NULL` + ).run(name.trim(), oldName); + res.json({ updated: result.changes }); +}); + +// Delete a category (clear from all items) +router.delete('/:name', (req, res) => { + const name = req.params.name; + const result = db.prepare( + `UPDATE items SET category = '', updated_at = datetime('now') + WHERE category = ? AND deleted_at IS NULL` + ).run(name); + res.json({ updated: result.changes }); +}); + +export default router; diff --git a/server/src/routes/locations.ts b/server/src/routes/locations.ts new file mode 100644 index 0000000..62c8aeb --- /dev/null +++ b/server/src/routes/locations.ts @@ -0,0 +1,41 @@ +import { Router } from 'express'; +import db from '../db.js'; + +const router = Router(); + +// List all locations with item counts +router.get('/', (_req, res) => { + const rows = db.prepare( + `SELECT location, COUNT(*) as item_count + FROM items WHERE deleted_at IS NULL AND location != '' + GROUP BY location ORDER BY location` + ).all() as { location: string; item_count: number }[]; + res.json(rows); +}); + +// Rename a location (bulk update all items) +router.put('/:name', (req, res) => { + const oldName = req.params.name; + const { name } = req.body; + if (!name || typeof name !== 'string' || !name.trim()) { + res.status(400).json({ error: 'New name is required' }); + return; + } + const result = db.prepare( + `UPDATE items SET location = ?, updated_at = datetime('now') + WHERE location = ? AND deleted_at IS NULL` + ).run(name.trim(), oldName); + res.json({ updated: result.changes }); +}); + +// Delete a location (clear from all items) +router.delete('/:name', (req, res) => { + const name = req.params.name; + const result = db.prepare( + `UPDATE items SET location = '', updated_at = datetime('now') + WHERE location = ? AND deleted_at IS NULL` + ).run(name); + res.json({ updated: result.changes }); +}); + +export default router;