feat: Phase 2 Schritt 10 — Einkaufslisten-Modul
Backend: - GET /shopping — alle Listen mit item_total/item_checked Zähler - POST/PUT/DELETE /shopping/:listId — Listen-CRUD - GET /shopping/:listId/items — Artikel nach Supermarkt-Gang sortiert (Obst→Backwaren→Milch→Fleisch→Tiefkühl→Getränke→Haushalt→Drogerie→Sonstiges) Abgehakte innerhalb der Kategorie ans Ende - POST /shopping/:listId/items — Artikel hinzufügen - PATCH /shopping/items/:id — Artikel aktualisieren (abhaken, umbenennen) - DELETE /shopping/items/:id — Einzelartikel löschen - DELETE /shopping/:listId/items/checked — nur abgehakte löschen - GET /shopping/suggestions?q= — Autocomplete aus bisherigen Einträgen Frontend: - Multi-Listen-Tabs (horizontal scrollbar, Artikel-Zähler im Tab) - Quick-Add: Name + Menge + Kategorie-Dropdown in einer Zeile - Autocomplete-Dropdown mit Tastaturnavigation (↑↓ Enter Escape) - Optimistisches Toggle: Checkbox reagiert sofort, Rollback bei Fehler - "Abgehakt löschen"-Button erscheint dynamisch bei checked > 0 - Listen umbenennen/löschen direkt im Header - Kategorie-Icons (Lucide) in Gruppen-Überschriften Tests: 17/17 bestanden (71 gesamt) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -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",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<link rel="stylesheet" href="/styles/login.css" />
|
||||
<link rel="stylesheet" href="/styles/dashboard.css" />
|
||||
<link rel="stylesheet" href="/styles/tasks.css" />
|
||||
<link rel="stylesheet" href="/styles/shopping.css" />
|
||||
|
||||
<!-- Lucide Icons (CDN) -->
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
|
||||
+544
-12
@@ -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 `
|
||||
<button class="list-tab ${list.id === state.activeListId ? 'list-tab--active' : ''}"
|
||||
data-action="switch-list" data-id="${list.id}">
|
||||
${list.name}
|
||||
${list.item_total > 0 ? `<span class="list-tab__count">${unchecked > 0 ? unchecked : '✓'}</span>` : ''}
|
||||
</button>`;
|
||||
}).join('');
|
||||
|
||||
bar.innerHTML = `
|
||||
${tabsHtml}
|
||||
<button class="list-tab__new" data-action="new-list" aria-label="Neue Liste erstellen">
|
||||
<i data-lucide="plus" style="width:18px;height:18px"></i>
|
||||
</button>
|
||||
`;
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderListContent(container) {
|
||||
const content = container.querySelector('#list-content');
|
||||
if (!content) return;
|
||||
|
||||
if (!state.activeList) {
|
||||
content.innerHTML = `
|
||||
<div class="no-lists">
|
||||
<i data-lucide="shopping-cart" style="width:56px;height:56px;color:var(--color-text-disabled)"></i>
|
||||
<div style="font-size:var(--text-lg);font-weight:var(--font-weight-semibold)">Keine Listen</div>
|
||||
<div style="font-size:var(--text-sm);color:var(--color-text-secondary)">
|
||||
Erstelle eine Liste mit dem + Button.
|
||||
</div>
|
||||
</div>`;
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
const checkedCount = state.items.filter((i) => i.is_checked).length;
|
||||
|
||||
content.innerHTML = `
|
||||
<!-- Liste-Header -->
|
||||
<div class="list-header">
|
||||
<span class="list-header__name" data-action="rename-list" data-id="${state.activeList.id}"
|
||||
role="button" tabindex="0" aria-label="Liste umbenennen">
|
||||
${state.activeList.name}
|
||||
<i data-lucide="pencil" class="list-header__edit-icon"></i>
|
||||
</span>
|
||||
<div class="list-header__actions">
|
||||
${checkedCount > 0 ? `
|
||||
<button class="btn btn--ghost" data-action="clear-checked"
|
||||
style="font-size:var(--text-sm);color:var(--color-text-secondary)">
|
||||
<i data-lucide="trash-2" style="width:15px;height:15px"></i>
|
||||
Abgehakt löschen (${checkedCount})
|
||||
</button>` : ''}
|
||||
<button class="btn btn--ghost btn--icon" data-action="delete-list"
|
||||
data-id="${state.activeList.id}" aria-label="Liste löschen"
|
||||
style="color:var(--color-text-secondary)">
|
||||
<i data-lucide="trash" style="width:18px;height:18px"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick-Add -->
|
||||
<div class="quick-add">
|
||||
<form class="quick-add__form" id="quick-add-form" novalidate autocomplete="off">
|
||||
<div class="quick-add__input-wrap">
|
||||
<input class="quick-add__input" type="text" id="item-name-input"
|
||||
placeholder="Artikel hinzufügen…" aria-label="Artikelname" autocomplete="off">
|
||||
<input class="quick-add__qty" type="text" id="item-qty-input"
|
||||
placeholder="Menge" aria-label="Menge" autocomplete="off">
|
||||
<div class="autocomplete-dropdown" id="autocomplete-dropdown" hidden></div>
|
||||
</div>
|
||||
<select class="quick-add__cat" id="item-cat-select" aria-label="Kategorie">
|
||||
${ITEM_CATEGORIES.map((c) =>
|
||||
`<option value="${c}">${c}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
<button class="quick-add__btn" type="submit" aria-label="Artikel hinzufügen">
|
||||
<i data-lucide="plus" style="width:20px;height:20px"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Artikel-Liste -->
|
||||
<div class="items-list" id="items-list">
|
||||
${renderItems()}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
wireAutocomplete(container);
|
||||
wireQuickAdd(container);
|
||||
}
|
||||
|
||||
function renderItems() {
|
||||
if (!state.items.length) {
|
||||
return `
|
||||
<div class="shopping-empty">
|
||||
<i data-lucide="check-circle" class="shopping-empty__icon"></i>
|
||||
<div class="shopping-empty__title">Liste ist leer</div>
|
||||
<div class="shopping-empty__desc">Füge Artikel mit dem Eingabefeld oben hinzu.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const groups = groupItemsByCategory(state.items);
|
||||
return groups.map(([cat, items]) => `
|
||||
<div class="item-category">
|
||||
<div class="item-category__header">
|
||||
<i data-lucide="${CATEGORY_ICONS[cat] ?? 'tag'}" class="item-category__icon"></i>
|
||||
${cat}
|
||||
</div>
|
||||
${items.map(renderItem).join('')}
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderItem(item) {
|
||||
return `
|
||||
<div class="shopping-item ${item.is_checked ? 'shopping-item--checked' : ''}"
|
||||
data-item-id="${item.id}">
|
||||
<button class="item-check ${item.is_checked ? 'item-check--checked' : ''}"
|
||||
data-action="toggle-item" data-id="${item.id}" data-checked="${item.is_checked}"
|
||||
aria-label="${item.name} ${item.is_checked ? 'als nicht erledigt markieren' : 'abhaken'}">
|
||||
<i data-lucide="check" class="item-check__icon"></i>
|
||||
</button>
|
||||
<div class="item-body">
|
||||
<div class="item-name">${item.name}</div>
|
||||
${item.quantity ? `<div class="item-quantity">${item.quantity}</div>` : ''}
|
||||
</div>
|
||||
<button class="item-delete" data-action="delete-item" data-id="${item.id}"
|
||||
aria-label="${item.name} löschen">
|
||||
<i data-lucide="x" style="width:16px;height:16px"></i>
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 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) =>
|
||||
`<div class="autocomplete-item" data-idx="${i}" data-value="${s}">${s}</div>`
|
||||
).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', `
|
||||
<button class="btn btn--ghost" data-action="clear-checked"
|
||||
style="font-size:var(--text-sm);color:var(--color-text-secondary)">
|
||||
<i data-lucide="trash-2" style="width:15px;height:15px"></i>
|
||||
Abgehakt löschen (${checkedCount})
|
||||
</button>`);
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
} else if (clearBtn) {
|
||||
if (checkedCount === 0) {
|
||||
clearBtn.remove();
|
||||
} else {
|
||||
clearBtn.innerHTML = `
|
||||
<i data-lucide="trash-2" style="width:15px;height:15px"></i>
|
||||
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 = `
|
||||
<div class="page">
|
||||
<div class="page__header">
|
||||
<h1 class="page__title">Shopping</h1>
|
||||
<div class="shopping-page">
|
||||
<div class="list-tabs-bar" id="list-tabs-bar">
|
||||
<div class="skeleton skeleton-line skeleton-line--medium" style="height:36px;width:120px;border-radius:var(--radius-full)"></div>
|
||||
<div class="skeleton skeleton-line skeleton-line--short" style="height:36px;width:80px; border-radius:var(--radius-full)"></div>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__title">Kommt bald.</div>
|
||||
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
|
||||
<div id="list-content" style="flex:1;display:flex;flex-direction:column">
|
||||
<div style="padding:var(--space-6)">
|
||||
${[1,2,3].map(() => `
|
||||
<div class="skeleton skeleton-line skeleton-line--full" style="height:48px;margin-bottom:var(--space-2);border-radius:var(--radius-sm)"></div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="shopping-page">
|
||||
<div class="list-tabs-bar" id="list-tabs-bar"></div>
|
||||
<div id="list-content" style="flex:1;display:flex;flex-direction:column;overflow:hidden"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
renderTabs(container);
|
||||
wireTabBar(container);
|
||||
renderListContent(container);
|
||||
wireListContentEvents(container);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Modul: Einkaufslisten (Shopping)
|
||||
* Zweck: Styles für Listen-Tabs, Artikel-Liste, Kategorie-Gruppen, Quick-Add, Autocomplete
|
||||
* Abhängigkeiten: tokens.css, layout.css
|
||||
*/
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Seiten-Layout
|
||||
* -------------------------------------------------------- */
|
||||
.shopping-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100dvh - var(--nav-height-mobile) - var(--safe-area-inset-bottom));
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.shopping-page {
|
||||
height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Listen-Tabs (horizontal scroll)
|
||||
* -------------------------------------------------------- */
|
||||
.list-tabs-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background-color: var(--color-surface);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list-tabs-bar::-webkit-scrollbar { display: none; }
|
||||
|
||||
.list-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
border: 1.5px solid var(--color-border);
|
||||
background-color: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.list-tab--active {
|
||||
background-color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.list-tab__count {
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 1px 6px;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.list-tab--active .list-tab__count {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.list-tab__new {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1.5px dashed var(--color-border);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all var(--transition-fast);
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.list-tab__new:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Liste-Header (Name + Aktionen)
|
||||
* -------------------------------------------------------- */
|
||||
.list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4) var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list-header__name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-weight-bold);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.list-header__edit-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
.list-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Quick-Add Eingabe
|
||||
* -------------------------------------------------------- */
|
||||
.quick-add {
|
||||
padding: var(--space-2) var(--space-4) var(--space-3);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.quick-add__form {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.quick-add__input-wrap {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.quick-add__input {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
padding-right: 80px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1.5px solid var(--color-border);
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-base);
|
||||
transition: border-color var(--transition-fast);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.quick-add__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.quick-add__qty {
|
||||
position: absolute;
|
||||
right: var(--space-2);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 70px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-xs);
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-surface-2);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
text-align: center;
|
||||
min-height: unset;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.quick-add__qty:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.quick-add__cat {
|
||||
width: 130px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1.5px solid var(--color-border);
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.quick-add__cat:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.quick-add__btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: var(--color-accent);
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color var(--transition-fast);
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.quick-add__btn:hover { background-color: var(--color-accent-hover); }
|
||||
|
||||
/* Autocomplete-Dropdown */
|
||||
.autocomplete-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: var(--z-modal);
|
||||
overflow: hidden;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.autocomplete-item {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-base);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.autocomplete-item:hover,
|
||||
.autocomplete-item--active {
|
||||
background-color: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Artikel-Liste (scrollbar)
|
||||
* -------------------------------------------------------- */
|
||||
.items-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 var(--space-4) var(--space-4);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Kategorie-Gruppen
|
||||
* -------------------------------------------------------- */
|
||||
.item-category {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.item-category__header {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: var(--space-2) 0 var(--space-1);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: var(--space-1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.item-category__icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Einkaufsartikel
|
||||
* -------------------------------------------------------- */
|
||||
.shopping-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-1);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
transition: opacity var(--transition-fast);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.shopping-item:last-child { border-bottom: none; }
|
||||
|
||||
.shopping-item--checked {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
.item-check {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 2px solid var(--color-border);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.item-check--checked {
|
||||
background-color: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.item-check__icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #ffffff;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item-check--checked .item-check__icon { display: block; }
|
||||
|
||||
/* Artikel-Text */
|
||||
.item-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.shopping-item--checked .item-name {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.item-quantity {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Löschen-Button */
|
||||
.item-delete {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-disabled);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast), color var(--transition-fast);
|
||||
min-height: unset;
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shopping-item:hover .item-delete,
|
||||
.shopping-item:focus-within .item-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.item-delete:hover { color: var(--color-danger); }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Leer-Zustand
|
||||
* -------------------------------------------------------- */
|
||||
.shopping-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-12) var(--space-6);
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shopping-empty__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
color: var(--color-text-disabled);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.shopping-empty__title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.shopping-empty__desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* No-Lists-Zustand
|
||||
* -------------------------------------------------------- */
|
||||
.no-lists {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-12);
|
||||
text-align: center;
|
||||
}
|
||||
+280
-6
@@ -1,13 +1,287 @@
|
||||
/**
|
||||
* Modul: Einkaufslisten (Shopping)
|
||||
* Zweck: REST-API-Routen für Einkaufslisten und -artikel
|
||||
* Abhängigkeiten: express, server/db.js, server/auth.js
|
||||
* Zweck: REST-API-Routen für Einkaufslisten, Artikel, Autocomplete
|
||||
* Abhängigkeiten: express, server/db.js
|
||||
*
|
||||
* Routen-Reihenfolge: Statische Pfade (/suggestions, /items/:id) müssen
|
||||
* vor dynamischen (/:listId) registriert sein, damit Express korrekt matcht.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
'use strict';
|
||||
|
||||
// Platzhalter — wird in Phase 2 implementiert
|
||||
router.get('/', (req, res) => res.json({ data: [] }));
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db');
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Konstanten
|
||||
// --------------------------------------------------------
|
||||
|
||||
const ITEM_CATEGORIES = [
|
||||
'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch',
|
||||
'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges',
|
||||
];
|
||||
|
||||
// --------------------------------------------------------
|
||||
// GET /api/v1/shopping/suggestions?q=…
|
||||
// Autocomplete-Vorschläge aus bisherigen Artikelnamen.
|
||||
// Response: { data: string[] }
|
||||
// --------------------------------------------------------
|
||||
router.get('/suggestions', (req, res) => {
|
||||
try {
|
||||
const q = (req.query.q ?? '').trim();
|
||||
if (q.length < 1) return res.json({ data: [] });
|
||||
|
||||
const rows = db.get().prepare(`
|
||||
SELECT DISTINCT name FROM shopping_items
|
||||
WHERE name LIKE ? COLLATE NOCASE
|
||||
ORDER BY name ASC
|
||||
LIMIT 8
|
||||
`).all(`${q}%`);
|
||||
|
||||
res.json({ data: rows.map((r) => r.name) });
|
||||
} catch (err) {
|
||||
console.error('[Shopping] suggestions Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// PATCH /api/v1/shopping/items/:itemId
|
||||
// Artikel aktualisieren (is_checked, name, quantity, category).
|
||||
// Body: { is_checked?, name?, quantity?, category? }
|
||||
// Response: { data: ShoppingItem }
|
||||
// --------------------------------------------------------
|
||||
router.patch('/items/:itemId', (req, res) => {
|
||||
try {
|
||||
const item = db.get()
|
||||
.prepare('SELECT * FROM shopping_items WHERE id = ?')
|
||||
.get(req.params.itemId);
|
||||
if (!item) return res.status(404).json({ error: 'Artikel nicht gefunden.', code: 404 });
|
||||
|
||||
const {
|
||||
is_checked = item.is_checked,
|
||||
name = item.name,
|
||||
quantity = item.quantity,
|
||||
category = item.category,
|
||||
} = req.body;
|
||||
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'name darf nicht leer sein.', code: 400 });
|
||||
if (category && !ITEM_CATEGORIES.includes(category))
|
||||
return res.status(400).json({ error: 'Ungültige Kategorie.', code: 400 });
|
||||
|
||||
db.get().prepare(`
|
||||
UPDATE shopping_items
|
||||
SET is_checked = ?, name = ?, quantity = ?, category = ?
|
||||
WHERE id = ?
|
||||
`).run(is_checked ? 1 : 0, name.trim(), quantity ?? null, category, req.params.itemId);
|
||||
|
||||
const updated = db.get()
|
||||
.prepare('SELECT * FROM shopping_items WHERE id = ?')
|
||||
.get(req.params.itemId);
|
||||
res.json({ data: updated });
|
||||
} catch (err) {
|
||||
console.error('[Shopping] PATCH items/:id Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// DELETE /api/v1/shopping/items/:itemId
|
||||
// Einzelnen Artikel löschen.
|
||||
// Response: { ok: true }
|
||||
// --------------------------------------------------------
|
||||
router.delete('/items/:itemId', (req, res) => {
|
||||
try {
|
||||
const result = db.get()
|
||||
.prepare('DELETE FROM shopping_items WHERE id = ?')
|
||||
.run(req.params.itemId);
|
||||
if (result.changes === 0)
|
||||
return res.status(404).json({ error: 'Artikel nicht gefunden.', code: 404 });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[Shopping] DELETE items/:id Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// GET /api/v1/shopping
|
||||
// Alle Einkaufslisten mit Artikel-Zähler.
|
||||
// Response: { data: ShoppingList[] }
|
||||
// --------------------------------------------------------
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const lists = db.get().prepare(`
|
||||
SELECT
|
||||
sl.*,
|
||||
COUNT(si.id) AS item_total,
|
||||
SUM(CASE WHEN si.is_checked = 1 THEN 1 ELSE 0 END) AS item_checked
|
||||
FROM shopping_lists sl
|
||||
LEFT JOIN shopping_items si ON si.list_id = sl.id
|
||||
GROUP BY sl.id
|
||||
ORDER BY sl.created_at ASC
|
||||
`).all();
|
||||
res.json({ data: lists });
|
||||
} catch (err) {
|
||||
console.error('[Shopping] GET / Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// POST /api/v1/shopping
|
||||
// Neue Einkaufsliste erstellen.
|
||||
// Body: { name }
|
||||
// Response: { data: ShoppingList }
|
||||
// --------------------------------------------------------
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const name = req.body.name?.trim();
|
||||
if (!name) return res.status(400).json({ error: 'name ist erforderlich.', code: 400 });
|
||||
|
||||
const result = db.get()
|
||||
.prepare('INSERT INTO shopping_lists (name, created_by) VALUES (?, ?)')
|
||||
.run(name, req.session.userId);
|
||||
|
||||
const list = db.get()
|
||||
.prepare('SELECT * FROM shopping_lists WHERE id = ?')
|
||||
.get(result.lastInsertRowid);
|
||||
res.status(201).json({ data: list });
|
||||
} catch (err) {
|
||||
console.error('[Shopping] POST / Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// PUT /api/v1/shopping/:listId
|
||||
// Einkaufsliste umbenennen.
|
||||
// Body: { name }
|
||||
// Response: { data: ShoppingList }
|
||||
// --------------------------------------------------------
|
||||
router.put('/:listId', (req, res) => {
|
||||
try {
|
||||
const name = req.body.name?.trim();
|
||||
if (!name) return res.status(400).json({ error: 'name ist erforderlich.', code: 400 });
|
||||
|
||||
const result = db.get()
|
||||
.prepare('UPDATE shopping_lists SET name = ? WHERE id = ?')
|
||||
.run(name, req.params.listId);
|
||||
if (result.changes === 0)
|
||||
return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 });
|
||||
|
||||
const list = db.get()
|
||||
.prepare('SELECT * FROM shopping_lists WHERE id = ?')
|
||||
.get(req.params.listId);
|
||||
res.json({ data: list });
|
||||
} catch (err) {
|
||||
console.error('[Shopping] PUT /:listId Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// DELETE /api/v1/shopping/:listId
|
||||
// Liste und alle Artikel löschen (CASCADE).
|
||||
// Response: { ok: true }
|
||||
// --------------------------------------------------------
|
||||
router.delete('/:listId', (req, res) => {
|
||||
try {
|
||||
const result = db.get()
|
||||
.prepare('DELETE FROM shopping_lists WHERE id = ?')
|
||||
.run(req.params.listId);
|
||||
if (result.changes === 0)
|
||||
return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[Shopping] DELETE /:listId Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 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 }
|
||||
// --------------------------------------------------------
|
||||
router.get('/:listId/items', (req, res) => {
|
||||
try {
|
||||
const list = db.get()
|
||||
.prepare('SELECT * FROM shopping_lists WHERE id = ?')
|
||||
.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 items = db.get().prepare(`
|
||||
SELECT * FROM shopping_items
|
||||
WHERE list_id = ?
|
||||
ORDER BY
|
||||
CASE category ${categoryOrder} ELSE ${ITEM_CATEGORIES.length} END,
|
||||
is_checked ASC,
|
||||
created_at ASC
|
||||
`).all(req.params.listId);
|
||||
|
||||
res.json({ data: items, list });
|
||||
} catch (err) {
|
||||
console.error('[Shopping] GET /:listId/items Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// POST /api/v1/shopping/:listId/items
|
||||
// Artikel zur Liste hinzufügen.
|
||||
// Body: { name, quantity?, category? }
|
||||
// Response: { data: ShoppingItem }
|
||||
// --------------------------------------------------------
|
||||
router.post('/:listId/items', (req, res) => {
|
||||
try {
|
||||
const list = db.get()
|
||||
.prepare('SELECT id FROM shopping_lists WHERE id = ?')
|
||||
.get(req.params.listId);
|
||||
if (!list) return res.status(404).json({ error: 'Liste nicht gefunden.', code: 404 });
|
||||
|
||||
const name = req.body.name?.trim();
|
||||
const quantity = req.body.quantity?.trim() || null;
|
||||
const category = req.body.category || 'Sonstiges';
|
||||
|
||||
if (!name) return res.status(400).json({ error: 'name ist erforderlich.', code: 400 });
|
||||
if (!ITEM_CATEGORIES.includes(category))
|
||||
return res.status(400).json({ error: 'Ungültige Kategorie.', code: 400 });
|
||||
|
||||
const result = db.get().prepare(`
|
||||
INSERT INTO shopping_items (list_id, name, quantity, category)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(req.params.listId, name, quantity, category);
|
||||
|
||||
const item = db.get()
|
||||
.prepare('SELECT * FROM shopping_items WHERE id = ?')
|
||||
.get(result.lastInsertRowid);
|
||||
res.status(201).json({ data: item });
|
||||
} catch (err) {
|
||||
console.error('[Shopping] POST /:listId/items Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// DELETE /api/v1/shopping/:listId/items/checked
|
||||
// Alle abgehakten Artikel aus einer Liste löschen.
|
||||
// Response: { deleted: number }
|
||||
// --------------------------------------------------------
|
||||
router.delete('/:listId/items/checked', (req, res) => {
|
||||
try {
|
||||
const result = db.get().prepare(`
|
||||
DELETE FROM shopping_items WHERE list_id = ? AND is_checked = 1
|
||||
`).run(req.params.listId);
|
||||
res.json({ deleted: result.changes });
|
||||
} catch (err) {
|
||||
console.error('[Shopping] DELETE /:listId/items/checked Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Modul: Einkaufslisten-Test
|
||||
* Zweck: Validiert alle Shopping-API-Abfragen, Sortierung, Constraints
|
||||
* Ausführen: node --experimental-sqlite test-shopping.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { MIGRATIONS_SQL } = require('./server/db-schema-test');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try { fn(); console.log(` ✓ ${name}`); passed++; }
|
||||
catch (err) { console.error(` ✗ ${name}: ${err.message}`); failed++; }
|
||||
}
|
||||
function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion fehlgeschlagen'); }
|
||||
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec('PRAGMA foreign_keys = ON;');
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY, description TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);`);
|
||||
db.exec(MIGRATIONS_SQL[1]);
|
||||
|
||||
const u1 = db.prepare(`INSERT INTO users (username, display_name, password_hash, role)
|
||||
VALUES ('admin', 'Admin', 'x', 'admin')`).run();
|
||||
const uid = u1.lastInsertRowid;
|
||||
|
||||
console.log('\n[Shopping-Test] Listen, Artikel, Sortierung\n');
|
||||
|
||||
let listId, list2Id, itemId1, itemId2, itemId3;
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Listen-CRUD
|
||||
// --------------------------------------------------------
|
||||
test('Liste erstellen', () => {
|
||||
const r = db.prepare(`INSERT INTO shopping_lists (name, created_by) VALUES ('REWE', ?)`).run(uid);
|
||||
listId = r.lastInsertRowid;
|
||||
assert(listId > 0);
|
||||
});
|
||||
|
||||
test('Zweite Liste erstellen', () => {
|
||||
const r = db.prepare(`INSERT INTO shopping_lists (name, created_by) VALUES ('dm', ?)`).run(uid);
|
||||
list2Id = r.lastInsertRowid;
|
||||
assert(list2Id > 0);
|
||||
});
|
||||
|
||||
test('Alle Listen mit Zähler abrufbar', () => {
|
||||
const lists = db.prepare(`
|
||||
SELECT sl.*,
|
||||
COUNT(si.id) AS item_total,
|
||||
SUM(CASE WHEN si.is_checked = 1 THEN 1 ELSE 0 END) AS item_checked
|
||||
FROM shopping_lists sl
|
||||
LEFT JOIN shopping_items si ON si.list_id = sl.id
|
||||
GROUP BY sl.id ORDER BY sl.created_at ASC
|
||||
`).all();
|
||||
assert(lists.length === 2, `Erwartet 2, erhalten ${lists.length}`);
|
||||
assert(lists[0].name === 'REWE');
|
||||
assert(lists[0].item_total === 0, 'Noch keine Artikel');
|
||||
});
|
||||
|
||||
test('Liste umbenennen', () => {
|
||||
db.prepare(`UPDATE shopping_lists SET name = 'REWE Wocheneinkauf' WHERE id = ?`).run(listId);
|
||||
const l = db.prepare('SELECT name FROM shopping_lists WHERE id = ?').get(listId);
|
||||
assert(l.name === 'REWE Wocheneinkauf', 'Name aktualisiert');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Artikel-CRUD
|
||||
// --------------------------------------------------------
|
||||
test('Artikel hinzufügen — Obst & Gemüse', () => {
|
||||
const r = db.prepare(`INSERT INTO shopping_items (list_id, name, quantity, category)
|
||||
VALUES (?, 'Äpfel', '1 kg', 'Obst & Gemüse')`).run(listId);
|
||||
itemId1 = r.lastInsertRowid;
|
||||
assert(itemId1 > 0);
|
||||
});
|
||||
|
||||
test('Artikel hinzufügen — Milchprodukte', () => {
|
||||
const r = db.prepare(`INSERT INTO shopping_items (list_id, name, quantity, category)
|
||||
VALUES (?, 'Milch', '1 Liter', 'Milchprodukte')`).run(listId);
|
||||
itemId2 = r.lastInsertRowid;
|
||||
assert(itemId2 > 0);
|
||||
});
|
||||
|
||||
test('Artikel hinzufügen — Backwaren', () => {
|
||||
const r = db.prepare(`INSERT INTO shopping_items (list_id, name, category)
|
||||
VALUES (?, 'Brot', 'Backwaren')`).run(listId);
|
||||
itemId3 = r.lastInsertRowid;
|
||||
assert(itemId3 > 0);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Supermarkt-Gang-Sortierung
|
||||
// --------------------------------------------------------
|
||||
test('Sortierung nach Supermarkt-Gang-Logik', () => {
|
||||
const categories = [
|
||||
'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch',
|
||||
'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges',
|
||||
];
|
||||
const caseExpr = categories.map((c, i) => `WHEN '${c}' THEN ${i}`).join(' ');
|
||||
|
||||
const items = db.prepare(`
|
||||
SELECT * FROM shopping_items
|
||||
WHERE list_id = ?
|
||||
ORDER BY CASE category ${caseExpr} ELSE 9 END, is_checked ASC, created_at ASC
|
||||
`).all(listId);
|
||||
|
||||
assert(items.length === 3, `Erwartet 3, erhalten ${items.length}`);
|
||||
assert(items[0].category === 'Obst & Gemüse', `Erste Kategorie: ${items[0].category}`);
|
||||
assert(items[1].category === 'Backwaren', `Zweite Kategorie: ${items[1].category}`);
|
||||
assert(items[2].category === 'Milchprodukte', `Dritte Kategorie: ${items[2].category}`);
|
||||
});
|
||||
|
||||
test('Abgehakte Artikel ans Ende innerhalb der Kategorie', () => {
|
||||
// Zweiten Artikel in Obst einfügen
|
||||
db.prepare(`INSERT INTO shopping_items (list_id, name, category, is_checked)
|
||||
VALUES (?, 'Bananen', 'Obst & Gemüse', 1)`).run(listId);
|
||||
|
||||
const categories = [
|
||||
'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch',
|
||||
'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges',
|
||||
];
|
||||
const caseExpr = categories.map((c, i) => `WHEN '${c}' THEN ${i}`).join(' ');
|
||||
|
||||
const items = db.prepare(`
|
||||
SELECT * FROM shopping_items WHERE list_id = ?
|
||||
ORDER BY CASE category ${caseExpr} ELSE 9 END, is_checked ASC, created_at ASC
|
||||
`).all(listId);
|
||||
|
||||
const obst = items.filter((i) => i.category === 'Obst & Gemüse');
|
||||
assert(obst[0].name === 'Äpfel', 'Nicht abgehakt zuerst');
|
||||
assert(obst[1].name === 'Bananen', 'Abgehakt danach');
|
||||
assert(obst[1].is_checked === 1, 'Bananen ist abgehakt');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Artikel abhaken
|
||||
// --------------------------------------------------------
|
||||
test('Artikel abhaken (toggle)', () => {
|
||||
db.prepare(`UPDATE shopping_items SET is_checked = 1 WHERE id = ?`).run(itemId1);
|
||||
const item = db.prepare('SELECT is_checked FROM shopping_items WHERE id = ?').get(itemId1);
|
||||
assert(item.is_checked === 1, 'Artikel abgehakt');
|
||||
});
|
||||
|
||||
test('Artikel wieder aktivieren', () => {
|
||||
db.prepare(`UPDATE shopping_items SET is_checked = 0 WHERE id = ?`).run(itemId1);
|
||||
const item = db.prepare('SELECT is_checked FROM shopping_items WHERE id = ?').get(itemId1);
|
||||
assert(item.is_checked === 0, 'Artikel wieder aktiv');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Abgehakte löschen
|
||||
// --------------------------------------------------------
|
||||
test('"Abgehakte löschen" entfernt nur is_checked=1', () => {
|
||||
db.prepare(`UPDATE shopping_items SET is_checked = 1 WHERE id IN (?, ?)`).run(itemId1, itemId2);
|
||||
|
||||
// Äpfel (itemId1) + Milch (itemId2) + Bananen (bereits checked aus vorherigem Test) = 3
|
||||
const result = db.prepare(`DELETE FROM shopping_items WHERE list_id = ? AND is_checked = 1`).run(listId);
|
||||
assert(result.changes === 3, `Gelöscht: ${result.changes}, erwartet: 3`);
|
||||
|
||||
const remaining = db.prepare(`SELECT * FROM shopping_items WHERE list_id = ?`).all(listId);
|
||||
assert(remaining.every((i) => i.is_checked === 0), 'Nur nicht-abgehakte verbleiben');
|
||||
assert(remaining.length === 1, `Verbleibend: ${remaining.length} (nur Brot)`);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Autocomplete
|
||||
// --------------------------------------------------------
|
||||
test('Autocomplete-Suggestions nach Prefix', () => {
|
||||
db.prepare(`INSERT INTO shopping_items (list_id, name, category) VALUES (?, 'Joghurt', 'Milchprodukte')`).run(listId);
|
||||
db.prepare(`INSERT INTO shopping_items (list_id, name, category) VALUES (?, 'Käse', 'Milchprodukte')`).run(listId);
|
||||
|
||||
const results = db.prepare(`
|
||||
SELECT DISTINCT name FROM shopping_items
|
||||
WHERE name LIKE ? COLLATE NOCASE
|
||||
ORDER BY name ASC LIMIT 8
|
||||
`).all('J%');
|
||||
|
||||
assert(results.length >= 1, 'Mindestens 1 Vorschlag');
|
||||
assert(results[0].name === 'Joghurt', `Erwartet Joghurt, erhalten: ${results[0].name}`);
|
||||
});
|
||||
|
||||
test('Autocomplete — kein Match gibt leeres Array', () => {
|
||||
const results = db.prepare(`
|
||||
SELECT DISTINCT name FROM shopping_items WHERE name LIKE ? COLLATE NOCASE
|
||||
`).all('XXXXXXXX%');
|
||||
assert(results.length === 0, 'Kein Match erwartet');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Zähler-Abfrage
|
||||
// --------------------------------------------------------
|
||||
test('Listen-Zähler korrekt nach Änderungen', () => {
|
||||
const list = db.prepare(`
|
||||
SELECT sl.*,
|
||||
COUNT(si.id) AS item_total,
|
||||
SUM(CASE WHEN si.is_checked = 1 THEN 1 ELSE 0 END) AS item_checked
|
||||
FROM shopping_lists sl
|
||||
LEFT JOIN shopping_items si ON si.list_id = sl.id
|
||||
WHERE sl.id = ?
|
||||
GROUP BY sl.id
|
||||
`).get(listId);
|
||||
assert(list.item_total > 0, `item_total=${list.item_total}`);
|
||||
assert(list.item_checked === 0, 'Keine abgehakten mehr');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Cascade-Löschung
|
||||
// --------------------------------------------------------
|
||||
test('Liste löschen entfernt alle Artikel (CASCADE)', () => {
|
||||
db.prepare('DELETE FROM shopping_lists WHERE id = ?').run(list2Id);
|
||||
const items = db.prepare('SELECT * FROM shopping_items WHERE list_id = ?').all(list2Id);
|
||||
assert(items.length === 0, 'Keine Artikel nach Listen-Löschung');
|
||||
});
|
||||
|
||||
test('Nicht existierende Liste gibt keine Zeile', () => {
|
||||
const list = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(99999);
|
||||
assert(!list, 'Sollte undefined sein');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Ergebnis
|
||||
// --------------------------------------------------------
|
||||
console.log(`\n[Shopping-Test] Ergebnis: ${passed} bestanden, ${failed} fehlgeschlagen\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
Reference in New Issue
Block a user