// 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"; 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(); // ------------------------------------------------------------------- // 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); 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 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", {}, "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", onkeydown: (ev) => { if (ev.key === "Enter") addItem(); }, }); // 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(); }, }); // Vorschläge aus dem, was auf dieser Liste schon vorkommt - so muss // "bunt, ganz" nur einmal getippt werden. const suggestions = new Set(); for (const market of view?.markets || []) { for (const cat of market.categories) { for (const item of cat.items) { if (item.variant) suggestions.add(item.variant); } } } const variantList = el("datalist", { id: "variant-suggestions" }, [...suggestions].sort((a, b) => a.localeCompare(b, "de")) .map((v) => el("option", { value: v }))); const variantField = el("input.variant", { type: "text", maxLength: 200, placeholder: "Eigenschaft", title: "Nähere Bestimmung, z. B. „bunt, ganz“ oder „laktosefrei“", autocomplete: "off", list: "variant-suggestions", onkeydown: (ev) => { if (ev.key === "Enter") addItem(); }, }); // el() setzt "list" nicht als Eigenschaft - das Attribut muss direkt // gesetzt werden, sonst findet der Browser die Vorschlagsliste nicht. variantField.setAttribute("list", "variant-suggestions"); 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, }; nameField.value = ""; countField.value = ""; qtyField.value = ""; unitField.value = ""; variantField.value = ""; 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; } // 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, count: parseCount(countField.value), pack_size: parseQuantity(qtyField.value), pack_unit: unitField.value.trim() || null, variant: variantField.value.trim() || null, }); 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, }); } // 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", {}, nameField, el("div.add-fields", {}, countField, qtyField, unitField, variantField), 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")), variantList ); } 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, el("header.bar", {}, el("button.linklike", { type: "button", onclick: back }, "‹ Listen"), el("span.bar-actions", {}, // Serverseitiger Ausdruck: eigene Seite, damit auch ohne // geöffnete App gedruckt werden kann. el("a.linklike", { href: `/api/lists/${listId}/print`, target: "_blank", rel: "noopener", }, "Drucken"), el("button.linklike", { type: "button", onclick: () => prices(listId) }, "Preise"), el("button.linklike", { type: "button", onclick: () => articles(listId) }, "Artikel"), el("button.linklike", { type: "button", onclick: () => manage(listId) }, "Märkte & Gruppen"), meta && meta.may_share_public ? el("button.linklike", { type: "button", onclick: () => share(listId) }, meta.member_count > 1 ? `Teilen (${meta.member_count})` : "Teilen") : null)), 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); }; }