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

186
web/html/js/views/manage.js Normal file
View File

@@ -0,0 +1,186 @@
// Märkte und Warengruppen pflegen.
//
// Die Reihenfolge wird gezogen, nicht getippt: `sort_order` verwaltet die
// App selbst und schreibt nach jedem Verschieben 10, 20, 30 … zurück. Die
// Abstände lassen Platz, falls später einmal einzeln eingefügt werden soll.
"use strict";
import { del, get, post, put } from "../api.js";
import { clear, el, mount } from "../dom.js";
import { makeSortable } from "../sortable.js";
import { set, state } from "../store.js";
const STEP = 10;
export async function manageView(root, { listId, back }) {
let banner = null;
let listName = "";
async function reload() {
const [markets, categories, meta] = await Promise.all([
get(`/api/lists/${listId}/markets`),
get(`/api/lists/${listId}/categories`),
get(`/api/lists/${listId}`),
]);
listName = meta.name;
set({ markets, categories });
}
function say(message, kind = "error") {
banner = { message, kind };
render();
setTimeout(() => { banner = null; render(); }, 6000);
}
async function guarded(fn, { silent = false } = {}) {
try {
await fn();
await reload();
render();
} catch (err) {
if (!silent) say(err.message);
await reload().catch(() => {});
render();
}
}
/** Schreibt die neue Reihenfolge zurück - nur die Einträge, deren Rang
* sich tatsächlich geändert hat. */
async function persistOrder(entries, endpoint, orderedIds) {
const byId = new Map(entries.map((e) => [e.id, e]));
const updates = [];
orderedIds.forEach((id, index) => {
const entry = byId.get(id);
const next = (index + 1) * STEP;
if (entry && entry.sort_order !== next) {
updates.push(put(`/api/lists/${listId}/${endpoint}/${entry.id}`, {
name: entry.name,
sort_order: next,
}));
}
});
if (!updates.length) return;
await guarded(() => Promise.all(updates));
}
function section({ title, entries, endpoint, hint, emptyText }) {
const nameField = el("input", {
type: "text",
maxLength: 120,
placeholder: "Name",
onkeydown: (ev) => { if (ev.key === "Enter") add(); },
});
function add() {
const name = nameField.value.trim();
if (!name) return;
guarded(async () => {
// Neues ans Ende: ein Schritt hinter dem letzten Rang.
const last = entries.length ? Math.max(...entries.map((e) => e.sort_order)) : 0;
await post(`/api/lists/${listId}/${endpoint}`, {
name,
sort_order: last + STEP,
});
nameField.value = "";
});
}
const list = el("ul.sortable", {});
for (const entry of entries) {
const handle = el("button.grip", {
type: "button",
title: "Zum Verschieben ziehen oder mit den Pfeiltasten bewegen",
"aria-label": `${entry.name} verschieben`,
}, "⠿");
const name = el("input.entry-name", {
type: "text",
value: entry.name,
maxLength: 120,
onchange: (ev) => {
const value = ev.target.value.trim();
if (!value) {
ev.target.value = entry.name;
return;
}
guarded(() => put(`/api/lists/${listId}/${endpoint}/${entry.id}`, {
name: value,
sort_order: entry.sort_order,
}));
},
});
const remove = el("button.danger.remove", {
type: "button",
title: `${entry.name} löschen`,
"aria-label": `${entry.name} löschen`,
onclick: () => {
if (!confirm(`${entry.name}“ löschen?`)) return;
guarded(() => del(`/api/lists/${listId}/${endpoint}/${entry.id}`));
},
}, "×");
list.append(el("li.sortable-row", { dataset: { id: entry.id } },
handle, name, remove));
}
if (entries.length > 1) {
makeSortable(list, {
handleSelector: ".grip",
itemSelector: ".sortable-row",
onReorder: (ids) => persistOrder(entries, endpoint, ids),
});
}
return el("section.card", {},
el("h2", {}, title),
hint ? el("p.lead", {}, hint) : null,
entries.length ? list : el("p.empty", {}, emptyText),
el("label", { htmlFor: `add-${endpoint}` }, "Hinzufügen"),
el("div.row", {},
Object.assign(nameField, { id: `add-${endpoint}` }),
el("button.primary", { type: "button", onclick: add }, "Anlegen"))
);
}
function render() {
mount(root,
el("header.bar", {},
el("button.linklike", { type: "button", onclick: back },
" Zurück zur Liste"),
listName ? el("span.who", {}, listName) : null),
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
section({
title: "Märkte",
entries: state.markets,
endpoint: "markets",
hint: "Die Reihenfolge bestimmt, in welcher Folge die Märkte in der " +
"Liste und im Ausdruck erscheinen. Am Anfasser ziehen, um sie " +
"zu ändern.",
emptyText: "Noch kein Markt angelegt.",
}),
section({
title: "Warengruppen",
entries: state.categories,
endpoint: "categories",
hint: "Innerhalb eines Marktes wird nach diesen Gruppen gegliedert; " +
"die Artikel darin stehen alphabetisch.",
emptyText: "Noch keine Warengruppe angelegt.",
}),
el("p.footnote", {},
"Ein gelöschter Markt nimmt keine Einträge mit sie landen in der " +
"Gruppe „Ohne Markt“.")
);
}
await reload();
render();
return render;
}