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 = ` + +
+ + ${state.activeList.name} + + +
+ ${checkedCount > 0 ? ` + ` : ''} + +
+
+ + +
+
+
+ + + +
+ + +
+
+ + +
+ ${renderItems()} +
+ `; + + if (window.lucide) window.lucide.createIcons(); + wireAutocomplete(container); + wireQuickAdd(container); +} + +function renderItems() { + if (!state.items.length) { + return ` +
+ +
Liste ist leer
+
Füge Artikel mit dem Eingabefeld oben hinzu.
+
`; + } + + const groups = groupItemsByCategory(state.items); + return groups.map(([cat, items]) => ` +
+
+ + ${cat} +
+ ${items.map(renderItem).join('')} +
`).join(''); +} + +function renderItem(item) { + return ` +
+ +
+
${item.name}
+ ${item.quantity ? `
${item.quantity}
` : ''} +
+ +
`; +} + +// -------------------------------------------------------- +// 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 = ` -
-