Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

View File

@@ -0,0 +1,296 @@
// 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;
}