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,600 @@
// 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); };
}