diff --git a/CHANGELOG.md b/CHANGELOG.md index 70d2bb9..97ab076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 ### Changed diff --git a/package.json b/package.json index 96e828d..51423a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "main": "server/index.js", "type": "module", diff --git a/public/locales/de.json b/public/locales/de.json index 78b6799..9b917b5 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -455,6 +455,19 @@ "settings": { "title": "Einstellungen", "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", "sectionCalendarSync": "Kalender-Synchronisation", "sectionFamily": "Familienmitglieder", diff --git a/public/locales/en.json b/public/locales/en.json index fdeab0a..353964e 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -455,6 +455,19 @@ "settings": { "title": "Settings", "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", "sectionCalendarSync": "Calendar Sync", "sectionFamily": "Family Members", diff --git a/public/locales/it.json b/public/locales/it.json index eb99f2f..51202c1 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -455,6 +455,19 @@ "settings": { "title": "Impostazioni", "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", "sectionCalendarSync": "Sincronizzazione calendario", "sectionFamily": "Membri della famiglia", diff --git a/public/locales/sv.json b/public/locales/sv.json index 661de94..4f87a89 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -455,6 +455,19 @@ "settings": { "title": "Inställningar", "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", "sectionCalendarSync": "Kalendersynkronisering", "sectionFamily": "Familjemedlemmar", diff --git a/public/pages/settings.js b/public/pages/settings.js index c06723b..66dbde8 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -40,18 +40,21 @@ export async function render(container, { user }) { let googleStatus = { configured: false, connected: false, lastSync: null }; let appleStatus = { configured: false, lastSync: null }; let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR' }; + let categories = []; 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: [] }), api.get('/calendar/google/status'), api.get('/calendar/apple/status'), api.get('/preferences'), + api.get('/shopping/categories'), ]); if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? []; if (gStatus.status === 'fulfilled') googleStatus = gStatus.value; if (aStatus.status === 'fulfilled') appleStatus = aStatus.value; if (prefsRes.status === 'fulfilled') prefs = prefsRes.value.data ?? prefs; + if (catsRes.status === 'fulfilled') categories = catsRes.value.data ?? []; } catch (_) { /* non-critical */ } const googleStatusText = googleStatus.connected @@ -142,6 +145,24 @@ export async function render(container, { user }) { + + + ${t('settings.sectionShopping')} + + ${t('settings.shoppingCategoriesLabel')} + ${t('settings.shoppingCategoriesHint')} + + ${categories.map((c, i) => categoryRowHtml(c, i === 0, i === categories.length - 1)).join('')} + + + + ${t('common.add')} + + + + ${t('settings.sectionAccount')} @@ -317,14 +338,15 @@ export async function render(container, { user }) { }); } - bindEvents(container, user); + bindEvents(container, user, categories); } // -------------------------------------------------------- // Event-Binding // -------------------------------------------------------- -function bindEvents(container, user) { +function bindEvents(container, user, categories) { + bindCategoryEvents(container); // Theme-Toggle const themeToggle = container.querySelector('#theme-toggle'); if (themeToggle) { @@ -585,6 +607,146 @@ function bindDeleteButtons(container, user) { } +// -------------------------------------------------------- +// Kategorie-Verwaltung +// -------------------------------------------------------- + +function categoryRowHtml(cat, isFirst, isLast) { + return ` + + + ${esc(cat.name)} + + + + + + + + + + + + `; +} + +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) { return ` diff --git a/public/pages/shopping.js b/public/pages/shopping.js index a164b9e..1cd43b3 100644 --- a/public/pages/shopping.js +++ b/public/pages/shopping.js @@ -19,35 +19,35 @@ const SWIPE_THRESHOLD = 80; // px - Mindestweg für Aktion const SWIPE_MAX_VERT = 12; // px - vertikaler Toleranzbereich const SWIPE_LOCK_VERT = 30; // px - ab diesem Weg gilt es als Scroll -const ITEM_CATEGORIES = [ - 'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch', - 'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges', -]; - -const CATEGORY_LABELS = () => ({ - 'Obst & Gemüse': t('shopping.catFruitVeg'), - 'Backwaren': t('shopping.catBakery'), - 'Milchprodukte': t('shopping.catDairy'), - 'Fleisch & Fisch': t('shopping.catMeatFish'), - 'Tiefkühl': t('shopping.catFrozen'), - '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', +// Übersetzungs-Map für die Standard-Kategorien (DB-Name → i18n-Key) +const DEFAULT_CATEGORY_I18N = { + 'Obst & Gemüse': 'shopping.catFruitVeg', + 'Backwaren': 'shopping.catBakery', + 'Milchprodukte': 'shopping.catDairy', + 'Fleisch & Fisch': 'shopping.catMeatFish', + 'Tiefkühl': 'shopping.catFrozen', + 'Getränke': 'shopping.catDrinks', + 'Haushalt': 'shopping.catHousehold', + 'Drogerie': 'shopping.catDrugstore', + 'Sonstiges': 'shopping.catMisc', }; +/** Ü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 // -------------------------------------------------------- @@ -57,6 +57,7 @@ const state = { activeListId: null, items: [], activeList: null, + categories: [], // { id, name, icon, sort_order }[] }; // -------------------------------------------------------- @@ -66,13 +67,14 @@ const state = { function groupItemsByCategory(items) { const grouped = {}; 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); } - // In Supermarkt-Gang-Reihenfolge zurückgeben - return ITEM_CATEGORIES - .filter((c) => grouped[c]) - .map((c) => [c, grouped[c]]); + // In DB-Reihenfolge zurückgeben; unbekannte Kategorien ans Ende + const names = categoryNames(); + const known = names.filter((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) { - ${(() => { const labels = CATEGORY_LABELS(); return ITEM_CATEGORIES.map((c) => `${labels[c] || c}`).join(''); })()} + ${state.categories.map((c) => `${esc(catLabel(c.name))}`).join('')} @@ -189,13 +191,12 @@ function renderItems() { `; } - const catLabels = CATEGORY_LABELS(); const groups = groupItemsByCategory(state.items); return groups.map(([cat, items]) => ` - - ${catLabels[cat] || cat} + + ${esc(catLabel(cat))} ${items.map(renderItem).join('')} `).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) { const data = await api.get(`/shopping/${listId}/items`); state.items = data.data ?? []; 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) { @@ -835,7 +847,7 @@ export async function render(container, { user }) { `; try { - await loadLists(); + await Promise.all([loadCategories(), loadLists()]); if (state.lists.length) { state.activeListId = state.lists[0].id; await loadItems(state.activeListId); diff --git a/public/styles/settings.css b/public/styles/settings.css index 1fbaf23..00ba886 100644 --- a/public/styles/settings.css +++ b/public/styles/settings.css @@ -362,3 +362,70 @@ .settings-logout-btn { 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; +} diff --git a/server/db.js b/server/db.js index 1a1269c..dc1ec57 100644 --- a/server/db.js +++ b/server/db.js @@ -331,6 +331,30 @@ const MIGRATIONS = [ 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); + `, + }, ]; /** diff --git a/server/routes/shopping.js b/server/routes/shopping.js index 18b4fac..6857a08 100644 --- a/server/routes/shopping.js +++ b/server/routes/shopping.js @@ -1,29 +1,186 @@ /** * 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 * - * 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. */ import { createLogger } from '../logger.js'; import express from 'express'; 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 router = express.Router(); // -------------------------------------------------------- -// Konstanten +// Hilfsfunktionen // -------------------------------------------------------- -const ITEM_CATEGORIES = [ - 'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch', - 'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges', -]; +/** Alle Kategorien aus DB laden (nach sort_order sortiert). */ +function loadCategories() { + 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=… @@ -70,7 +227,9 @@ router.patch('/items/:itemId', (req, res) => { } = req.body; 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 }); db.get().prepare(` @@ -207,7 +366,7 @@ router.delete('/:listId', (req, res) => { // GET /api/v1/shopping/:listId/items // Alle Artikel einer Liste, sortiert nach Supermarkt-Gang-Logik. // 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) => { try { @@ -216,18 +375,19 @@ router.get('/:listId/items', (req, res) => { .get(req.params.listId); 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(` SELECT * FROM shopping_items WHERE list_id = ? ORDER BY - CASE category ${categoryOrder} ELSE ${ITEM_CATEGORIES.length} END, + CASE category ${categoryOrder} ELSE ${categories.length} END, is_checked ASC, created_at ASC `).all(req.params.listId); - res.json({ data: items, list }); + res.json({ data: items, list, categories }); } catch (err) { log.error('GET /:listId/items Fehler:', err); res.status(500).json({ error: 'Interner Serverfehler.', code: 500 }); @@ -247,16 +407,20 @@ router.post('/:listId/items', (req, res) => { .get(req.params.listId); 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 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]); if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); const result = db.get().prepare(` INSERT INTO shopping_items (list_id, name, quantity, category) 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() .prepare('SELECT * FROM shopping_items WHERE id = ?')
${t('settings.shoppingCategoriesHint')}