297 lines
9.6 KiB
JavaScript
297 lines
9.6 KiB
JavaScript
// 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;
|
||
}
|