neue Archivfunktion und Optimierung an Artikelbearbeitung
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user