Files
einkaufsapp/web/html/js/views/list-detail.js

768 lines
26 KiB
JavaScript
Raw 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.
// Eine geöffnete Liste: gruppiert nach Markt und Warengruppe, Artikel
// alphabetisch. Arbeitet gegen den lokalen Zwischenspeicher, nicht gegen
// den Server - dadurch funktioniert die Ansicht auch ohne Verbindung.
//
// Jede Änderung wandert zuerst in die Outbox und wird sofort angezeigt.
// Der Versand läuft danach; scheitert er, bleibt die Operation liegen und
// wird beim nächsten Anlauf erneut versucht.
"use strict";
// get/ApiError nur für die Strichcode-Auflösung: Sie braucht eine
// Verbindung, weil die Zuordnung Code -> Artikel serverseitig steht.
import { ApiError, get } from "../api.js";
import * as db from "../db.js";
import {
clear, el, euro, formatPack, formatQuantity, mount,
parseCents, parseCount, parseQuantity,
} from "../dom.js";
import { set, state } from "../store.js";
import { composeView, flush, pull, syncNow, watch } from "../sync.js";
import { confirmScan } from "./scan-result.js";
import { cameraAvailable, scanBarcode } from "./scanner.js";
import { appBar } from "../appbar.js";
let openMenuId = null;
export async function listDetailView(
root, { listId, back, manage, share, articles, prices }
) {
let view = null;
let meta = null;
let markets = [];
let categories = [];
let banner = null;
/** article_id -> { best_market_ids, best_cents, spread_cents, prices } */
let priceHints = new Map();
// Zuletzt im Formular gewählter Markt - überlebt den Ansichtswechsel.
let lastMarket = null;
// -------------------------------------------------------------------
// Laden
// -------------------------------------------------------------------
/** Preishinweise sind eine Nebeninformation: Kommen sie nicht, arbeitet
* die Liste ohne sie weiter. Deshalb hier kein throw, sondern der
* Rückgriff auf den zuletzt bekannten Stand. */
async function loadHints() {
if (!state.online) {
const cached = await db.cacheGet(`hints:${listId}`).catch(() => null);
if (cached) priceHints = new Map(cached.map((r) => [r.article_id, r]));
return;
}
try {
const rows = await get(`/api/lists/${listId}/price-hints`);
priceHints = new Map(rows.map((r) => [r.article_id, r]));
await db.cacheSet(`hints:${listId}`, rows);
} catch {
const cached = await db.cacheGet(`hints:${listId}`).catch(() => null);
if (cached) priceHints = new Map(cached.map((r) => [r.article_id, r]));
}
}
async function readLocal() {
view = await composeView(listId);
// cacheGet liefert den gespeicherten Wert unmittelbar; ein noch
// nie gesetzter Schlüssel ergibt undefined.
lastMarket = await db.cacheGet(`lastmarket:${listId}`).catch(() => null) || null;
markets = (await db.cacheGet(`markets:${listId}`)) || [];
categories = (await db.cacheGet(`categories:${listId}`)) || [];
meta = await db.cacheGet(`meta:${listId}`);
}
async function refresh({ fromServer = false } = {}) {
if (fromServer) {
try {
await pull(listId);
set({ syncState: "ok" });
} catch {
set({ syncState: navigator.onLine ? "error" : "offline" });
}
}
await readLocal();
render();
}
function say(message) {
banner = message;
render();
setTimeout(() => { banner = null; render(); }, 6000);
}
/** Änderung anmelden: in die Outbox, sofort anzeigen, dann senden. */
async function change(kind, payload) {
await db.enqueue(listId, kind, payload);
await readLocal();
render();
const sent = await flush(listId);
if (sent) {
await pull(listId).catch(() => {});
await readLocal();
}
if (state.syncProblem) {
const problem = state.syncProblem;
set({ syncProblem: null });
say(problem);
} else {
render();
}
}
// -------------------------------------------------------------------
// Bausteine
// -------------------------------------------------------------------
function itemRow(item) {
const bought = item.status === "bought";
const deferred = item.status === "deferred";
// Ein neu angelegter, noch nicht bestätigter Eintrag hat serverseitig
// keine ID - Folgeänderungen daran ließen sich nicht zuordnen.
const provisional = Boolean(item.pending && item.row_rev === 0);
const check = el("button.check", {
type: "button",
"aria-pressed": bought ? "true" : "false",
title: bought ? "Als offen markieren" : "Als gekauft markieren",
onclick: () => change("item.update", {
item_id: item.id,
status: bought ? "open" : "bought",
}),
disabled: provisional,
}, bought ? "✓" : "");
const priceField = el("input.price", {
type: "text",
inputMode: "decimal",
placeholder: "€",
value: item.price_cents ? (item.price_cents / 100).toFixed(2).replace(".", ",") : "",
title: provisional ? "Erst nach der Übertragung möglich" : "Preis erfassen",
disabled: provisional,
onchange: (ev) => {
const cents = parseCents(ev.target.value);
if (ev.target.value.trim() && cents === null) {
ev.target.value = "";
say("Preis konnte nicht gelesen werden.");
return;
}
change("item.update", { item_id: item.id, price_cents: cents });
},
});
const menuButton = el("button.menu-toggle", {
type: "button",
title: "Weitere Aktionen",
disabled: provisional,
onclick: () => {
openMenuId = openMenuId === item.id ? null : item.id;
render();
},
}, "⋯");
const pack = formatPack(item.pack_size, item.pack_unit);
const shared = meta && meta.member_count > 1;
const by = shared && item.created_by_name ? `von ${item.created_by_name}` : "";
const sub = [pack, item.variant, item.note, by].filter(Boolean).join(" · ");
return el("li", {
className: [
"item",
bought ? "bought" : "",
deferred ? "deferred" : "",
item.pending ? "pending" : "",
].filter(Boolean).join(" "),
},
el("div.item-main", {},
check,
// Stückzahl direkt am Artikel: Beim Einkaufen ist "wie viele"
// die wichtigste Angabe und darf nicht im Untertitel untergehen.
el("span", {
className: `item-count${item.count > 1 ? " many" : ""}`,
title: item.count === 1 ? "1 Stück" : `${item.count} Stück`,
}, `${item.count || 1}\u00d7`),
el("div.item-text", {},
el("span.name", {}, item.article_name),
sub ? el("span.sub", {}, sub) : null),
el("div.price-cell", {},
priceField,
// Bei mehreren Stück wird sichtbar, dass der Preis je Gebinde
// gilt und was zusammen daraus wird.
item.price_cents && item.count > 1
? el("span.line-total", {}, `= ${euro(item.total_cents)}`)
: null),
menuButton),
openMenuId === item.id && !provisional ? itemMenu(item) : null
);
}
function itemMenu(item) {
const marketSelect = el("select", {
onchange: (ev) => {
const value = ev.target.value;
openMenuId = null;
change("item.update", value
? { item_id: item.id, market_id: value }
: { item_id: item.id, clear_market: true });
},
},
el("option", { value: "" }, "— ohne Markt —"),
markets.map((m) =>
el("option", { value: m.id, selected: m.id === item.market_id }, m.name))
);
const categorySelect = el("select", {
onchange: (ev) => {
const value = ev.target.value;
openMenuId = null;
change("item.update", value
? { item_id: item.id, category_id: value }
: { item_id: item.id, clear_category: true });
},
},
el("option", { value: "" }, "— ohne Warengruppe —"),
categories.map((c) =>
el("option", { value: c.id, selected: c.id === item.category_id }, c.name))
);
const deferred = item.status === "deferred";
const nameEdit = el("input", {
type: "text",
value: item.article_name,
maxLength: 200,
onchange: (ev) => {
const value = ev.target.value.trim();
if (!value) {
// Leeres Feld ist kein Löschbefehl - der alte Name bleibt.
ev.target.value = item.article_name;
return;
}
if (value === item.article_name) return;
change("item.update", { item_id: item.id, article_name: value });
},
});
const variantField = el("input", {
type: "text",
value: item.variant || "",
maxLength: 200,
placeholder: "z. B. bunt, ganz",
onchange: (ev) => change("item.update", {
item_id: item.id,
variant: ev.target.value.trim() || null,
}),
});
const noteField = el("input", {
type: "text",
value: item.note || "",
maxLength: 500,
placeholder: "Bemerkung für den Einkauf",
onchange: (ev) => change("item.update", {
item_id: item.id,
note: ev.target.value.trim() || null,
}),
});
const hint = priceHints.get(item.article_id);
const hintBlock = hint ? (() => {
const bestNames = hint.best_market_ids
.map((id) => markets.find((m) => m.id === id)?.name)
.filter(Boolean)
.join(", ");
const hereCents = item.market_id ? hint.prices[item.market_id] : null;
if (!bestNames) return null;
const alreadyBest = item.market_id
&& hint.best_market_ids.includes(item.market_id);
return el("p.price-tip", {},
alreadyBest
? `Hier am günstigsten: ${euro(hint.best_cents)}.`
: `Zuletzt günstigster Markt: ${bestNames} für ${euro(hint.best_cents)}`,
!alreadyBest && hereCents
? ` hier zuletzt ${euro(hereCents)}.`
: !alreadyBest ? "." : "");
})() : null;
const countEdit = el("input", {
type: "text",
inputMode: "numeric",
value: String(item.count || 1),
title: "Stückzahl",
onchange: (ev) => change("item.update", {
item_id: item.id, count: parseCount(ev.target.value),
}),
});
const packSizeEdit = el("input.qty", {
type: "text",
inputMode: "decimal",
value: item.pack_size ? formatQuantity(item.pack_size) : "",
placeholder: "Gebinde",
onchange: (ev) => change("item.update", {
item_id: item.id, pack_size: parseQuantity(ev.target.value),
}),
});
const packUnitEdit = el("input.unit", {
type: "text",
maxLength: 32,
value: item.pack_unit || "",
placeholder: "Einheit",
onchange: (ev) => change("item.update", {
item_id: item.id, pack_unit: ev.target.value.trim() || null,
}),
});
return el("div.item-menu", {},
hintBlock,
el("label", {}, "Artikel"),
nameEdit,
el("p.sub", {},
"Steht der Artikel nur auf diesem Eintrag, wird er umbenannt " +
"ein Tippfehler verschwindet damit ganz. Wird er auch anderswo " +
"verwendet, bekommt nur dieser Eintrag den neuen Artikel."),
el("label", {}, "Anzahl und Gebinde",
el("span.hint", {}, " (Preis gilt je Gebinde)")),
el("div.add-fields", {}, countEdit, packSizeEdit, packUnitEdit),
el("label", {}, "Eigenschaft"), variantField,
el("label", {}, "Notiz"), noteField,
el("label", {}, "Markt"), marketSelect,
el("label", {}, "Warengruppe"), categorySelect,
el("div.menu-actions", {},
el("button", {
type: "button",
onclick: () => {
openMenuId = null;
change("item.update", {
item_id: item.id,
status: deferred ? "open" : "deferred",
});
},
}, deferred ? "Wieder aufnehmen" : "Zurückstellen"),
el("button.danger", {
type: "button",
onclick: () => {
openMenuId = null;
change("item.delete", { item_id: item.id });
},
}, "Löschen"))
);
}
function marketSection(market) {
return el("section.market", {},
el("h2", {},
el("span", {}, market.market_name),
el("span.market-meta", {},
market.open_count === 1 ? "1 offen" : `${market.open_count} offen`,
market.total_cents ? ` · ${euro(market.total_cents)}` : "")),
market.categories.map((cat) =>
el("div.category", {},
el("h3", {}, cat.category_name),
el("ul.items", {}, cat.items.map(itemRow))))
);
}
function addRow() {
const nameField = el("input", {
id: "add-name",
type: "text",
maxLength: 200,
placeholder: "Artikel hinzufügen",
autocomplete: "off",
// Der eingebaute Vervollständiger würde die eigene Vorschlagsliste
// überdecken.
autocapitalize: "sentences",
onkeydown: (ev) => {
if (ev.key === "Enter") { closeSuggestions(); addItem(); }
if (ev.key === "Escape") closeSuggestions();
},
oninput: () => scheduleSuggestions(),
});
// Anzahl und Gebinde getrennt: Der Preis gilt je Gebinde, die
// Summe ist Preis mal Anzahl. Steckte beides in einem Feld, ergaben
// 500 ml zu 2,99 EUR eine Summe von 1495 EUR.
const countField = el("input.count", {
type: "text", inputMode: "numeric", placeholder: "Anz.", value: "",
title: "Stückzahl wie viele Packungen",
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
});
const qtyField = el("input.qty", {
type: "text", inputMode: "decimal", placeholder: "Gebinde",
title: "Packungsgröße, z. B. 500",
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
});
const unitField = el("input.unit", {
type: "text", maxLength: 32, placeholder: "Einheit",
title: "z. B. ml, g, Stk",
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
});
const variantField = el("input.variant", {
type: "text",
maxLength: 200,
placeholder: "Eigenschaft",
title: "Nähere Bestimmung, z. B. „bunt, ganz“ oder „laktosefrei“",
autocomplete: "off",
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
});
// Marktauswahl direkt im Formular: Ohne sie müsste jeder Eintrag
// nachbearbeitet werden, und beim Füllen einer Liste sind das
// schnell zwanzig Handgriffe.
//
// Die leere Auswahl heißt bewusst "Vorgabe des Artikels" und nicht
// "ohne Markt": Der Server greift in diesem Fall auf die im
// Artikelstamm hinterlegte Vorgabe zurück.
const marketField = markets.length
? el("select.market-select", {
title: "Markt für diesen Eintrag",
onchange: () => {
markSelection();
// Merken, damit die nächsten Artikel derselben Runde nicht
// erneut ausgewählt werden müssen.
db.cacheSet(`lastmarket:${listId}`, marketField.value || null)
.catch(() => {});
},
},
el("option", { value: "" }, "— Vorgabe des Artikels —"),
markets.map((m) => el("option", { value: m.id }, m.name)))
: null;
function markSelection() {
if (!marketField) return;
// Leeres Attribut = keine Vorgabe; das Stylesheet hebt eine
// getroffene Auswahl hervor.
if (marketField.value) marketField.removeAttribute("data-empty");
else marketField.setAttribute("data-empty", "");
}
if (marketField && lastMarket
&& markets.some((m) => m.id === lastMarket)) {
marketField.value = lastMarket;
}
markSelection();
// Die Warengruppe bleibt im Formular unsichtbar: Sie kommt aus der
// Vorgabe des Artikels oder aus einem Vorschlag. Ein zweites
// Auswahlfeld würde die Zeile überladen, ohne oft gebraucht zu
// werden.
let chosenCategory = null;
const suggestionBox = el("ul.suggestions", { hidden: true });
function closeSuggestions() {
suggestionBox.hidden = true;
suggestionBox.replaceChildren();
}
/** Übernimmt einen Vorschlag in die Felder - aber trägt ihn NICHT
* ein. So bleibt Gelegenheit, Menge oder Eigenschaft zu ändern,
* bevor der Artikel auf der Liste landet. */
function applySuggestion(suggestion) {
nameField.value = suggestion.name;
countField.value = suggestion.count && suggestion.count > 1
? String(suggestion.count) : "";
qtyField.value = suggestion.pack_size
? formatQuantity(suggestion.pack_size) : "";
unitField.value = suggestion.pack_unit || "";
variantField.value = suggestion.variant || "";
// Zuletzt verwendeter Markt des Artikels - genau das, was man
// beim Wiederholen eines Einkaufs erwartet.
if (marketField && suggestion.market_id
&& markets.some((m) => m.id === suggestion.market_id)) {
marketField.value = suggestion.market_id;
markSelection();
}
chosenCategory = suggestion.category_id || null;
closeSuggestions();
// Zum Ändern einladen, nicht zum Abschicken drängen.
qtyField.focus();
}
let suggestTimer = null;
let suggestToken = 0;
function scheduleSuggestions() {
// Getippte Zeichen sammeln, statt bei jedem Anschlag zu fragen.
clearTimeout(suggestTimer);
const value = nameField.value.trim();
// Eine frei getippte Eingabe hebt die aus einem Vorschlag
// übernommene Warengruppe auf. Der Markt bleibt stehen: Er ist
// jetzt sichtbar ausgewählt, und wer ihn nicht will, ändert ihn.
chosenCategory = null;
if (value.length < 3 || !state.online) {
closeSuggestions();
return;
}
suggestTimer = setTimeout(() => loadSuggestions(value), 220);
}
async function loadSuggestions(value) {
const token = ++suggestToken;
let rows;
try {
rows = await get(
`/api/lists/${listId}/articles/suggest?q=${encodeURIComponent(value)}`);
} catch {
// Vorschläge sind Beiwerk: Wenn sie nicht kommen, tippt man
// eben weiter.
return;
}
// Antwort einer älteren Anfrage verwerfen.
if (token !== suggestToken) return;
if (!rows.length) {
closeSuggestions();
return;
}
suggestionBox.replaceChildren(...rows.map((row) => {
const details = [
row.count && row.count > 1 ? `${row.count}×` : null,
formatPack(row.pack_size, row.pack_unit) || null,
row.variant,
markets.find((m) => m.id === row.market_id)?.name,
].filter(Boolean);
return el("li", {},
el("button", {
type: "button",
// mousedown statt click: Ein Klick käme erst nach dem
// blur des Eingabefelds - und das schließt die Liste.
onmousedown: (ev) => { ev.preventDefault(); applySuggestion(row); },
onclick: () => applySuggestion(row),
},
el("span.name", {}, row.name),
details.length ? el("span.sub", {}, details.join(" · ")) : null));
}));
suggestionBox.hidden = false;
}
async function addItem() {
const name = nameField.value.trim();
if (!name) return;
const payload = {
article_name: name,
count: parseCount(countField.value),
pack_size: parseQuantity(qtyField.value),
pack_unit: unitField.value.trim() || null,
variant: variantField.value.trim() || null,
};
if (marketField && marketField.value) payload.market_id = marketField.value;
if (chosenCategory) payload.category_id = chosenCategory;
nameField.value = "";
countField.value = "";
qtyField.value = "";
unitField.value = "";
variantField.value = "";
chosenCategory = null;
// Der Markt bleibt absichtlich stehen: Wer eine Liste füllt,
// trägt meist mehrere Artikel für denselben Laden ein.
closeSuggestions();
await change("item.create", payload);
document.getElementById("add-name")?.focus();
}
/** Strichcode scannen, auflösen, bestätigen, hinzufügen.
*
* Braucht Verbindung: Sowohl der eigene Artikelstamm als auch die
* Produktdatenbank liegen auf dem Server. Offline bliebe eine
* Ziffernfolge ohne Bedeutung - deshalb hier ehrlich abbrechen,
* statt etwas Unbrauchbares in die Warteschlange zu legen. */
async function scanAndAdd() {
const code = await scanBarcode();
if (!code) return;
if (!state.online) {
say("Zum Scannen wird eine Verbindung gebraucht die Zuordnung " +
"des Codes steht auf dem Server.");
return;
}
let lookup;
try {
lookup = await get(
`/api/lists/${listId}/barcode/${encodeURIComponent(code)}`);
} catch (err) {
say(err.message);
return;
}
const extras = {
count: parseCount(countField.value),
pack_size: parseQuantity(qtyField.value),
pack_unit: unitField.value.trim() || null,
variant: variantField.value.trim() || null,
};
if (marketField && marketField.value) extras.market_id = marketField.value;
// Bereits im eigenen Bestand: ohne Rückfrage auf die Liste.
if (lookup.source === "catalog") {
await change("item.create", {
article_id: lookup.article_id, barcode: code, ...extras,
});
countField.value = "";
qtyField.value = "";
unitField.value = "";
variantField.value = "";
say(`${lookup.name}“ hinzugefügt.`);
return;
}
// Sonst bestätigen lassen - auch bei einem Treffer in der
// Produktdatenbank. Fremde Angaben sollen nicht ungeprüft in den
// eigenen Artikelstamm wandern.
const confirmed = await confirmScan(lookup);
if (!confirmed) return;
await change("item.create", {
article_name: confirmed.name,
barcode: code,
count: parseCount(confirmed.count),
pack_size: parseQuantity(confirmed.packSize),
pack_unit: confirmed.packUnit || null,
variant: confirmed.variant || null,
...(marketField && marketField.value
? { market_id: marketField.value } : {}),
});
}
// Aufbau: Name über die volle Breite, darunter die Detailfelder,
// darunter die Schaltflächen. Vorher standen Felder und
// Schaltflächen in einer Zeile - auf schmalen Geräten lief der
// letzte Knopf aus der Box.
return el("div.add-row", {},
el("div.name-wrap", {}, nameField, suggestionBox),
el("div.add-fields", {}, countField, qtyField, unitField, variantField),
marketField ? el("div.add-market", {}, marketField) : null,
el("div.add-actions", {},
cameraAvailable()
? el("button.secondary", {
type: "button",
title: "Strichcode scannen",
onclick: scanAndAdd,
}, "Scannen")
: null,
el("button.primary", { type: "button", onclick: addItem }, "Hinzufügen"))
);
}
function statusLine() {
const pendingCount = view?.pending_ops || 0;
if (!state.online) {
return el("p.sync-hint.offline", {},
"Offline Änderungen werden gespeichert und später übertragen",
pendingCount ? ` (${pendingCount} wartend).` : ".");
}
if (pendingCount) {
return el("p.sync-hint.pending", {},
pendingCount === 1
? "1 Änderung wird übertragen …"
: `${pendingCount} Änderungen werden übertragen …`);
}
if (state.syncState === "error") {
return el("p.sync-hint.problem", {},
"Der Server ist gerade nicht erreichbar. ",
el("button.linklike", {
type: "button",
onclick: () => refresh({ fromServer: true }),
}, "Erneut versuchen"));
}
return null;
}
// -------------------------------------------------------------------
// Gesamtansicht
// -------------------------------------------------------------------
function render() {
if (!view) {
mount(root, el("p.loading", {}, "Wird geladen …"));
return;
}
const boughtCount = view.markets.reduce(
(sum, m) => sum + m.categories.reduce(
(n, c) => n + c.items.filter((i) => i.status === "bought").length, 0), 0);
mount(root,
appBar({
back: { label: "Listen", onClick: back },
title: view.list_name,
actions: [
{ label: "Märkte & Gruppen", onClick: () => manage(listId),
hint: "Reihenfolge im Laden" },
{ label: "Artikel", onClick: () => articles(listId),
hint: "Stammdaten, Strichcodes" },
{ label: "Preise", onClick: () => prices(listId),
hint: "Vergleich zwischen Märkten" },
meta && meta.may_share_public
? {
label: meta.member_count > 1
? `Teilen (${meta.member_count})` : "Teilen",
onClick: () => share(listId),
hint: "Personen und öffentliche Links",
}
: null,
// Serverseitiger Ausdruck: eigene Seite, damit auch ohne
// geöffnete App gedruckt werden kann.
{ label: "Drucken", href: `/api/lists/${listId}/print`,
hint: "A4, nach Märkten gegliedert" },
],
}),
statusLine(),
banner ? el("p.error", {}, banner) : null,
el("section.card", {},
el("h1", {}, view.list_name),
addRow()),
view.markets.length
? view.markets.map(marketSection)
: el("p.empty", {}, "Die Liste ist leer."),
el("footer.summary", {},
el("div.total", {},
el("span", {}, "Gesamt"),
el("strong", {}, euro(view.grand_total_cents))),
boughtCount
? el("button.secondary", {
type: "button",
onclick: () => change("items.clear_bought", {}),
}, boughtCount === 1
? "1 gekauften Eintrag entfernen"
: `${boughtCount} gekaufte Einträge entfernen`)
: null)
);
}
// -------------------------------------------------------------------
// Start
// -------------------------------------------------------------------
// Erst den lokalen Stand zeigen - die App startet dadurch sofort,
// auch ohne Verbindung.
await readLocal();
render();
// Dann im Hintergrund abgleichen.
await syncNow(listId);
await readLocal();
await loadHints();
render();
// Änderungen anderer Geräte.
watch(listId, async () => {
await readLocal();
render();
});
return () => { readLocal().then(render); };
}