// Artikelstamm einer Liste: Namen, Strichcodes, Vorgaben für Markt und // Warengruppe, Verfügbarkeit und frei definierbare Eigenschaften. "use strict"; import { del, get, patch, post } from "../api.js"; import { el, mount } from "../dom.js"; import { set, state } from "../store.js"; import { cameraAvailable, scanBarcode } from "./scanner.js"; let editingId = null; let query = ""; export async function articlesView(root, { listId, back }) { let articles = []; let banner = null; async function reload() { const [list, markets, categories] = await Promise.all([ get(`/api/lists/${listId}/articles${query ? `?q=${encodeURIComponent(query)}` : ""}`), get(`/api/lists/${listId}/markets`), get(`/api/lists/${listId}/categories`), ]); articles = list; set({ markets, categories }); } function say(message, kind = "error") { banner = { message, kind }; render(); setTimeout(() => { banner = null; render(); }, 6000); } async function guarded(fn) { try { await fn(); await reload(); render(); } catch (err) { say(err.message); } } // ------------------------------------------------------------------- // Bearbeitungsformular // ------------------------------------------------------------------- function editor(article) { const name = el("input", { type: "text", value: article.name, maxLength: 200 }); const barcode = el("input", { type: "text", inputMode: "numeric", value: article.barcode || "", maxLength: 64, placeholder: "keiner hinterlegt", }); const note = el("input", { type: "text", value: article.note || "", maxLength: 500, placeholder: "z. B. Lieblingsmarke", }); const marketSelect = el("select", {}, el("option", { value: "" }, "— keine Vorgabe —"), state.markets.map((m) => el("option", { value: m.id, selected: m.id === article.default_market_id, }, m.name))); const categorySelect = el("select", {}, el("option", { value: "" }, "— keine Vorgabe —"), state.categories.map((c) => el("option", { value: c.id, selected: c.id === article.default_category_id, }, c.name))); // Verfügbarkeit: mehrere Märkte ankreuzbar const availability = state.markets.map((m) => { const box = el("input", { type: "checkbox", checked: article.available_market_ids.includes(m.id), dataset: { marketId: m.id }, }); return el("label.checkline", {}, box, el("span", {}, m.name)); }); // Freie Eigenschaften const attrRows = el("div.attr-rows", {}); function addAttrRow(attr = { name: "", value: "" }) { const key = el("input.attr-key", { type: "text", value: attr.name, maxLength: 80, placeholder: "Eigenschaft", }); const value = el("input.attr-value", { type: "text", value: attr.value, maxLength: 300, placeholder: "Wert", }); const row = el("div.attr-row", {}, key, value, el("button.danger.remove", { type: "button", title: "Zeile entfernen", onclick: () => row.remove(), }, "×")); attrRows.append(row); } for (const attr of article.attributes) addAttrRow(attr); if (!article.attributes.length) addAttrRow(); async function scan() { const code = await scanBarcode(); if (code) barcode.value = code; } /** Angaben zum eingetragenen Code aus der Produktdatenbank holen. * Überschreibt nur leere Felder - Gepflegtes bleibt stehen. */ async function fetchProductData() { const code = barcode.value.replace(/\s/g, ""); if (!code) { say("Erst einen Strichcode eintragen oder scannen."); return; } try { const hit = await get( `/api/lists/${listId}/barcode/${encodeURIComponent(code)}`); if (!hit.found || hit.source !== "openfoodfacts") { say("Zu diesem Code liegen keine Angaben vor.", "notice"); return; } if (hit.name && !name.value.trim()) name.value = hit.name; if (hit.package && !note.value.trim()) note.value = hit.package; const rows = [...attrRows.querySelectorAll(".attr-row")]; const empty = rows.find( (r) => !r.querySelector(".attr-key").value.trim()); if (hit.brand && empty) { empty.querySelector(".attr-key").value = "Marke"; empty.querySelector(".attr-value").value = hit.brand; } say(`Angaben zu „${hit.name}“ übernommen. Noch nicht gespeichert.`, "notice"); } catch (err) { say(err.message); } } async function save() { const attributes = [...attrRows.querySelectorAll(".attr-row")] .map((row) => ({ name: row.querySelector(".attr-key").value.trim(), value: row.querySelector(".attr-value").value.trim(), })) .filter((a) => a.name); const available = [...availability] .map((label) => label.querySelector("input")) .filter((box) => box.checked) .map((box) => box.dataset.marketId); editingId = null; await guarded(() => patch(`/api/lists/${listId}/articles/${article.id}`, { name: name.value.trim() || article.name, barcode: barcode.value.replace(/\s/g, "") || null, note: note.value.trim() || null, default_market_id: marketSelect.value || null, default_category_id: categorySelect.value || null, attributes, available_market_ids: available, })); } return el("div.article-editor", {}, el("label", {}, "Name"), name, el("label", {}, "Strichcode"), el("div.row", {}, barcode, cameraAvailable() ? el("button.secondary", { type: "button", onclick: scan }, "Scannen") : null, el("button.secondary", { type: "button", title: "Bezeichnung und Packungsgröße aus der Produktdatenbank holen", onclick: fetchProductData, }, "Abrufen")), el("label", {}, "Notiz"), note, el("label", {}, "Vorgabe Markt"), marketSelect, el("label", {}, "Vorgabe Warengruppe"), categorySelect, state.markets.length ? el("div.availability", {}, el("label", {}, "Erhältlich bei"), availability) : null, el("label", {}, "Eigenschaften", el("span.hint", {}, " (Verpackungseinheit, Farbe, Größe …)")), attrRows, el("button.linklike.add-attr", { type: "button", onclick: () => addAttrRow(), }, "+ weitere Eigenschaft"), el("div.menu-actions", {}, el("button.primary", { type: "button", onclick: save }, "Speichern"), el("button", { type: "button", onclick: () => { editingId = null; render(); }, }, "Abbrechen"), el("button.danger", { type: "button", onclick: () => { if (!confirm( `Artikel „${article.name}“ löschen?\n\n` + "Offene Einträge dieses Artikels verschwinden mit." )) return; editingId = null; guarded(() => del(`/api/lists/${listId}/articles/${article.id}`)); }, }, "Löschen")) ); } // ------------------------------------------------------------------- // Zeile // ------------------------------------------------------------------- function row(article) { if (editingId === article.id) { return el("li.article-row.editing", {}, el("span.name", {}, article.name), editor(article)); } const marketName = state.markets.find((m) => m.id === article.default_market_id)?.name; const categoryName = state.categories.find( (c) => c.id === article.default_category_id)?.name; const facts = [ article.barcode ? `Code ${article.barcode}` : null, marketName, categoryName, article.attributes.length ? article.attributes.map((a) => `${a.name}: ${a.value}`).join(", ") : null, ].filter(Boolean); return el("li.article-row", {}, el("button.article-open", { type: "button", onclick: () => { editingId = article.id; render(); }, }, el("span.name", {}, article.name), facts.length ? el("span.sub", {}, facts.join(" · ")) : null) ); } // ------------------------------------------------------------------- function render() { const search = el("input", { type: "search", value: query, placeholder: "Artikel suchen", oninput: (ev) => { query = ev.target.value; clearTimeout(render._timer); render._timer = setTimeout(() => guarded(async () => {}), 250); }, }); mount(root, el("header.bar", {}, el("button.linklike", { type: "button", onclick: back }, "‹ Zurück zur Liste")), banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null, el("section.card", {}, el("h1", {}, "Artikel"), el("p.lead", {}, "Vorgaben für Markt und Warengruppe gelten für neue Einträge. " + "Ein hinterlegter Strichcode lässt den Artikel beim Scannen sofort " + "finden."), search, articles.length ? el("ul.articles", {}, articles.map(row)) : el("p.empty", {}, query ? "Kein Artikel gefunden." : "Noch kein Artikel angelegt.")), el("p.footnote", {}, "Artikel entstehen automatisch, sobald du sie auf die Liste setzt.") ); if (query) { const field = root.querySelector('input[type="search"]'); field.focus(); field.setSelectionRange(field.value.length, field.value.length); } } await reload(); render(); return render; }