Erste Produktivversion
This commit is contained in:
296
web/html/js/views/articles.js
Normal file
296
web/html/js/views/articles.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user