feat(shopping): custom categories - add, rename, delete and reorder (#26)

- New DB table shopping_categories (migration v5) seeds 9 default
  categories with Lucide icons and sort_order
- Backend CRUD routes: GET/POST/PUT/DELETE /shopping/categories
  plus PATCH /shopping/categories/reorder
- Category validation now uses DB instead of hardcoded constant;
  items of deleted category are moved to the next available one
- Frontend shopping page loads categories from API, dropdown and
  grouping reflect custom order dynamically
- Settings -> Shopping section: list categories with up/down buttons,
  click-to-rename, delete with confirmation; add new categories inline
- i18n keys added in de/en/sv/it
This commit is contained in:
Ulas
2026-04-05 17:24:06 +02:00
parent 517e4454d0
commit 2dc8984c3e
11 changed files with 545 additions and 56 deletions
+8
View File
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.12.0] - 2026-04-05
### Added
- Shopping: custom categories - add, rename, delete and reorder shopping list categories in Settings → Shopping (#26)
- Shopping: categories are now stored in the database (`shopping_categories` table, migration v5) and fully customizable per household
- Shopping: category order in the shopping list reflects the custom sort order from Settings
- Shopping: items belonging to a deleted category are automatically moved to the next available category
## [0.11.9] - 2026-04-05 ## [0.11.9] - 2026-04-05
### Changed ### Changed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "oikos", "name": "oikos",
"version": "0.11.9", "version": "0.12.0",
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
"main": "server/index.js", "main": "server/index.js",
"type": "module", "type": "module",
+13
View File
@@ -455,6 +455,19 @@
"settings": { "settings": {
"title": "Einstellungen", "title": "Einstellungen",
"sectionDesign": "Design", "sectionDesign": "Design",
"sectionShopping": "Einkauf",
"shoppingCategoriesLabel": "Einkaufskategorien",
"shoppingCategoriesHint": "Kategorien hinzufügen, umbenennen, löschen oder sortieren.",
"shoppingCategoryPlaceholder": "Neue Kategorie…",
"shoppingCategoryRenameHint": "Klicken zum Umbenennen",
"shoppingCategoryRenamePrompt": "Neuer Kategoriename:",
"shoppingCategoryMoveUp": "Kategorie nach oben",
"shoppingCategoryMoveDown": "Kategorie nach unten",
"shoppingCategoryDelete": "Kategorie löschen",
"shoppingCategoryDeleteConfirm": "Kategorie \"{{name}}\" löschen? Vorhandene Artikel werden der nächsten Kategorie zugeordnet.",
"shoppingCategoryAdded": "Kategorie hinzugefügt.",
"shoppingCategoryRenamed": "Kategorie umbenannt.",
"shoppingCategoryDeleted": "Kategorie gelöscht.",
"sectionAccount": "Mein Konto", "sectionAccount": "Mein Konto",
"sectionCalendarSync": "Kalender-Synchronisation", "sectionCalendarSync": "Kalender-Synchronisation",
"sectionFamily": "Familienmitglieder", "sectionFamily": "Familienmitglieder",
+13
View File
@@ -455,6 +455,19 @@
"settings": { "settings": {
"title": "Settings", "title": "Settings",
"sectionDesign": "Appearance", "sectionDesign": "Appearance",
"sectionShopping": "Shopping",
"shoppingCategoriesLabel": "Shopping Categories",
"shoppingCategoriesHint": "Add, rename, delete or reorder categories.",
"shoppingCategoryPlaceholder": "New category…",
"shoppingCategoryRenameHint": "Click to rename",
"shoppingCategoryRenamePrompt": "New category name:",
"shoppingCategoryMoveUp": "Move category up",
"shoppingCategoryMoveDown": "Move category down",
"shoppingCategoryDelete": "Delete category",
"shoppingCategoryDeleteConfirm": "Delete category \"{{name}}\"? Existing items will be moved to the next category.",
"shoppingCategoryAdded": "Category added.",
"shoppingCategoryRenamed": "Category renamed.",
"shoppingCategoryDeleted": "Category deleted.",
"sectionAccount": "My Account", "sectionAccount": "My Account",
"sectionCalendarSync": "Calendar Sync", "sectionCalendarSync": "Calendar Sync",
"sectionFamily": "Family Members", "sectionFamily": "Family Members",
+13
View File
@@ -455,6 +455,19 @@
"settings": { "settings": {
"title": "Impostazioni", "title": "Impostazioni",
"sectionDesign": "Aspetto", "sectionDesign": "Aspetto",
"sectionShopping": "Spesa",
"shoppingCategoriesLabel": "Categorie spesa",
"shoppingCategoriesHint": "Aggiungi, rinomina, elimina o riordina le categorie.",
"shoppingCategoryPlaceholder": "Nuova categoria…",
"shoppingCategoryRenameHint": "Clicca per rinominare",
"shoppingCategoryRenamePrompt": "Nuovo nome categoria:",
"shoppingCategoryMoveUp": "Sposta categoria su",
"shoppingCategoryMoveDown": "Sposta categoria giu",
"shoppingCategoryDelete": "Elimina categoria",
"shoppingCategoryDeleteConfirm": "Eliminare la categoria \"{{name}}\"? Gli articoli esistenti verranno spostati alla categoria successiva.",
"shoppingCategoryAdded": "Categoria aggiunta.",
"shoppingCategoryRenamed": "Categoria rinominata.",
"shoppingCategoryDeleted": "Categoria eliminata.",
"sectionAccount": "Il mio account", "sectionAccount": "Il mio account",
"sectionCalendarSync": "Sincronizzazione calendario", "sectionCalendarSync": "Sincronizzazione calendario",
"sectionFamily": "Membri della famiglia", "sectionFamily": "Membri della famiglia",
+13
View File
@@ -455,6 +455,19 @@
"settings": { "settings": {
"title": "Inställningar", "title": "Inställningar",
"sectionDesign": "Utseende", "sectionDesign": "Utseende",
"sectionShopping": "Inköp",
"shoppingCategoriesLabel": "Inköpskategorier",
"shoppingCategoriesHint": "Lägg till, byt namn, ta bort eller sortera om kategorier.",
"shoppingCategoryPlaceholder": "Ny kategori…",
"shoppingCategoryRenameHint": "Klicka för att byta namn",
"shoppingCategoryRenamePrompt": "Nytt kategorinamn:",
"shoppingCategoryMoveUp": "Flytta kategori uppåt",
"shoppingCategoryMoveDown": "Flytta kategori nedåt",
"shoppingCategoryDelete": "Ta bort kategori",
"shoppingCategoryDeleteConfirm": "Ta bort kategorin \"{{name}}\"? Befintliga artiklar flyttas till nästa kategori.",
"shoppingCategoryAdded": "Kategori tillagd.",
"shoppingCategoryRenamed": "Kategori omdöpt.",
"shoppingCategoryDeleted": "Kategori borttagen.",
"sectionAccount": "Mitt konto", "sectionAccount": "Mitt konto",
"sectionCalendarSync": "Kalendersynkronisering", "sectionCalendarSync": "Kalendersynkronisering",
"sectionFamily": "Familjemedlemmar", "sectionFamily": "Familjemedlemmar",
+165 -3
View File
@@ -40,18 +40,21 @@ export async function render(container, { user }) {
let googleStatus = { configured: false, connected: false, lastSync: null }; let googleStatus = { configured: false, connected: false, lastSync: null };
let appleStatus = { configured: false, lastSync: null }; let appleStatus = { configured: false, lastSync: null };
let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR' }; let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR' };
let categories = [];
try { try {
const [usersRes, gStatus, aStatus, prefsRes] = await Promise.allSettled([ const [usersRes, gStatus, aStatus, prefsRes, catsRes] = await Promise.allSettled([
user.role === 'admin' ? auth.getUsers() : Promise.resolve({ data: [] }), user.role === 'admin' ? auth.getUsers() : Promise.resolve({ data: [] }),
api.get('/calendar/google/status'), api.get('/calendar/google/status'),
api.get('/calendar/apple/status'), api.get('/calendar/apple/status'),
api.get('/preferences'), api.get('/preferences'),
api.get('/shopping/categories'),
]); ]);
if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? []; if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? [];
if (gStatus.status === 'fulfilled') googleStatus = gStatus.value; if (gStatus.status === 'fulfilled') googleStatus = gStatus.value;
if (aStatus.status === 'fulfilled') appleStatus = aStatus.value; if (aStatus.status === 'fulfilled') appleStatus = aStatus.value;
if (prefsRes.status === 'fulfilled') prefs = prefsRes.value.data ?? prefs; if (prefsRes.status === 'fulfilled') prefs = prefsRes.value.data ?? prefs;
if (catsRes.status === 'fulfilled') categories = catsRes.value.data ?? [];
} catch (_) { /* non-critical */ } } catch (_) { /* non-critical */ }
const googleStatusText = googleStatus.connected const googleStatusText = googleStatus.connected
@@ -142,6 +145,24 @@ export async function render(container, { user }) {
</div> </div>
</section> </section>
<!-- Einkauf: Kategorien -->
<section class="settings-section">
<h2 class="settings-section__title">${t('settings.sectionShopping')}</h2>
<div class="settings-card">
<h3 class="settings-card__title">${t('settings.shoppingCategoriesLabel')}</h3>
<p class="form-hint" style="margin-bottom:var(--space-3)">${t('settings.shoppingCategoriesHint')}</p>
<ul class="cat-list" id="cat-list">
${categories.map((c, i) => categoryRowHtml(c, i === 0, i === categories.length - 1)).join('')}
</ul>
<form class="cat-add-form" id="cat-add-form" novalidate autocomplete="off">
<input class="form-input" type="text" id="cat-add-input"
placeholder="${t('settings.shoppingCategoryPlaceholder')}"
maxlength="60" />
<button type="submit" class="btn btn--primary">${t('common.add')}</button>
</form>
</div>
</section>
<!-- Mein Konto --> <!-- Mein Konto -->
<section class="settings-section"> <section class="settings-section">
<h2 class="settings-section__title">${t('settings.sectionAccount')}</h2> <h2 class="settings-section__title">${t('settings.sectionAccount')}</h2>
@@ -317,14 +338,15 @@ export async function render(container, { user }) {
}); });
} }
bindEvents(container, user); bindEvents(container, user, categories);
} }
// -------------------------------------------------------- // --------------------------------------------------------
// Event-Binding // Event-Binding
// -------------------------------------------------------- // --------------------------------------------------------
function bindEvents(container, user) { function bindEvents(container, user, categories) {
bindCategoryEvents(container);
// Theme-Toggle // Theme-Toggle
const themeToggle = container.querySelector('#theme-toggle'); const themeToggle = container.querySelector('#theme-toggle');
if (themeToggle) { if (themeToggle) {
@@ -585,6 +607,146 @@ function bindDeleteButtons(container, user) {
} }
// --------------------------------------------------------
// Kategorie-Verwaltung
// --------------------------------------------------------
function categoryRowHtml(cat, isFirst, isLast) {
return `
<li class="cat-row" data-cat-id="${cat.id}">
<i data-lucide="${esc(cat.icon)}" class="cat-row__icon" aria-hidden="true"></i>
<span class="cat-row__name" data-action="rename-cat" title="${t('settings.shoppingCategoryRenameHint')}">${esc(cat.name)}</span>
<div class="cat-row__actions">
<button class="btn btn--icon btn--ghost" data-action="move-cat-up" data-id="${cat.id}"
aria-label="${t('settings.shoppingCategoryMoveUp')}"
${isFirst ? 'disabled' : ''}>
<i data-lucide="chevron-up" style="width:16px;height:16px" aria-hidden="true"></i>
</button>
<button class="btn btn--icon btn--ghost" data-action="move-cat-down" data-id="${cat.id}"
aria-label="${t('settings.shoppingCategoryMoveDown')}"
${isLast ? 'disabled' : ''}>
<i data-lucide="chevron-down" style="width:16px;height:16px" aria-hidden="true"></i>
</button>
<button class="btn btn--icon btn--danger-outline" data-action="delete-cat" data-id="${cat.id}"
aria-label="${t('settings.shoppingCategoryDelete')}">
<i data-lucide="trash-2" style="width:14px;height:14px" aria-hidden="true"></i>
</button>
</div>
</li>`;
}
function renderCatList(container, cats) {
const list = container.querySelector('#cat-list');
if (!list) return;
// DOM-API statt innerHTML (Security-Constraint des Projekts)
list.replaceChildren();
cats.forEach((c, i) => {
const tmp = document.createElement('template');
tmp.innerHTML = categoryRowHtml(c, i === 0, i === cats.length - 1);
list.appendChild(tmp.content.firstElementChild);
});
if (window.lucide) window.lucide.createIcons();
}
function bindCategoryEvents(container) {
let cats = [];
api.get('/shopping/categories').then((res) => {
cats = res.data ?? [];
renderCatList(container, cats);
}).catch(() => {});
const addForm = container.querySelector('#cat-add-form');
if (addForm) {
addForm.addEventListener('submit', async (e) => {
e.preventDefault();
const input = container.querySelector('#cat-add-input');
const name = input.value.trim();
if (!name) return;
try {
const res = await api.post('/shopping/categories', { name });
cats.push(res.data);
renderCatList(container, cats);
input.value = '';
input.focus();
window.oikos?.showToast(t('settings.shoppingCategoryAdded'), 'success');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
});
}
const catList = container.querySelector('#cat-list');
if (!catList) return;
catList.addEventListener('click', async (e) => {
const target = e.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
const rowEl = target.closest('[data-cat-id]');
const id = rowEl ? Number(rowEl.dataset.catId) : Number(target.dataset.id);
if (action === 'rename-cat') {
const cat = cats.find((c) => c.id === id);
if (!cat) return;
const { promptModal } = await import('/components/modal.js');
const newName = await promptModal(t('settings.shoppingCategoryRenamePrompt'), cat.name);
if (!newName || newName === cat.name) return;
try {
const res = await api.put(`/shopping/categories/${id}`, { name: newName });
const idx = cats.findIndex((c) => c.id === id);
if (idx >= 0) cats[idx] = res.data;
renderCatList(container, cats);
window.oikos?.showToast(t('settings.shoppingCategoryRenamed'), 'success');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
}
if (action === 'move-cat-up') {
const idx = cats.findIndex((c) => c.id === id);
if (idx <= 0) return;
[cats[idx - 1], cats[idx]] = [cats[idx], cats[idx - 1]];
renderCatList(container, cats);
try {
await api.patch('/shopping/categories/reorder', { order: cats.map((c) => c.id) });
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
}
if (action === 'move-cat-down') {
const idx = cats.findIndex((c) => c.id === id);
if (idx < 0 || idx >= cats.length - 1) return;
[cats[idx], cats[idx + 1]] = [cats[idx + 1], cats[idx]];
renderCatList(container, cats);
try {
await api.patch('/shopping/categories/reorder', { order: cats.map((c) => c.id) });
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
}
if (action === 'delete-cat') {
const cat = cats.find((c) => c.id === id);
if (!cat) return;
const { confirmModal: confirmDel } = await import('/components/modal.js');
if (!await confirmDel(
t('settings.shoppingCategoryDeleteConfirm', { name: cat.name }),
{ danger: true, confirmLabel: t('common.delete') }
)) return;
try {
await api.delete(`/shopping/categories/${id}`);
cats = cats.filter((c) => c.id !== id);
renderCatList(container, cats);
window.oikos?.showToast(t('settings.shoppingCategoryDeleted'), 'default');
} catch (err) {
window.oikos?.showToast(err.message, 'danger');
}
}
});
}
function memberHtml(u) { function memberHtml(u) {
return ` return `
<li class="settings-member" data-id="${u.id}"> <li class="settings-member" data-id="${u.id}">
+49 -37
View File
@@ -19,35 +19,35 @@ const SWIPE_THRESHOLD = 80; // px - Mindestweg für Aktion
const SWIPE_MAX_VERT = 12; // px - vertikaler Toleranzbereich const SWIPE_MAX_VERT = 12; // px - vertikaler Toleranzbereich
const SWIPE_LOCK_VERT = 30; // px - ab diesem Weg gilt es als Scroll const SWIPE_LOCK_VERT = 30; // px - ab diesem Weg gilt es als Scroll
const ITEM_CATEGORIES = [ // Übersetzungs-Map für die Standard-Kategorien (DB-Name → i18n-Key)
'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch', const DEFAULT_CATEGORY_I18N = {
'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges', 'Obst & Gemüse': 'shopping.catFruitVeg',
]; 'Backwaren': 'shopping.catBakery',
'Milchprodukte': 'shopping.catDairy',
const CATEGORY_LABELS = () => ({ 'Fleisch & Fisch': 'shopping.catMeatFish',
'Obst & Gemüse': t('shopping.catFruitVeg'), 'Tiefkühl': 'shopping.catFrozen',
'Backwaren': t('shopping.catBakery'), 'Getränke': 'shopping.catDrinks',
'Milchprodukte': t('shopping.catDairy'), 'Haushalt': 'shopping.catHousehold',
'Fleisch & Fisch': t('shopping.catMeatFish'), 'Drogerie': 'shopping.catDrugstore',
'Tiefkühl': t('shopping.catFrozen'), 'Sonstiges': 'shopping.catMisc',
'Getränke': t('shopping.catDrinks'),
'Haushalt': t('shopping.catHousehold'),
'Drogerie': t('shopping.catDrugstore'),
'Sonstiges': t('shopping.catMisc'),
});
const CATEGORY_ICONS = {
'Obst & Gemüse': 'apple',
'Backwaren': 'wheat',
'Milchprodukte': 'milk',
'Fleisch & Fisch':'beef',
'Tiefkühl': 'snowflake',
'Getränke': 'cup-soda',
'Haushalt': 'spray-can',
'Drogerie': 'pill',
'Sonstiges': 'shopping-basket',
}; };
/** Übersetzten Label für eine Kategorie zurückgeben. */
function catLabel(name) {
const key = DEFAULT_CATEGORY_I18N[name];
return key ? t(key) : name;
}
/** Icon für eine Kategorie (aus state.categories, Fallback 'tag'). */
function catIcon(name) {
return state.categories.find((c) => c.name === name)?.icon ?? 'tag';
}
/** Kategorienamen in DB-Reihenfolge. */
function categoryNames() {
return state.categories.map((c) => c.name);
}
// -------------------------------------------------------- // --------------------------------------------------------
// State // State
// -------------------------------------------------------- // --------------------------------------------------------
@@ -57,6 +57,7 @@ const state = {
activeListId: null, activeListId: null,
items: [], items: [],
activeList: null, activeList: null,
categories: [], // { id, name, icon, sort_order }[]
}; };
// -------------------------------------------------------- // --------------------------------------------------------
@@ -66,13 +67,14 @@ const state = {
function groupItemsByCategory(items) { function groupItemsByCategory(items) {
const grouped = {}; const grouped = {};
for (const item of items) { for (const item of items) {
const cat = item.category || 'Sonstiges'; const cat = item.category || (state.categories[0]?.name ?? 'Sonstiges');
(grouped[cat] = grouped[cat] || []).push(item); (grouped[cat] = grouped[cat] || []).push(item);
} }
// In Supermarkt-Gang-Reihenfolge zurückgeben // In DB-Reihenfolge zurückgeben; unbekannte Kategorien ans Ende
return ITEM_CATEGORIES const names = categoryNames();
.filter((c) => grouped[c]) const known = names.filter((c) => grouped[c]).map((c) => [c, grouped[c]]);
.map((c) => [c, grouped[c]]); const unknown = Object.keys(grouped).filter((c) => !names.includes(c)).map((c) => [c, grouped[c]]);
return [...known, ...unknown];
} }
// -------------------------------------------------------- // --------------------------------------------------------
@@ -155,7 +157,7 @@ function renderListContent(container) {
<div class="autocomplete-dropdown" id="autocomplete-dropdown" hidden></div> <div class="autocomplete-dropdown" id="autocomplete-dropdown" hidden></div>
</div> </div>
<select class="quick-add__cat" id="item-cat-select" aria-label="${t('shopping.categoryLabel')}"> <select class="quick-add__cat" id="item-cat-select" aria-label="${t('shopping.categoryLabel')}">
${(() => { const labels = CATEGORY_LABELS(); return ITEM_CATEGORIES.map((c) => `<option value="${c}">${labels[c] || c}</option>`).join(''); })()} ${state.categories.map((c) => `<option value="${esc(c.name)}">${esc(catLabel(c.name))}</option>`).join('')}
</select> </select>
<button class="quick-add__btn" type="submit" aria-label="${t('shopping.addItemLabel')}"> <button class="quick-add__btn" type="submit" aria-label="${t('shopping.addItemLabel')}">
<i data-lucide="plus" style="width:20px;height:20px" aria-hidden="true"></i> <i data-lucide="plus" style="width:20px;height:20px" aria-hidden="true"></i>
@@ -189,13 +191,12 @@ function renderItems() {
</div>`; </div>`;
} }
const catLabels = CATEGORY_LABELS();
const groups = groupItemsByCategory(state.items); const groups = groupItemsByCategory(state.items);
return groups.map(([cat, items]) => ` return groups.map(([cat, items]) => `
<div class="item-category"> <div class="item-category">
<div class="item-category__header"> <div class="item-category__header">
<i data-lucide="${CATEGORY_ICONS[cat] ?? 'tag'}" class="item-category__icon" aria-hidden="true"></i> <i data-lucide="${catIcon(cat)}" class="item-category__icon" aria-hidden="true"></i>
${catLabels[cat] || cat} ${esc(catLabel(cat))}
</div> </div>
${items.map(renderItem).join('')} ${items.map(renderItem).join('')}
</div>`).join(''); </div>`).join('');
@@ -592,10 +593,21 @@ async function loadLists() {
} }
} }
async function loadCategories() {
try {
const data = await api.get('/shopping/categories');
state.categories = data.data ?? [];
} catch {
state.categories = [];
}
}
async function loadItems(listId) { async function loadItems(listId) {
const data = await api.get(`/shopping/${listId}/items`); const data = await api.get(`/shopping/${listId}/items`);
state.items = data.data ?? []; state.items = data.data ?? [];
state.activeList = data.list ?? null; state.activeList = data.list ?? null;
// Kategorien aus API-Antwort übernehmen wenn vorhanden (immer aktuell)
if (data.categories?.length) state.categories = data.categories;
} }
async function switchList(listId, container) { async function switchList(listId, container) {
@@ -835,7 +847,7 @@ export async function render(container, { user }) {
`; `;
try { try {
await loadLists(); await Promise.all([loadCategories(), loadLists()]);
if (state.lists.length) { if (state.lists.length) {
state.activeListId = state.lists[0].id; state.activeListId = state.lists[0].id;
await loadItems(state.activeListId); await loadItems(state.activeListId);
+67
View File
@@ -362,3 +362,70 @@
.settings-logout-btn { .settings-logout-btn {
width: 100%; width: 100%;
} }
/* --------------------------------------------------------
Einkaufskategorien
-------------------------------------------------------- */
.cat-list {
list-style: none;
margin: 0 0 var(--space-3);
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.cat-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-1);
border-radius: var(--radius-sm);
transition: background var(--duration-fast);
}
.cat-row:hover {
background: var(--color-surface-raised);
}
.cat-row__icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--color-text-secondary);
}
.cat-row__name {
flex: 1;
min-width: 0;
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cat-row__name:hover {
color: var(--color-accent);
text-decoration: underline;
}
.cat-row__actions {
display: flex;
align-items: center;
gap: var(--space-1);
flex-shrink: 0;
}
.cat-add-form {
display: flex;
gap: var(--space-2);
margin-top: var(--space-2);
}
.cat-add-form .form-input {
flex: 1;
}
+24
View File
@@ -331,6 +331,30 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(due_date); CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(due_date);
`, `,
}, },
{
version: 5,
description: 'Einkaufskategorien als eigene Tabelle (anpassbar, sortierbar)',
up: `
CREATE TABLE IF NOT EXISTS shopping_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
icon TEXT NOT NULL DEFAULT 'tag',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
INSERT INTO shopping_categories (name, icon, sort_order) VALUES
('Obst & Gemüse', 'apple', 0),
('Backwaren', 'wheat', 1),
('Milchprodukte', 'milk', 2),
('Fleisch & Fisch', 'beef', 3),
('Tiefkühl', 'snowflake', 4),
('Getränke', 'cup-soda', 5),
('Haushalt', 'spray-can', 6),
('Drogerie', 'pill', 7),
('Sonstiges', 'shopping-basket', 8);
`,
},
]; ];
/** /**
+179 -15
View File
@@ -1,29 +1,186 @@
/** /**
* Modul: Einkaufslisten (Shopping) * Modul: Einkaufslisten (Shopping)
* Zweck: REST-API-Routen für Einkaufslisten, Artikel, Autocomplete * Zweck: REST-API-Routen für Einkaufslisten, Artikel, Kategorien, Autocomplete
* Abhängigkeiten: express, server/db.js * Abhängigkeiten: express, server/db.js
* *
* Routen-Reihenfolge: Statische Pfade (/suggestions, /items/:id) müssen * Routen-Reihenfolge: Statische Pfade (/suggestions, /categories, /items/:id) müssen
* vor dynamischen (/:listId) registriert sein, damit Express korrekt matcht. * vor dynamischen (/:listId) registriert sein, damit Express korrekt matcht.
*/ */
import { createLogger } from '../logger.js'; import { createLogger } from '../logger.js';
import express from 'express'; import express from 'express';
import * as db from '../db.js'; import * as db from '../db.js';
import { str, oneOf, collectErrors, MAX_TITLE, MAX_SHORT } from '../middleware/validate.js'; import { str, oneOf, num, collectErrors, MAX_TITLE, MAX_SHORT } from '../middleware/validate.js';
const log = createLogger('Shopping'); const log = createLogger('Shopping');
const router = express.Router(); const router = express.Router();
// -------------------------------------------------------- // --------------------------------------------------------
// Konstanten // Hilfsfunktionen
// -------------------------------------------------------- // --------------------------------------------------------
const ITEM_CATEGORIES = [ /** Alle Kategorien aus DB laden (nach sort_order sortiert). */
'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch', function loadCategories() {
'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges', return db.get().prepare('SELECT * FROM shopping_categories ORDER BY sort_order ASC').all();
]; }
/** Kategorie-Namen-Array für Validierung. */
function validCategoryNames() {
return loadCategories().map((c) => c.name);
}
// --------------------------------------------------------
// GET /api/v1/shopping/categories
// Alle Kategorien zurückgeben.
// Response: { data: ShoppingCategory[] }
// --------------------------------------------------------
router.get('/categories', (_req, res) => {
try {
res.json({ data: loadCategories() });
} catch (err) {
log.error('GET /categories Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// POST /api/v1/shopping/categories
// Neue Kategorie erstellen.
// Body: { name }
// Response: { data: ShoppingCategory }
// --------------------------------------------------------
router.post('/categories', (req, res) => {
try {
const vName = str(req.body.name, 'Name', { max: MAX_SHORT });
if (vName.error) return res.status(400).json({ error: vName.error, code: 400 });
const existing = db.get()
.prepare('SELECT id FROM shopping_categories WHERE name = ? COLLATE NOCASE')
.get(vName.value);
if (existing) return res.status(409).json({ error: 'Kategorie existiert bereits.', code: 409 });
const maxOrder = db.get()
.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM shopping_categories')
.get().m;
const result = db.get()
.prepare('INSERT INTO shopping_categories (name, icon, sort_order) VALUES (?, ?, ?)')
.run(vName.value, 'tag', maxOrder + 1);
const cat = db.get()
.prepare('SELECT * FROM shopping_categories WHERE id = ?')
.get(result.lastInsertRowid);
res.status(201).json({ data: cat });
} catch (err) {
log.error('POST /categories Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// PUT /api/v1/shopping/categories/:catId
// Kategorie umbenennen.
// Body: { name }
// Response: { data: ShoppingCategory }
// --------------------------------------------------------
router.put('/categories/:catId', (req, res) => {
try {
const cat = db.get()
.prepare('SELECT * FROM shopping_categories WHERE id = ?')
.get(req.params.catId);
if (!cat) return res.status(404).json({ error: 'Kategorie nicht gefunden.', code: 404 });
const vName = str(req.body.name, 'Name', { max: MAX_SHORT });
if (vName.error) return res.status(400).json({ error: vName.error, code: 400 });
const conflict = db.get()
.prepare('SELECT id FROM shopping_categories WHERE name = ? COLLATE NOCASE AND id != ?')
.get(vName.value, cat.id);
if (conflict) return res.status(409).json({ error: 'Kategorie existiert bereits.', code: 409 });
// Artikel, die die alte Kategorie nutzen, mitumbenennen
db.get().transaction(() => {
db.get()
.prepare('UPDATE shopping_items SET category = ? WHERE category = ?')
.run(vName.value, cat.name);
db.get()
.prepare('UPDATE shopping_categories SET name = ? WHERE id = ?')
.run(vName.value, cat.id);
})();
const updated = db.get()
.prepare('SELECT * FROM shopping_categories WHERE id = ?')
.get(cat.id);
res.json({ data: updated });
} catch (err) {
log.error('PUT /categories/:catId Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// DELETE /api/v1/shopping/categories/:catId
// Kategorie löschen (Artikel werden zu "Sonstiges" verschoben).
// Die letzte verbleibende Kategorie kann nicht gelöscht werden.
// Response: { ok: true }
// --------------------------------------------------------
router.delete('/categories/:catId', (req, res) => {
try {
const cat = db.get()
.prepare('SELECT * FROM shopping_categories WHERE id = ?')
.get(req.params.catId);
if (!cat) return res.status(404).json({ error: 'Kategorie nicht gefunden.', code: 404 });
const total = db.get()
.prepare('SELECT COUNT(*) AS c FROM shopping_categories')
.get().c;
if (total <= 1) return res.status(400).json({ error: 'Letzte Kategorie kann nicht gelöscht werden.', code: 400 });
// Fallback-Kategorie: erste andere Kategorie nach sort_order
const fallback = db.get()
.prepare('SELECT name FROM shopping_categories WHERE id != ? ORDER BY sort_order ASC LIMIT 1')
.get(cat.id);
db.get().transaction(() => {
db.get()
.prepare('UPDATE shopping_items SET category = ? WHERE category = ?')
.run(fallback.name, cat.name);
db.get()
.prepare('DELETE FROM shopping_categories WHERE id = ?')
.run(cat.id);
})();
res.json({ ok: true });
} catch (err) {
log.error('DELETE /categories/:catId Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// --------------------------------------------------------
// PATCH /api/v1/shopping/categories/reorder
// Reihenfolge der Kategorien ändern.
// Body: { order: number[] } (Array von IDs in gewünschter Reihenfolge)
// Response: { data: ShoppingCategory[] }
// --------------------------------------------------------
router.patch('/categories/reorder', (req, res) => {
try {
const { order } = req.body;
if (!Array.isArray(order) || order.length === 0)
return res.status(400).json({ error: 'order muss ein nicht-leeres Array von IDs sein.', code: 400 });
const update = db.get().prepare('UPDATE shopping_categories SET sort_order = ? WHERE id = ?');
db.get().transaction(() => {
order.forEach((id, idx) => update.run(idx, id));
})();
res.json({ data: loadCategories() });
} catch (err) {
log.error('PATCH /categories/reorder Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
// -------------------------------------------------------- // --------------------------------------------------------
// GET /api/v1/shopping/suggestions?q=… // GET /api/v1/shopping/suggestions?q=…
@@ -70,7 +227,9 @@ router.patch('/items/:itemId', (req, res) => {
} = req.body; } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'name darf nicht leer sein.', code: 400 }); if (!name?.trim()) return res.status(400).json({ error: 'name darf nicht leer sein.', code: 400 });
if (category && !ITEM_CATEGORIES.includes(category))
const validNames = validCategoryNames();
if (category && !validNames.includes(category))
return res.status(400).json({ error: 'Ungültige Kategorie.', code: 400 }); return res.status(400).json({ error: 'Ungültige Kategorie.', code: 400 });
db.get().prepare(` db.get().prepare(`
@@ -207,7 +366,7 @@ router.delete('/:listId', (req, res) => {
// GET /api/v1/shopping/:listId/items // GET /api/v1/shopping/:listId/items
// Alle Artikel einer Liste, sortiert nach Supermarkt-Gang-Logik. // Alle Artikel einer Liste, sortiert nach Supermarkt-Gang-Logik.
// Abgehakte Artikel ans Ende innerhalb ihrer Kategorie. // Abgehakte Artikel ans Ende innerhalb ihrer Kategorie.
// Response: { data: ShoppingItem[], list: ShoppingList } // Response: { data: ShoppingItem[], list: ShoppingList, categories: ShoppingCategory[] }
// -------------------------------------------------------- // --------------------------------------------------------
router.get('/:listId/items', (req, res) => { router.get('/:listId/items', (req, res) => {
try { try {
@@ -216,18 +375,19 @@ router.get('/:listId/items', (req, res) => {
.get(req.params.listId); .get(req.params.listId);
if (!list) return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 }); if (!list) return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 });
const categoryOrder = ITEM_CATEGORIES.map((c, i) => `WHEN '${c}' THEN ${i}`).join(' '); const categories = loadCategories();
const categoryOrder = categories.map((c, i) => `WHEN '${c.name.replace(/'/g, "''")}' THEN ${i}`).join(' ');
const items = db.get().prepare(` const items = db.get().prepare(`
SELECT * FROM shopping_items SELECT * FROM shopping_items
WHERE list_id = ? WHERE list_id = ?
ORDER BY ORDER BY
CASE category ${categoryOrder} ELSE ${ITEM_CATEGORIES.length} END, CASE category ${categoryOrder} ELSE ${categories.length} END,
is_checked ASC, is_checked ASC,
created_at ASC created_at ASC
`).all(req.params.listId); `).all(req.params.listId);
res.json({ data: items, list }); res.json({ data: items, list, categories });
} catch (err) { } catch (err) {
log.error('GET /:listId/items Fehler:', err); log.error('GET /:listId/items Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
@@ -247,16 +407,20 @@ router.post('/:listId/items', (req, res) => {
.get(req.params.listId); .get(req.params.listId);
if (!list) return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 }); if (!list) return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 });
const validNames = validCategoryNames();
const defaultCat = validNames[0] ?? 'Sonstiges';
const requestedCat = req.body.category || defaultCat;
const vName = str(req.body.name, 'Name', { max: MAX_TITLE }); const vName = str(req.body.name, 'Name', { max: MAX_TITLE });
const vQty = str(req.body.quantity, 'Menge', { max: MAX_SHORT, required: false }); const vQty = str(req.body.quantity, 'Menge', { max: MAX_SHORT, required: false });
const vCat = oneOf(req.body.category || 'Sonstiges', ITEM_CATEGORIES, 'Kategorie'); const vCat = oneOf(requestedCat, validNames, 'Kategorie');
const errors = collectErrors([vName, vQty, vCat]); const errors = collectErrors([vName, vQty, vCat]);
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
const result = db.get().prepare(` const result = db.get().prepare(`
INSERT INTO shopping_items (list_id, name, quantity, category) INSERT INTO shopping_items (list_id, name, quantity, category)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`).run(req.params.listId, vName.value, vQty.value, vCat.value || 'Sonstiges'); `).run(req.params.listId, vName.value, vQty.value, vCat.value || defaultCat);
const item = db.get() const item = db.get()
.prepare('SELECT * FROM shopping_items WHERE id = ?') .prepare('SELECT * FROM shopping_items WHERE id = ?')