Files
einkaufsapp/web/html/js/views/articles.js
2026-08-09 18:44:21 +02:00

296 lines
9.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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";
import { appBar } from "../appbar.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,
appBar({ back: { label: "Zurück", onClick: back }, title: "Artikel" }),
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;
}