712 lines
24 KiB
JavaScript
712 lines
24 KiB
JavaScript
// 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 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(); },
|
||
});
|
||
|
||
// Vorgeschlagener Markt und Warengruppe: werden mit übernommen,
|
||
// sind im Formular aber nicht sichtbar - sie stehen im Menü des
|
||
// Eintrags und würden die Eingabezeile überladen.
|
||
let chosenMarket = null;
|
||
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 || "";
|
||
chosenMarket = suggestion.market_id || null;
|
||
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 eine zuvor übernommene
|
||
// Zuordnung auf - sonst landete der neue Artikel im Markt des
|
||
// alten Vorschlags.
|
||
chosenMarket = null;
|
||
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 (chosenMarket) payload.market_id = chosenMarket;
|
||
if (chosenCategory) payload.category_id = chosenCategory;
|
||
|
||
nameField.value = "";
|
||
countField.value = "";
|
||
qtyField.value = "";
|
||
unitField.value = "";
|
||
variantField.value = "";
|
||
chosenMarket = null;
|
||
chosenCategory = null;
|
||
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,
|
||
};
|
||
|
||
// 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,
|
||
});
|
||
}
|
||
|
||
// 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),
|
||
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,
|
||
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); };
|
||
}
|