Files
einkaufsapp/web/html/js/views/list-detail.js
2026-08-08 20:31:58 +02:00

601 lines
20 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";
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();
// -------------------------------------------------------------------
// 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);
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 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", {}, "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",
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
});
// 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(); },
});
// Vorschläge aus dem, was auf dieser Liste schon vorkommt - so muss
// "bunt, ganz" nur einmal getippt werden.
const suggestions = new Set();
for (const market of view?.markets || []) {
for (const cat of market.categories) {
for (const item of cat.items) {
if (item.variant) suggestions.add(item.variant);
}
}
}
const variantList = el("datalist", { id: "variant-suggestions" },
[...suggestions].sort((a, b) => a.localeCompare(b, "de"))
.map((v) => el("option", { value: v })));
const variantField = el("input.variant", {
type: "text",
maxLength: 200,
placeholder: "Eigenschaft",
title: "Nähere Bestimmung, z. B. „bunt, ganz“ oder „laktosefrei“",
autocomplete: "off",
list: "variant-suggestions",
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
});
// el() setzt "list" nicht als Eigenschaft - das Attribut muss direkt
// gesetzt werden, sonst findet der Browser die Vorschlagsliste nicht.
variantField.setAttribute("list", "variant-suggestions");
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,
};
nameField.value = "";
countField.value = "";
qtyField.value = "";
unitField.value = "";
variantField.value = "";
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;
}
// 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,
count: parseCount(countField.value),
pack_size: parseQuantity(qtyField.value),
pack_unit: unitField.value.trim() || null,
variant: variantField.value.trim() || null,
});
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,
});
}
// 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", {},
nameField,
el("div.add-fields", {}, countField, qtyField, unitField, variantField),
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")),
variantList
);
}
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,
el("header.bar", {},
el("button.linklike", { type: "button", onclick: back }, " Listen"),
el("span.bar-actions", {},
// Serverseitiger Ausdruck: eigene Seite, damit auch ohne
// geöffnete App gedruckt werden kann.
el("a.linklike", {
href: `/api/lists/${listId}/print`,
target: "_blank",
rel: "noopener",
}, "Drucken"),
el("button.linklike", { type: "button", onclick: () => prices(listId) },
"Preise"),
el("button.linklike", { type: "button", onclick: () => articles(listId) },
"Artikel"),
el("button.linklike", { type: "button", onclick: () => manage(listId) },
"Märkte & Gruppen"),
meta && meta.may_share_public
? el("button.linklike", { type: "button", onclick: () => share(listId) },
meta.member_count > 1 ? `Teilen (${meta.member_count})` : "Teilen")
: null)),
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); };
}