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

104
web/html/js/dom.js Normal file
View File

@@ -0,0 +1,104 @@
// Kleine DOM-Hilfen. Ersetzt kein Framework, spart nur Wiederholung.
"use strict";
/** el("button.primary", { onclick: fn }, "Text")
* Setzt Text ausschließlich über textContent - dadurch kann kein
* Benutzereingabewert als HTML interpretiert werden. */
export function el(spec, props = {}, ...children) {
const [tag, ...classes] = String(spec).split(".");
const node = document.createElement(tag || "div");
if (classes.length) node.className = classes.join(" ");
for (const [key, value] of Object.entries(props)) {
if (value === null || value === undefined || value === false) continue;
if (key.startsWith("on") && typeof value === "function") {
node.addEventListener(key.slice(2).toLowerCase(), value);
} else if (key === "dataset") {
Object.assign(node.dataset, value);
} else if (key in node && key !== "list") {
node[key] = value;
} else {
node.setAttribute(key, value === true ? "" : value);
}
}
append(node, children);
return node;
}
function append(node, children) {
for (const child of children.flat(Infinity)) {
if (child === null || child === undefined || child === false) continue;
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
}
export function clear(node) {
node.replaceChildren();
return node;
}
/** Ersetzt den Inhalt eines Knotens.
*
* Nicht clear(root).append(...) verwenden: Node.append() macht aus einem
* null die Zeichenkette "null" und zeigt sie an. Genau dafür filtert
* el() seine Kinder - mount() zieht denselben Schutz auf die Wurzel. */
export function mount(node, ...children) {
node.replaceChildren();
for (const child of children.flat(Infinity)) {
if (child === null || child === undefined || child === false) continue;
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
return node;
}
/** Preis in Cent -> "1,29 €". Rechnen immer in Cent, formatieren erst hier. */
export function euro(cents) {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format((cents || 0) / 100);
}
/** "1,29" oder "1.29" -> 129. Gibt null zurück, wenn nichts Sinnvolles drinsteht. */
export function parseCents(text) {
const cleaned = String(text).trim().replace(",", ".");
if (!cleaned) return null;
const value = Number(cleaned);
if (!Number.isFinite(value) || value < 0) return null;
return Math.round(value * 100);
}
export function parseQuantity(text) {
const cleaned = String(text).trim().replace(",", ".");
if (!cleaned) return null;
const value = Number(cleaned);
return Number.isFinite(value) && value >= 0 ? value : null;
}
/** Stückzahl: ganze Zahl ab 1. Leer oder unsinnig ergibt 1 - ein Eintrag
* ohne Stückzahl wäre unvollständig. */
export function parseCount(text) {
const value = Number(String(text).trim().replace(",", "."));
if (!Number.isFinite(value) || value < 1) return 1;
return Math.min(999, Math.round(value));
}
/** Nur das Gebinde, ohne Stückzahl.
*
* Wird in der Listenzeile gebraucht, weil die Stückzahl dort als
* eigenes Feld links neben dem Namen steht - beim Einkaufen soll sie
* ins Auge springen und nicht im Kleingedruckten stehen. */
export function formatPack(packSize, packUnit) {
if (!packSize) return "";
return `${formatQuantity(packSize)}${packUnit ? " " + packUnit : ""}`;
}
/** Menge aus der API kommt als Zeichenkette ("2.000"), damit unterwegs
* keine Nachkommastellen verloren gehen. Für die Anzeige aufräumen. */
export function formatQuantity(value) {
if (value === null || value === undefined) return "";
const num = Number(value);
if (!Number.isFinite(num)) return String(value);
return num.toLocaleString("de-DE", { maximumFractionDigits: 3 });
}