Manage categories and locations
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
143
client/src/pages/CategoriesPage.tsx
Normal file
143
client/src/pages/CategoriesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
client/src/pages/LocationsPage.tsx
Normal file
143
client/src/pages/LocationsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
41
server/src/routes/categories.ts
Normal file
41
server/src/routes/categories.ts
Normal 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;
|
||||
41
server/src/routes/locations.ts
Normal file
41
server/src/routes/locations.ts
Normal 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;
|
||||
Reference in New Issue
Block a user