diff --git a/package.json b/package.json
index 4773337..80895d1 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,8 @@
"test:db": "node --experimental-sqlite test-db.js",
"test:dashboard": "node --experimental-sqlite test-dashboard.js",
"test:tasks": "node --experimental-sqlite test-tasks.js",
- "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js"
+ "test:shopping": "node --experimental-sqlite test-shopping.js",
+ "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js"
},
"dependencies": {
"bcrypt": "^5.1.1",
diff --git a/public/index.html b/public/index.html
index aef0079..ab09bcb 100644
--- a/public/index.html
+++ b/public/index.html
@@ -18,6 +18,7 @@
+
diff --git a/public/pages/shopping.js b/public/pages/shopping.js
index 9929066..6e30a29 100644
--- a/public/pages/shopping.js
+++ b/public/pages/shopping.js
@@ -1,25 +1,557 @@
/**
- * Modul: Shopping
- * Zweck: Seite für das Shopping-Modul
+ * Modul: Einkaufslisten (Shopping)
+ * Zweck: Multi-Listen-Tabs, Artikel mit Kategorie-Gruppierung, Quick-Add mit Autocomplete
* Abhängigkeiten: /api.js
*/
import { api } from '/api.js';
-/**
- * @param {HTMLElement} container
- * @param {{ user: object }} context
- */
+// --------------------------------------------------------
+// Konstanten
+// --------------------------------------------------------
+
+const ITEM_CATEGORIES = [
+ 'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch',
+ 'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges',
+];
+
+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',
+};
+
+// --------------------------------------------------------
+// State
+// --------------------------------------------------------
+
+const state = {
+ lists: [],
+ activeListId: null,
+ items: [],
+ activeList: null,
+};
+
+// --------------------------------------------------------
+// Hilfsfunktionen
+// --------------------------------------------------------
+
+function groupItemsByCategory(items) {
+ const grouped = {};
+ for (const item of items) {
+ const cat = item.category || '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]]);
+}
+
+// --------------------------------------------------------
+// Render-Bausteine
+// --------------------------------------------------------
+
+function renderTabs(container) {
+ const bar = container.querySelector('#list-tabs-bar');
+ if (!bar) return;
+
+ const tabsHtml = state.lists.map((list) => {
+ const unchecked = list.item_total - list.item_checked;
+ return `
+ `;
+ }).join('');
+
+ bar.innerHTML = `
+ ${tabsHtml}
+
+ `;
+ if (window.lucide) window.lucide.createIcons();
+}
+
+function renderListContent(container) {
+ const content = container.querySelector('#list-content');
+ if (!content) return;
+
+ if (!state.activeList) {
+ content.innerHTML = `
+
+
+
Keine Listen
+
+ Erstelle eine Liste mit dem + Button.
+
+
`;
+ if (window.lucide) window.lucide.createIcons();
+ return;
+ }
+
+ const checkedCount = state.items.filter((i) => i.is_checked).length;
+
+ content.innerHTML = `
+
+
+
+
+
+
+
+
+ ${renderItems()}
+
+ `;
+
+ if (window.lucide) window.lucide.createIcons();
+ wireAutocomplete(container);
+ wireQuickAdd(container);
+}
+
+function renderItems() {
+ if (!state.items.length) {
+ return `
+ `;
+ }
+
+ const groups = groupItemsByCategory(state.items);
+ return groups.map(([cat, items]) => `
+
+
+ ${items.map(renderItem).join('')}
+
`).join('');
+}
+
+function renderItem(item) {
+ return `
+ `;
+}
+
+// --------------------------------------------------------
+// Autocomplete
+// --------------------------------------------------------
+
+let autocompleteTimeout = null;
+
+function wireAutocomplete(container) {
+ const input = container.querySelector('#item-name-input');
+ const dropdown = container.querySelector('#autocomplete-dropdown');
+ if (!input || !dropdown) return;
+
+ let activeIdx = -1;
+
+ input.addEventListener('input', () => {
+ clearTimeout(autocompleteTimeout);
+ const q = input.value.trim();
+ if (q.length < 1) { dropdown.hidden = true; return; }
+
+ autocompleteTimeout = setTimeout(async () => {
+ try {
+ const data = await api.get(`/shopping/suggestions?q=${encodeURIComponent(q)}`);
+ const suggestions = data.data ?? [];
+ if (!suggestions.length) { dropdown.hidden = true; return; }
+
+ dropdown.innerHTML = suggestions.map((s, i) =>
+ `${s}
`
+ ).join('');
+ dropdown.hidden = false;
+ activeIdx = -1;
+
+ dropdown.querySelectorAll('.autocomplete-item').forEach((el) => {
+ el.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ input.value = el.dataset.value;
+ dropdown.hidden = true;
+ });
+ });
+
+ if (window.lucide) window.lucide.createIcons();
+ } catch { dropdown.hidden = true; }
+ }, 200);
+ });
+
+ input.addEventListener('keydown', (e) => {
+ if (dropdown.hidden) return;
+ const items = dropdown.querySelectorAll('.autocomplete-item');
+ if (!items.length) return;
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ activeIdx = Math.min(activeIdx + 1, items.length - 1);
+ items.forEach((el, i) => el.classList.toggle('autocomplete-item--active', i === activeIdx));
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ activeIdx = Math.max(activeIdx - 1, 0);
+ items.forEach((el, i) => el.classList.toggle('autocomplete-item--active', i === activeIdx));
+ } else if (e.key === 'Enter' && activeIdx >= 0) {
+ e.preventDefault();
+ input.value = items[activeIdx].dataset.value;
+ dropdown.hidden = true;
+ } else if (e.key === 'Escape') {
+ dropdown.hidden = true;
+ }
+ });
+
+ input.addEventListener('blur', () => {
+ setTimeout(() => { dropdown.hidden = true; }, 150);
+ });
+}
+
+// --------------------------------------------------------
+// Quick-Add Form
+// --------------------------------------------------------
+
+function wireQuickAdd(container) {
+ const form = container.querySelector('#quick-add-form');
+ if (!form) return;
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const nameInput = container.querySelector('#item-name-input');
+ const qtyInput = container.querySelector('#item-qty-input');
+ const catSelect = container.querySelector('#item-cat-select');
+
+ const name = nameInput.value.trim();
+ const quantity = qtyInput.value.trim() || null;
+ const category = catSelect.value;
+
+ if (!name) { nameInput.focus(); return; }
+
+ try {
+ const data = await api.post(`/shopping/${state.activeListId}/items`, { name, quantity, category });
+ state.items.push(data.data);
+ // Einfügen in DOM ohne komplettes Re-Render
+ updateItemsList(container);
+ updateListCounter(state.activeListId, 1, 0);
+ renderTabs(container);
+ nameInput.value = '';
+ qtyInput.value = '';
+ nameInput.focus();
+ } catch (err) {
+ window.oikos.showToast(err.message, 'danger');
+ }
+ });
+}
+
+// --------------------------------------------------------
+// DOM-Updates (ohne komplettes Re-Render)
+// --------------------------------------------------------
+
+function updateItemsList(container) {
+ const listEl = container.querySelector('#items-list');
+ if (listEl) {
+ listEl.innerHTML = renderItems();
+ if (window.lucide) window.lucide.createIcons();
+ }
+ // clear-checked Button aktualisieren
+ const checkedCount = state.items.filter((i) => i.is_checked).length;
+ const clearBtn = container.querySelector('[data-action="clear-checked"]');
+ const header = container.querySelector('.list-header__actions');
+ if (header) {
+ if (checkedCount > 0 && !clearBtn) {
+ header.insertAdjacentHTML('afterbegin', `
+ `);
+ if (window.lucide) window.lucide.createIcons();
+ } else if (clearBtn) {
+ if (checkedCount === 0) {
+ clearBtn.remove();
+ } else {
+ clearBtn.innerHTML = `
+
+ Abgehakt löschen (${checkedCount})`;
+ if (window.lucide) window.lucide.createIcons();
+ }
+ }
+ }
+}
+
+function updateListCounter(listId, totalDelta, checkedDelta) {
+ const list = state.lists.find((l) => l.id === listId);
+ if (list) {
+ list.item_total = (list.item_total || 0) + totalDelta;
+ list.item_checked = (list.item_checked || 0) + checkedDelta;
+ }
+}
+
+// --------------------------------------------------------
+// API-Aktionen
+// --------------------------------------------------------
+
+async function loadLists() {
+ const data = await api.get('/shopping');
+ state.lists = data.data ?? [];
+}
+
+async function loadItems(listId) {
+ const data = await api.get(`/shopping/${listId}/items`);
+ state.items = data.data ?? [];
+ state.activeList = data.list ?? null;
+}
+
+async function switchList(listId, container) {
+ state.activeListId = listId;
+ renderTabs(container);
+ try {
+ await loadItems(listId);
+ } catch {
+ state.items = [];
+ state.activeList = state.lists.find((l) => l.id === listId) ?? null;
+ }
+ renderListContent(container);
+ wireListContentEvents(container);
+}
+
+// --------------------------------------------------------
+// Event-Verdrahtung
+// --------------------------------------------------------
+
+function wireTabBar(container) {
+ container.querySelector('#list-tabs-bar')?.addEventListener('click', async (e) => {
+ const target = e.target.closest('[data-action]');
+ if (!target) return;
+
+ if (target.dataset.action === 'switch-list') {
+ await switchList(Number(target.dataset.id), container);
+ }
+
+ if (target.dataset.action === 'new-list') {
+ const name = prompt('Name der neuen Liste:');
+ if (!name?.trim()) return;
+ try {
+ const data = await api.post('/shopping', { name: name.trim() });
+ state.lists.push({ ...data.data, item_total: 0, item_checked: 0 });
+ await switchList(data.data.id, container);
+ } catch (err) {
+ window.oikos.showToast(err.message, 'danger');
+ }
+ }
+ });
+}
+
+function wireListContentEvents(container) {
+ const content = container.querySelector('#list-content');
+ if (!content) return;
+
+ content.addEventListener('click', async (e) => {
+ const target = e.target.closest('[data-action]');
+ if (!target) return;
+ const action = target.dataset.action;
+
+ // ---- Artikel abhaken ----
+ if (action === 'toggle-item') {
+ const id = Number(target.dataset.id);
+ const checked = Number(target.dataset.checked);
+ const newVal = checked ? 0 : 1;
+
+ // Optimistisches Update
+ const item = state.items.find((i) => i.id === id);
+ if (item) {
+ item.is_checked = newVal;
+ updateItemsList(container);
+ updateListCounter(state.activeListId, 0, newVal ? 1 : -1);
+ renderTabs(container);
+ }
+
+ try {
+ await api.patch(`/shopping/items/${id}`, { is_checked: newVal });
+ } catch (err) {
+ // Zurückrollen
+ if (item) item.is_checked = checked;
+ updateItemsList(container);
+ window.oikos.showToast(err.message, 'danger');
+ }
+ }
+
+ // ---- Artikel löschen ----
+ if (action === 'delete-item') {
+ const id = Number(target.dataset.id);
+ const item = state.items.find((i) => i.id === id);
+ try {
+ await api.delete(`/shopping/items/${id}`);
+ state.items = state.items.filter((i) => i.id !== id);
+ updateItemsList(container);
+ updateListCounter(state.activeListId, -1, item?.is_checked ? -1 : 0);
+ renderTabs(container);
+ } catch (err) {
+ window.oikos.showToast(err.message, 'danger');
+ }
+ }
+
+ // ---- Abgehakte löschen ----
+ if (action === 'clear-checked') {
+ const count = state.items.filter((i) => i.is_checked).length;
+ if (!count) return;
+ try {
+ await api.delete(`/shopping/${state.activeListId}/items/checked`);
+ state.items = state.items.filter((i) => !i.is_checked);
+ updateItemsList(container);
+ updateListCounter(state.activeListId, -count, -count);
+ renderTabs(container);
+ window.oikos.showToast(`${count} Artikel entfernt.`);
+ } catch (err) {
+ window.oikos.showToast(err.message, 'danger');
+ }
+ }
+
+ // ---- Liste umbenennen ----
+ if (action === 'rename-list') {
+ const newName = prompt('Neuer Listen-Name:', state.activeList?.name);
+ if (!newName?.trim() || newName.trim() === state.activeList?.name) return;
+ try {
+ const data = await api.put(`/shopping/${state.activeListId}`, { name: newName.trim() });
+ const idx = state.lists.findIndex((l) => l.id === state.activeListId);
+ if (idx >= 0) state.lists[idx].name = data.data.name;
+ state.activeList = data.data;
+ renderTabs(container);
+ renderListContent(container);
+ wireListContentEvents(container);
+ } catch (err) {
+ window.oikos.showToast(err.message, 'danger');
+ }
+ }
+
+ // ---- Liste löschen ----
+ if (action === 'delete-list') {
+ if (!confirm(`Liste "${state.activeList?.name}" und alle Artikel löschen?`)) return;
+ try {
+ await api.delete(`/shopping/${state.activeListId}`);
+ state.lists = state.lists.filter((l) => l.id !== state.activeListId);
+ state.activeListId = state.lists[0]?.id ?? null;
+ if (state.activeListId) {
+ await switchList(state.activeListId, container);
+ } else {
+ state.items = [];
+ state.activeList = null;
+ renderTabs(container);
+ renderListContent(container);
+ }
+ window.oikos.showToast('Liste gelöscht.');
+ } catch (err) {
+ window.oikos.showToast(err.message, 'danger');
+ }
+ }
+ });
+
+ // Rename per Enter
+ content.querySelector('[data-action="rename-list"]')?.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') e.currentTarget.click();
+ });
+}
+
+// --------------------------------------------------------
+// Haupt-Render
+// --------------------------------------------------------
+
export async function render(container, { user }) {
container.innerHTML = `
-
-