// Eine geöffnete Liste: gruppiert nach Markt und Warengruppe, Artikel // alphabetisch. Arbeitet gegen den lokalen Zwischenspeicher, nicht gegen // den Server - dadurch funktioniert die Ansicht auch ohne Verbindung. // // Jede Änderung wandert zuerst in die Outbox und wird sofort angezeigt. // Der Versand läuft danach; scheitert er, bleibt die Operation liegen und // wird beim nächsten Anlauf erneut versucht. "use strict"; // get/ApiError nur für die Strichcode-Auflösung: Sie braucht eine // Verbindung, weil die Zuordnung Code -> Artikel serverseitig steht. import { ApiError, get } from "../api.js"; import * as db from "../db.js"; import { clear, el, euro, formatPack, formatQuantity, mount, parseCents, parseCount, parseQuantity, } from "../dom.js"; import { set, state } from "../store.js"; import { composeView, flush, pull, syncNow, watch } from "../sync.js"; import { confirmScan } from "./scan-result.js"; import { cameraAvailable, scanBarcode } from "./scanner.js"; import { appBar } from "../appbar.js"; let openMenuId = null; export async function listDetailView( root, { listId, back, manage, share, articles, prices } ) { let view = null; let meta = null; let markets = []; let categories = []; let banner = null; /** article_id -> { best_market_ids, best_cents, spread_cents, prices } */ let priceHints = new Map(); // Zuletzt im Formular gewählter Markt - überlebt den Ansichtswechsel. let lastMarket = null; // ------------------------------------------------------------------- // Laden // ------------------------------------------------------------------- /** Preishinweise sind eine Nebeninformation: Kommen sie nicht, arbeitet * die Liste ohne sie weiter. Deshalb hier kein throw, sondern der * Rückgriff auf den zuletzt bekannten Stand. */ async function loadHints() { if (!state.online) { const cached = await db.cacheGet(`hints:${listId}`).catch(() => null); if (cached) priceHints = new Map(cached.map((r) => [r.article_id, r])); return; } try { const rows = await get(`/api/lists/${listId}/price-hints`); priceHints = new Map(rows.map((r) => [r.article_id, r])); await db.cacheSet(`hints:${listId}`, rows); } catch { const cached = await db.cacheGet(`hints:${listId}`).catch(() => null); if (cached) priceHints = new Map(cached.map((r) => [r.article_id, r])); } } async function readLocal() { view = await composeView(listId); // cacheGet liefert den gespeicherten Wert unmittelbar; ein noch // nie gesetzter Schlüssel ergibt undefined. lastMarket = await db.cacheGet(`lastmarket:${listId}`).catch(() => null) || null; markets = (await db.cacheGet(`markets:${listId}`)) || []; categories = (await db.cacheGet(`categories:${listId}`)) || []; meta = await db.cacheGet(`meta:${listId}`); } async function refresh({ fromServer = false } = {}) { if (fromServer) { try { await pull(listId); set({ syncState: "ok" }); } catch { set({ syncState: navigator.onLine ? "error" : "offline" }); } } await readLocal(); render(); } function say(message) { banner = message; render(); setTimeout(() => { banner = null; render(); }, 6000); } /** Änderung anmelden: in die Outbox, sofort anzeigen, dann senden. */ async function change(kind, payload) { await db.enqueue(listId, kind, payload); await readLocal(); render(); const sent = await flush(listId); if (sent) { await pull(listId).catch(() => {}); await readLocal(); } if (state.syncProblem) { const problem = state.syncProblem; set({ syncProblem: null }); say(problem); } else { render(); } } // ------------------------------------------------------------------- // Bausteine // ------------------------------------------------------------------- function itemRow(item) { const bought = item.status === "bought"; const deferred = item.status === "deferred"; // Ein neu angelegter, noch nicht bestätigter Eintrag hat serverseitig // keine ID - Folgeänderungen daran ließen sich nicht zuordnen. const provisional = Boolean(item.pending && item.row_rev === 0); const check = el("button.check", { type: "button", "aria-pressed": bought ? "true" : "false", title: bought ? "Als offen markieren" : "Als gekauft markieren", onclick: () => change("item.update", { item_id: item.id, status: bought ? "open" : "bought", }), disabled: provisional, }, bought ? "✓" : ""); const priceField = el("input.price", { type: "text", inputMode: "decimal", placeholder: "€", value: item.price_cents ? (item.price_cents / 100).toFixed(2).replace(".", ",") : "", title: provisional ? "Erst nach der Übertragung möglich" : "Preis erfassen", disabled: provisional, onchange: (ev) => { const cents = parseCents(ev.target.value); if (ev.target.value.trim() && cents === null) { ev.target.value = ""; say("Preis konnte nicht gelesen werden."); return; } change("item.update", { item_id: item.id, price_cents: cents }); }, }); const menuButton = el("button.menu-toggle", { type: "button", title: "Weitere Aktionen", disabled: provisional, onclick: () => { openMenuId = openMenuId === item.id ? null : item.id; render(); }, }, "⋯"); const pack = formatPack(item.pack_size, item.pack_unit); const shared = meta && meta.member_count > 1; const by = shared && item.created_by_name ? `von ${item.created_by_name}` : ""; const sub = [pack, item.variant, item.note, by].filter(Boolean).join(" · "); return el("li", { className: [ "item", bought ? "bought" : "", deferred ? "deferred" : "", item.pending ? "pending" : "", ].filter(Boolean).join(" "), }, el("div.item-main", {}, check, // Stückzahl direkt am Artikel: Beim Einkaufen ist "wie viele" // die wichtigste Angabe und darf nicht im Untertitel untergehen. el("span", { className: `item-count${item.count > 1 ? " many" : ""}`, title: item.count === 1 ? "1 Stück" : `${item.count} Stück`, }, `${item.count || 1}\u00d7`), el("div.item-text", {}, el("span.name", {}, item.article_name), sub ? el("span.sub", {}, sub) : null), el("div.price-cell", {}, priceField, // Bei mehreren Stück wird sichtbar, dass der Preis je Gebinde // gilt und was zusammen daraus wird. item.price_cents && item.count > 1 ? el("span.line-total", {}, `= ${euro(item.total_cents)}`) : null), menuButton), openMenuId === item.id && !provisional ? itemMenu(item) : null ); } function itemMenu(item) { const marketSelect = el("select", { onchange: (ev) => { const value = ev.target.value; openMenuId = null; change("item.update", value ? { item_id: item.id, market_id: value } : { item_id: item.id, clear_market: true }); }, }, el("option", { value: "" }, "— ohne Markt —"), markets.map((m) => el("option", { value: m.id, selected: m.id === item.market_id }, m.name)) ); const categorySelect = el("select", { onchange: (ev) => { const value = ev.target.value; openMenuId = null; change("item.update", value ? { item_id: item.id, category_id: value } : { item_id: item.id, clear_category: true }); }, }, el("option", { value: "" }, "— ohne Warengruppe —"), categories.map((c) => el("option", { value: c.id, selected: c.id === item.category_id }, c.name)) ); const deferred = item.status === "deferred"; const nameEdit = el("input", { type: "text", value: item.article_name, maxLength: 200, onchange: (ev) => { const value = ev.target.value.trim(); if (!value) { // Leeres Feld ist kein Löschbefehl - der alte Name bleibt. ev.target.value = item.article_name; return; } if (value === item.article_name) return; change("item.update", { item_id: item.id, article_name: value }); }, }); const variantField = el("input", { type: "text", value: item.variant || "", maxLength: 200, placeholder: "z. B. bunt, ganz", onchange: (ev) => change("item.update", { item_id: item.id, variant: ev.target.value.trim() || null, }), }); const noteField = el("input", { type: "text", value: item.note || "", maxLength: 500, placeholder: "Bemerkung für den Einkauf", onchange: (ev) => change("item.update", { item_id: item.id, note: ev.target.value.trim() || null, }), }); const hint = priceHints.get(item.article_id); const hintBlock = hint ? (() => { const bestNames = hint.best_market_ids .map((id) => markets.find((m) => m.id === id)?.name) .filter(Boolean) .join(", "); const hereCents = item.market_id ? hint.prices[item.market_id] : null; if (!bestNames) return null; const alreadyBest = item.market_id && hint.best_market_ids.includes(item.market_id); return el("p.price-tip", {}, alreadyBest ? `Hier am günstigsten: ${euro(hint.best_cents)}.` : `Zuletzt günstigster Markt: ${bestNames} für ${euro(hint.best_cents)}`, !alreadyBest && hereCents ? ` – hier zuletzt ${euro(hereCents)}.` : !alreadyBest ? "." : ""); })() : null; const countEdit = el("input", { type: "text", inputMode: "numeric", value: String(item.count || 1), title: "Stückzahl", onchange: (ev) => change("item.update", { item_id: item.id, count: parseCount(ev.target.value), }), }); const packSizeEdit = el("input.qty", { type: "text", inputMode: "decimal", value: item.pack_size ? formatQuantity(item.pack_size) : "", placeholder: "Gebinde", onchange: (ev) => change("item.update", { item_id: item.id, pack_size: parseQuantity(ev.target.value), }), }); const packUnitEdit = el("input.unit", { type: "text", maxLength: 32, value: item.pack_unit || "", placeholder: "Einheit", onchange: (ev) => change("item.update", { item_id: item.id, pack_unit: ev.target.value.trim() || null, }), }); return el("div.item-menu", {}, hintBlock, el("label", {}, "Artikel"), nameEdit, el("p.sub", {}, "Steht der Artikel nur auf diesem Eintrag, wird er umbenannt – " + "ein Tippfehler verschwindet damit ganz. Wird er auch anderswo " + "verwendet, bekommt nur dieser Eintrag den neuen Artikel."), el("label", {}, "Anzahl und Gebinde", el("span.hint", {}, " (Preis gilt je Gebinde)")), el("div.add-fields", {}, countEdit, packSizeEdit, packUnitEdit), el("label", {}, "Eigenschaft"), variantField, el("label", {}, "Notiz"), noteField, el("label", {}, "Markt"), marketSelect, el("label", {}, "Warengruppe"), categorySelect, el("div.menu-actions", {}, el("button", { type: "button", onclick: () => { openMenuId = null; change("item.update", { item_id: item.id, status: deferred ? "open" : "deferred", }); }, }, deferred ? "Wieder aufnehmen" : "Zurückstellen"), el("button.danger", { type: "button", onclick: () => { openMenuId = null; change("item.delete", { item_id: item.id }); }, }, "Löschen")) ); } function marketSection(market) { return el("section.market", {}, el("h2", {}, el("span", {}, market.market_name), el("span.market-meta", {}, market.open_count === 1 ? "1 offen" : `${market.open_count} offen`, market.total_cents ? ` · ${euro(market.total_cents)}` : "")), market.categories.map((cat) => el("div.category", {}, el("h3", {}, cat.category_name), el("ul.items", {}, cat.items.map(itemRow)))) ); } function addRow() { const nameField = el("input", { id: "add-name", type: "text", maxLength: 200, placeholder: "Artikel hinzufügen", autocomplete: "off", // Der eingebaute Vervollständiger würde die eigene Vorschlagsliste // überdecken. autocapitalize: "sentences", onkeydown: (ev) => { if (ev.key === "Enter") { closeSuggestions(); addItem(); } if (ev.key === "Escape") closeSuggestions(); }, oninput: () => scheduleSuggestions(), }); // Anzahl und Gebinde getrennt: Der Preis gilt je Gebinde, die // Summe ist Preis mal Anzahl. Steckte beides in einem Feld, ergaben // 500 ml zu 2,99 EUR eine Summe von 1495 EUR. const countField = el("input.count", { type: "text", inputMode: "numeric", placeholder: "Anz.", value: "", title: "Stückzahl – wie viele Packungen", onkeydown: (ev) => { if (ev.key === "Enter") addItem(); }, }); const qtyField = el("input.qty", { type: "text", inputMode: "decimal", placeholder: "Gebinde", title: "Packungsgröße, z. B. 500", onkeydown: (ev) => { if (ev.key === "Enter") addItem(); }, }); const unitField = el("input.unit", { type: "text", maxLength: 32, placeholder: "Einheit", title: "z. B. ml, g, Stk", onkeydown: (ev) => { if (ev.key === "Enter") addItem(); }, }); const variantField = el("input.variant", { type: "text", maxLength: 200, placeholder: "Eigenschaft", title: "Nähere Bestimmung, z. B. „bunt, ganz“ oder „laktosefrei“", autocomplete: "off", onkeydown: (ev) => { if (ev.key === "Enter") addItem(); }, }); // Marktauswahl direkt im Formular: Ohne sie müsste jeder Eintrag // nachbearbeitet werden, und beim Füllen einer Liste sind das // schnell zwanzig Handgriffe. // // Die leere Auswahl heißt bewusst "Vorgabe des Artikels" und nicht // "ohne Markt": Der Server greift in diesem Fall auf die im // Artikelstamm hinterlegte Vorgabe zurück. const marketField = markets.length ? el("select.market-select", { title: "Markt für diesen Eintrag", onchange: () => { markSelection(); // Merken, damit die nächsten Artikel derselben Runde nicht // erneut ausgewählt werden müssen. db.cacheSet(`lastmarket:${listId}`, marketField.value || null) .catch(() => {}); }, }, el("option", { value: "" }, "— Vorgabe des Artikels —"), markets.map((m) => el("option", { value: m.id }, m.name))) : null; function markSelection() { if (!marketField) return; // Leeres Attribut = keine Vorgabe; das Stylesheet hebt eine // getroffene Auswahl hervor. if (marketField.value) marketField.removeAttribute("data-empty"); else marketField.setAttribute("data-empty", ""); } if (marketField && lastMarket && markets.some((m) => m.id === lastMarket)) { marketField.value = lastMarket; } markSelection(); // Die Warengruppe bleibt im Formular unsichtbar: Sie kommt aus der // Vorgabe des Artikels oder aus einem Vorschlag. Ein zweites // Auswahlfeld würde die Zeile überladen, ohne oft gebraucht zu // werden. let chosenCategory = null; const suggestionBox = el("ul.suggestions", { hidden: true }); function closeSuggestions() { suggestionBox.hidden = true; suggestionBox.replaceChildren(); } /** Übernimmt einen Vorschlag in die Felder - aber trägt ihn NICHT * ein. So bleibt Gelegenheit, Menge oder Eigenschaft zu ändern, * bevor der Artikel auf der Liste landet. */ function applySuggestion(suggestion) { nameField.value = suggestion.name; countField.value = suggestion.count && suggestion.count > 1 ? String(suggestion.count) : ""; qtyField.value = suggestion.pack_size ? formatQuantity(suggestion.pack_size) : ""; unitField.value = suggestion.pack_unit || ""; variantField.value = suggestion.variant || ""; // Zuletzt verwendeter Markt des Artikels - genau das, was man // beim Wiederholen eines Einkaufs erwartet. if (marketField && suggestion.market_id && markets.some((m) => m.id === suggestion.market_id)) { marketField.value = suggestion.market_id; markSelection(); } chosenCategory = suggestion.category_id || null; closeSuggestions(); // Zum Ändern einladen, nicht zum Abschicken drängen. qtyField.focus(); } let suggestTimer = null; let suggestToken = 0; function scheduleSuggestions() { // Getippte Zeichen sammeln, statt bei jedem Anschlag zu fragen. clearTimeout(suggestTimer); const value = nameField.value.trim(); // Eine frei getippte Eingabe hebt die aus einem Vorschlag // übernommene Warengruppe auf. Der Markt bleibt stehen: Er ist // jetzt sichtbar ausgewählt, und wer ihn nicht will, ändert ihn. chosenCategory = null; if (value.length < 3 || !state.online) { closeSuggestions(); return; } suggestTimer = setTimeout(() => loadSuggestions(value), 220); } async function loadSuggestions(value) { const token = ++suggestToken; let rows; try { rows = await get( `/api/lists/${listId}/articles/suggest?q=${encodeURIComponent(value)}`); } catch { // Vorschläge sind Beiwerk: Wenn sie nicht kommen, tippt man // eben weiter. return; } // Antwort einer älteren Anfrage verwerfen. if (token !== suggestToken) return; if (!rows.length) { closeSuggestions(); return; } suggestionBox.replaceChildren(...rows.map((row) => { const details = [ row.count && row.count > 1 ? `${row.count}×` : null, formatPack(row.pack_size, row.pack_unit) || null, row.variant, markets.find((m) => m.id === row.market_id)?.name, ].filter(Boolean); return el("li", {}, el("button", { type: "button", // mousedown statt click: Ein Klick käme erst nach dem // blur des Eingabefelds - und das schließt die Liste. onmousedown: (ev) => { ev.preventDefault(); applySuggestion(row); }, onclick: () => applySuggestion(row), }, el("span.name", {}, row.name), details.length ? el("span.sub", {}, details.join(" · ")) : null)); })); suggestionBox.hidden = false; } async function addItem() { const name = nameField.value.trim(); if (!name) return; const payload = { article_name: name, count: parseCount(countField.value), pack_size: parseQuantity(qtyField.value), pack_unit: unitField.value.trim() || null, variant: variantField.value.trim() || null, }; if (marketField && marketField.value) payload.market_id = marketField.value; if (chosenCategory) payload.category_id = chosenCategory; nameField.value = ""; countField.value = ""; qtyField.value = ""; unitField.value = ""; variantField.value = ""; chosenCategory = null; // Der Markt bleibt absichtlich stehen: Wer eine Liste füllt, // trägt meist mehrere Artikel für denselben Laden ein. closeSuggestions(); await change("item.create", payload); document.getElementById("add-name")?.focus(); } /** Strichcode scannen, auflösen, bestätigen, hinzufügen. * * Braucht Verbindung: Sowohl der eigene Artikelstamm als auch die * Produktdatenbank liegen auf dem Server. Offline bliebe eine * Ziffernfolge ohne Bedeutung - deshalb hier ehrlich abbrechen, * statt etwas Unbrauchbares in die Warteschlange zu legen. */ async function scanAndAdd() { const code = await scanBarcode(); if (!code) return; if (!state.online) { say("Zum Scannen wird eine Verbindung gebraucht – die Zuordnung " + "des Codes steht auf dem Server."); return; } let lookup; try { lookup = await get( `/api/lists/${listId}/barcode/${encodeURIComponent(code)}`); } catch (err) { say(err.message); return; } const extras = { count: parseCount(countField.value), pack_size: parseQuantity(qtyField.value), pack_unit: unitField.value.trim() || null, variant: variantField.value.trim() || null, }; if (marketField && marketField.value) extras.market_id = marketField.value; // Bereits im eigenen Bestand: ohne Rückfrage auf die Liste. if (lookup.source === "catalog") { await change("item.create", { article_id: lookup.article_id, barcode: code, ...extras, }); countField.value = ""; qtyField.value = ""; unitField.value = ""; variantField.value = ""; say(`„${lookup.name}“ hinzugefügt.`); return; } // Sonst bestätigen lassen - auch bei einem Treffer in der // Produktdatenbank. Fremde Angaben sollen nicht ungeprüft in den // eigenen Artikelstamm wandern. const confirmed = await confirmScan(lookup); if (!confirmed) return; await change("item.create", { article_name: confirmed.name, barcode: code, count: parseCount(confirmed.count), pack_size: parseQuantity(confirmed.packSize), pack_unit: confirmed.packUnit || null, variant: confirmed.variant || null, ...(marketField && marketField.value ? { market_id: marketField.value } : {}), }); } // Aufbau: Name über die volle Breite, darunter die Detailfelder, // darunter die Schaltflächen. Vorher standen Felder und // Schaltflächen in einer Zeile - auf schmalen Geräten lief der // letzte Knopf aus der Box. return el("div.add-row", {}, el("div.name-wrap", {}, nameField, suggestionBox), el("div.add-fields", {}, countField, qtyField, unitField, variantField), marketField ? el("div.add-market", {}, marketField) : null, el("div.add-actions", {}, cameraAvailable() ? el("button.secondary", { type: "button", title: "Strichcode scannen", onclick: scanAndAdd, }, "Scannen") : null, el("button.primary", { type: "button", onclick: addItem }, "Hinzufügen")) ); } function statusLine() { const pendingCount = view?.pending_ops || 0; if (!state.online) { return el("p.sync-hint.offline", {}, "Offline – Änderungen werden gespeichert und später übertragen", pendingCount ? ` (${pendingCount} wartend).` : "."); } if (pendingCount) { return el("p.sync-hint.pending", {}, pendingCount === 1 ? "1 Änderung wird übertragen …" : `${pendingCount} Änderungen werden übertragen …`); } if (state.syncState === "error") { return el("p.sync-hint.problem", {}, "Der Server ist gerade nicht erreichbar. ", el("button.linklike", { type: "button", onclick: () => refresh({ fromServer: true }), }, "Erneut versuchen")); } return null; } // ------------------------------------------------------------------- // Gesamtansicht // ------------------------------------------------------------------- function render() { if (!view) { mount(root, el("p.loading", {}, "Wird geladen …")); return; } const boughtCount = view.markets.reduce( (sum, m) => sum + m.categories.reduce( (n, c) => n + c.items.filter((i) => i.status === "bought").length, 0), 0); mount(root, appBar({ back: { label: "Listen", onClick: back }, title: view.list_name, actions: [ { label: "Märkte & Gruppen", onClick: () => manage(listId), hint: "Reihenfolge im Laden" }, { label: "Artikel", onClick: () => articles(listId), hint: "Stammdaten, Strichcodes" }, { label: "Preise", onClick: () => prices(listId), hint: "Vergleich zwischen Märkten" }, meta && meta.may_share_public ? { label: meta.member_count > 1 ? `Teilen (${meta.member_count})` : "Teilen", onClick: () => share(listId), hint: "Personen und öffentliche Links", } : null, // Serverseitiger Ausdruck: eigene Seite, damit auch ohne // geöffnete App gedruckt werden kann. { label: "Drucken", href: `/api/lists/${listId}/print`, hint: "A4, nach Märkten gegliedert" }, ], }), statusLine(), banner ? el("p.error", {}, banner) : null, el("section.card", {}, el("h1", {}, view.list_name), addRow()), view.markets.length ? view.markets.map(marketSection) : el("p.empty", {}, "Die Liste ist leer."), el("footer.summary", {}, el("div.total", {}, el("span", {}, "Gesamt"), el("strong", {}, euro(view.grand_total_cents))), boughtCount ? el("button.secondary", { type: "button", onclick: () => change("items.clear_bought", {}), }, boughtCount === 1 ? "1 gekauften Eintrag entfernen" : `${boughtCount} gekaufte Einträge entfernen`) : null) ); } // ------------------------------------------------------------------- // Start // ------------------------------------------------------------------- // Erst den lokalen Stand zeigen - die App startet dadurch sofort, // auch ohne Verbindung. await readLocal(); render(); // Dann im Hintergrund abgleichen. await syncNow(listId); await readLocal(); await loadHints(); render(); // Änderungen anderer Geräte. watch(listId, async () => { await readLocal(); render(); }); return () => { readLocal().then(render); }; }