Manage categories and locations

This commit is contained in:
2026-03-10 23:45:28 +01:00
parent 09a5feb5c4
commit 8d58458815
7 changed files with 420 additions and 0 deletions

View File

@@ -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
</NavLink>
<NavLink
to="/categories"
className={({ isActive }) =>
`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
</NavLink>
<NavLink
to="/locations"
className={({ isActive }) =>
`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
</NavLink>
<NavLink
to="/deleted"
className={({ isActive }) =>
@@ -57,6 +75,8 @@ export default function App() {
<Routes>
<Route path="/" element={<ItemsPage />} />
<Route path="/stats" element={<StatsPage dark={dark} />} />
<Route path="/categories" element={<CategoriesPage />} />
<Route path="/locations" element={<LocationsPage />} />
<Route path="/deleted" element={<DeletedItemsPage />} />
</Routes>
</main>

View File

@@ -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<ItemFile[]> {
const form = new FormData();

View File

@@ -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<CategoryRow[]>([]);
const [editingName, setEditingName] = useState<string | null>(null);
const [newName, setNewName] = useState('');
const [confirmDelete, setConfirmDelete] = useState<string | null>(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 (
<div className="space-y-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Categories</h2>
{categories.length === 0 ? (
<div className="text-sm text-gray-400 text-center py-12">
No categories found. Assign categories to items to see them here.
</div>
) : (
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 dark:border-gray-800 text-left text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
<th className="px-3 py-2 font-medium">Category</th>
<th className="px-3 py-2 font-medium">Items</th>
<th className="px-3 py-2 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{categories.map(cat => (
<tr key={cat.category} className="border-b border-gray-50 dark:border-gray-800">
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">
{editingName === cat.category ? (
<input
type="text"
value={newName}
onChange={e => 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
)}
</td>
<td className="px-3 py-2 text-gray-500 dark:text-gray-400">{cat.item_count}</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-2">
{editingName === cat.category ? (
<>
<button
onClick={() => handleRename(cat.category)}
className="text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 px-2 py-1 rounded hover:bg-blue-50 dark:hover:bg-blue-900/30"
>
Save
</button>
<button
onClick={cancelRename}
className="text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
>
Cancel
</button>
</>
) : (
<>
<button
onClick={() => startRename(cat.category)}
className="text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 px-2 py-1 rounded hover:bg-blue-50 dark:hover:bg-blue-900/30"
>
Rename
</button>
<button
onClick={() => handleDelete(cat.category)}
className={`text-xs font-medium px-2 py-1 rounded ${
confirmDelete === cat.category
? 'bg-red-600 text-white'
: 'text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/30'
}`}
>
{confirmDelete === cat.category ? 'Confirm' : 'Delete'}
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -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<LocationRow[]>([]);
const [editingName, setEditingName] = useState<string | null>(null);
const [newName, setNewName] = useState('');
const [confirmDelete, setConfirmDelete] = useState<string | null>(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 (
<div className="space-y-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Locations</h2>
{locations.length === 0 ? (
<div className="text-sm text-gray-400 text-center py-12">
No locations found. Assign locations to items to see them here.
</div>
) : (
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 dark:border-gray-800 text-left text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
<th className="px-3 py-2 font-medium">Location</th>
<th className="px-3 py-2 font-medium">Items</th>
<th className="px-3 py-2 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{locations.map(loc => (
<tr key={loc.location} className="border-b border-gray-50 dark:border-gray-800">
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">
{editingName === loc.location ? (
<input
type="text"
value={newName}
onChange={e => 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
)}
</td>
<td className="px-3 py-2 text-gray-500 dark:text-gray-400">{loc.item_count}</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-2">
{editingName === loc.location ? (
<>
<button
onClick={() => handleRename(loc.location)}
className="text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 px-2 py-1 rounded hover:bg-blue-50 dark:hover:bg-blue-900/30"
>
Save
</button>
<button
onClick={cancelRename}
className="text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
>
Cancel
</button>
</>
) : (
<>
<button
onClick={() => startRename(loc.location)}
className="text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 px-2 py-1 rounded hover:bg-blue-50 dark:hover:bg-blue-900/30"
>
Rename
</button>
<button
onClick={() => handleDelete(loc.location)}
className={`text-xs font-medium px-2 py-1 rounded ${
confirmDelete === loc.location
? 'bg-red-600 text-white'
: 'text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/30'
}`}
>
{confirmDelete === loc.location ? 'Confirm' : 'Delete'}
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -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');

View File

@@ -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;

View File

@@ -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;