neue Archivfunktion und Optimierung an Artikelbearbeitung

This commit is contained in:
2026-08-09 18:24:04 +02:00
parent dd51532533
commit b80e2d5a6c
23 changed files with 1185 additions and 77 deletions

View File

@@ -1243,3 +1243,83 @@ input.months {
}
@media print { .stats, ul.admin-users { display: none !important; } }
/* ===================================================================
Vorschlagsliste beim Eintippen
=================================================================== */
.name-wrap { position: relative; }
ul.suggestions {
position: absolute;
z-index: 20;
left: 0;
right: 0;
top: calc(100% + 2px);
list-style: none;
margin: 0;
padding: 0;
max-height: 16rem;
overflow-y: auto;
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: 0 6px 18px rgba(0, 0, 0, .16);
}
ul.suggestions > li { border-bottom: 1px solid var(--line); }
ul.suggestions > li:last-child { border-bottom: none; }
ul.suggestions button {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
padding: .55rem .8rem;
font: inherit;
color: inherit;
cursor: pointer;
}
ul.suggestions button:hover,
ul.suggestions button:focus-visible {
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
ul.suggestions .name { display: block; overflow-wrap: anywhere; }
ul.suggestions .sub { display: block; font-size: .78rem; color: var(--muted); }
/* ===================================================================
Archiv in der Listenübersicht
=================================================================== */
.card.archive { padding-top: .75rem; }
button.archive-head {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
background: none;
border: none;
padding: .4rem 0;
font: inherit;
font-weight: 600;
color: var(--fg);
cursor: pointer;
}
button.archive-head .chev { color: var(--muted); }
.card.archive ul.lists { margin-top: .5rem; }
.reg-toggle {
padding: 0 0 .9rem;
border-bottom: 1px solid var(--line);
margin-bottom: .3rem;
}
.reg-toggle .checkline { margin-top: 0; }
@media print { ul.suggestions, .card.archive { display: none !important; } }

View File

@@ -131,6 +131,10 @@ export function applyOp(view, op, markets, categories) {
else if ("market_id" in p) { item.market_id = p.market_id; needsRegroup = true; }
if (p.clear_category) { item.category_id = null; needsRegroup = true; }
else if ("category_id" in p) { item.category_id = p.category_id; needsRegroup = true; }
// Der Server entscheidet, ob umbenannt oder umgehängt wird - für
// die Anzeige ist beides derselbe neue Name.
if (p.article_name) item.article_name = p.article_name;
for (const field of ["count", "pack_size", "pack_unit", "variant",
"note", "status", "price_cents"]) {
if (field in p) item[field] = p[field];

View File

@@ -91,6 +91,35 @@ export async function adminView(root, { back }) {
);
}
/** Steht bei den Konten, nicht bei der Bereinigung: Es geht darum,
* WER hinzukommen darf - das gehört zur Kontenverwaltung. */
function registrationToggle() {
const box = el("input", {
type: "checkbox",
checked: config.allow_self_registration,
disabled: config.locked_by_env,
onchange: (ev) => guarded(async () => {
await put("/api/admin/settings", {
allow_self_registration: ev.target.checked,
});
say(ev.target.checked
? "Selbstregistrierung eingeschaltet."
: "Selbstregistrierung abgeschaltet Zugang nur noch per Einladung.");
}),
});
return el("div.reg-toggle", {},
el("label.checkline", {}, box,
el("span", {}, "Selbstregistrierung erlauben")),
config.locked_by_env
? el("p.sub", {},
"Über die Umgebungsvariable ALLOW_SELF_REGISTRATION festgelegt " +
"und hier nicht änderbar.")
: el("p.sub", {},
"Ist sie aus, kommen neue Personen nur über „Konto anlegen“ " +
"oder eine Listeneinladung hinein."));
}
// -------------------------------------------------------------------
// Neues Konto
// -------------------------------------------------------------------
@@ -275,12 +304,14 @@ export async function adminView(root, { back }) {
type: "number", min: "0", max: "600",
value: String(config.auto_delete_months),
});
const selfReg = el("input", {
type: "checkbox",
checked: config.allow_self_registration,
disabled: config.locked_by_env,
const archive = el("input.months", {
type: "number", min: "0", max: "600",
value: String(config.auto_archive_months),
});
const archiveDelete = el("input.months", {
type: "number", min: "0", max: "600",
value: String(config.archive_delete_months),
});
return el("section.card", {},
el("h2", {}, "Automatische Bereinigung"),
el("p.lead", {},
@@ -293,13 +324,17 @@ export async function adminView(root, { back }) {
el("label", {}, "Löschen nach … Monaten Deaktivierung"),
remove,
el("label.checkline", {}, selfReg,
el("span", {}, "Selbstregistrierung erlauben")),
config.locked_by_env
? el("p.sub", {},
"Über die Umgebungsvariable ALLOW_SELF_REGISTRATION festgelegt " +
"und hier nicht änderbar.")
: null,
el("h3.device-heading", {}, "Listen"),
el("label", {}, "Archivieren nach … Monaten ohne Öffnen"),
archive,
el("p.sub", {},
"Gilt je Person: Die Liste verschwindet nur aus der Übersicht " +
"derjenigen, die sie nicht geöffnet hat."),
el("label", {}, "Löschen nach … Monaten im Archiv"),
archiveDelete,
el("p.sub", {},
"Maßgeblich ist allein das Archivdatum des Eigentümers. Er wird " +
"7 Tage und 1 Tag vorher per E-Mail gewarnt."),
el("button.primary", {
type: "button",
@@ -307,9 +342,8 @@ export async function adminView(root, { back }) {
await put("/api/admin/settings", {
auto_deactivate_months: Number(deactivate.value) || 0,
auto_delete_months: Number(remove.value) || 0,
...(config.locked_by_env
? {}
: { allow_self_registration: selfReg.checked }),
auto_archive_months: Number(archive.value) || 0,
archive_delete_months: Number(archiveDelete.value) || 0,
});
say("Einstellungen gespeichert.");
}),
@@ -335,6 +369,7 @@ export async function adminView(root, { back }) {
statsCard(),
el("section.card", {},
el("h2", {}, "Konten"),
registrationToggle(),
el("ul.admin-users", {}, users.map(userRow))),
createCard(),
automationCard()

View File

@@ -220,6 +220,22 @@ export async function listDetailView(
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 || "",
@@ -293,6 +309,12 @@ export async function listDetailView(
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),
@@ -342,8 +364,16 @@ export async function listDetailView(
maxLength: 200,
placeholder: "Artikel hinzufügen",
autocomplete: "off",
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
// 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.
@@ -362,33 +392,106 @@ export async function listDetailView(
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");
// 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();
@@ -400,11 +503,18 @@ export async function listDetailView(
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();
}
@@ -434,15 +544,17 @@ export async function listDetailView(
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,
count: parseCount(countField.value),
pack_size: parseQuantity(qtyField.value),
pack_unit: unitField.value.trim() || null,
variant: variantField.value.trim() || null,
article_id: lookup.article_id, barcode: code, ...extras,
});
countField.value = "";
qtyField.value = "";
@@ -473,7 +585,7 @@ export async function listDetailView(
// Schaltflächen in einer Zeile - auf schmalen Geräten lief der
// letzte Knopf aus der Box.
return el("div.add-row", {},
nameField,
el("div.name-wrap", {}, nameField, suggestionBox),
el("div.add-fields", {}, countField, qtyField, unitField, variantField),
el("div.add-actions", {},
cameraAvailable()
@@ -483,8 +595,7 @@ export async function listDetailView(
onclick: scanAndAdd,
}, "Scannen")
: null,
el("button.primary", { type: "button", onclick: addItem }, "Hinzufügen")),
variantList
el("button.primary", { type: "button", onclick: addItem }, "Hinzufügen"))
);
}

View File

@@ -10,14 +10,32 @@ import { set, state } from "../store.js";
let openMenuId = null;
// Welche Zeile wird gerade umbenannt
let renamingId = null;
// Ist das Archiv aufgeklappt?
let archiveOpen = false;
const ROLE_LABEL = { owner: "Eigentümer", editor: "Bearbeiter", viewer: "Nur lesen" };
function formatDate(iso) {
if (!iso) return "";
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric",
});
}
export async function listsView(root, { openList, onLogout, settings }) {
let banner = null;
let archived = [];
async function reload() {
set({ lists: await get("/api/lists") });
// Zwei Abfragen statt einer mit Filterung im Browser: Das Archiv
// wird selten geöffnet und kann mit der Zeit groß werden.
const [active, old] = await Promise.all([
get("/api/lists?archived=false"),
get("/api/lists?archived=true"),
]);
archived = old;
set({ lists: active });
}
function say(message, kind = "error") {
@@ -102,25 +120,43 @@ export async function listsView(root, { openList, onLogout, settings }) {
openMenuId === entry.id
? el("div.list-menu", {},
isOwner
? el("div.menu-actions", {},
el("button", {
// Archivieren wirkt nur für die eigene Übersicht - deshalb
// steht es allen Mitgliedern offen, nicht nur dem Eigentümer.
el("p.sub", {},
entry.archived
? `Seit ${formatDate(entry.archived_at)} in deinem Archiv.`
: "Archivieren nimmt die Liste aus deiner Übersicht " +
"für die anderen Mitglieder bleibt sie sichtbar."),
entry.archived && entry.delete_due_at
? el("p.lead.warn", {},
`Wird am ${formatDate(entry.delete_due_at)} automatisch ` +
"gelöscht, wenn du sie nicht zurückholst.")
: null,
el("div.menu-actions", {},
el("button", {
type: "button",
onclick: () => guarded(() =>
post(`/api/lists/${entry.id}/${entry.archived ? "un" : ""}archive`)),
}, entry.archived ? "Aus dem Archiv holen" : "Archivieren"),
isOwner
? el("button", {
type: "button",
onclick: () => {
openMenuId = null;
renamingId = entry.id;
render();
},
}, "Umbenennen"),
el("button.danger", {
type: "button",
onclick: () => remove(entry),
}, "Löschen"))
: el("div.menu-actions", {},
el("button.danger", {
type: "button",
onclick: () => leave(entry),
}, "Liste verlassen")))
}, "Umbenennen")
: null),
isOwner
? el("button.danger.wide", {
type: "button",
onclick: () => remove(entry),
}, "Löschen")
: el("button.danger.wide", {
type: "button",
onclick: () => leave(entry),
}, "Liste verlassen"))
: null
);
}
@@ -220,9 +256,31 @@ export async function listsView(root, { openList, onLogout, settings }) {
el("h1", {}, "Meine Listen"),
state.lists.length
? el("ul.lists", {}, state.lists.map(row))
: el("p.empty", {}, "Noch keine Liste vorhanden."),
: el("p.empty", {},
archived.length
? "Alle Listen liegen im Archiv."
: "Noch keine Liste vorhanden."),
el("label", { htmlFor: "new-list" }, "Neue Liste"),
createRow())
createRow()),
archived.length
? el("section.card.archive", {},
el("button.archive-head", {
type: "button",
"aria-expanded": archiveOpen ? "true" : "false",
onclick: () => { archiveOpen = !archiveOpen; render(); },
},
el("span", {}, `Archiv (${archived.length})`),
el("span.chev", {}, archiveOpen ? "▾" : "▸")),
archiveOpen
? el("div", {},
el("p.lead", {},
"Listen, die du weggeräumt hast oder die du länger " +
"nicht geöffnet hast. Sie sind nicht gelöscht ein " +
"Klick holt sie zurück."),
el("ul.lists", {}, archived.map(row)))
: null)
: null
);
}

View File

@@ -5,7 +5,7 @@
// Bei jeder Änderung an den Dateien unten hochzählen. Der Wert entscheidet,
// wann alte Zwischenspeicher verworfen werden.
const VERSION = "v22";
const VERSION = "v24";
const SHELL_CACHE = `einkaufsapp-shell-${VERSION}`;
const SHELL = [