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

@@ -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"))
);
}