/** * Modul: Einkaufslisten (Shopping) * Zweck: Multi-Listen-Tabs, Artikel mit Kategorie-Gruppierung, Quick-Add mit Autocomplete * Abhängigkeiten: /api.js */ import { api } from '/api.js'; import { stagger, vibrate } from '/utils/ux.js'; // -------------------------------------------------------- // 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(); stagger(content.querySelectorAll('.shopping-item')); wireAutocomplete(container); wireQuickAdd(container); } function renderItems() { if (!state.items.length) { return `
Die Liste ist leer
Artikel über das Eingabefeld oben hinzufügen.
`; } const groups = groupItemsByCategory(state.items); return groups.map(([cat, items]) => `
${cat}
${items.map(renderItem).join('')}
`).join(''); } function renderItem(item) { const isDone = Boolean(item.is_checked); 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(); stagger(listEl.querySelectorAll('.shopping-item')); } // 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() { try { const data = await api.get('/shopping'); state.lists = data.data ?? []; } catch (err) { console.error('[Shopping] loadLists Fehler:', err); state.lists = []; window.oikos?.showToast('Listen konnten nicht geladen werden.', 'danger'); } } 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 (err) { console.error('[Shopping] loadItems Fehler:', err); state.items = []; state.activeList = state.lists.find((l) => l.id === listId) ?? null; window.oikos?.showToast('Artikel konnten nicht geladen werden.', 'danger'); } 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 }); vibrate(10); } 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 = `
${[1,2,3].map(() => `
`).join('')}
`; try { await loadLists(); if (state.lists.length) { state.activeListId = state.lists[0].id; await loadItems(state.activeListId); } } catch (err) { console.error('[Shopping] Ladefehler:', err.message); window.oikos.showToast('Einkaufslisten konnten nicht geladen werden.', 'danger'); } container.innerHTML = `

Einkaufslisten

`; renderTabs(container); wireTabBar(container); renderListContent(container); wireListContentEvents(container); container.querySelector('#fab-new-item')?.addEventListener('click', () => { const input = container.querySelector('#item-name-input'); if (input) { input.scrollIntoView({ behavior: 'smooth', block: 'center' }); input.focus(); } else { // Keine Liste aktiv → neue Liste erstellen container.querySelector('[data-action="new-list"]')?.click(); } }); }