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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user