Web: Verbrauchsgegenstand als dritte Gegenstands-Art + Typ-Position + Thumbnail

ProductForm: Typ/Kategorie an feste Position (nach Marke, vor den typ-
spezifischen Feldern) - der Umschalter springt nicht mehr je nach Auswahl.
Gegenstaende bekommen eine 3-Wege-Verwaltung: Menge je Lagerort /
Einzelstuecke / Verbrauchsgegenstand. Verbrauchsgegenstaende werden wie
Lebensmittel als Charge (Einheit, Packung, MHD, Mindestbestand mit Einheit,
Gruppe, Chargen-Ansicht) gefuehrt (foodLike = kein Gegenstand ODER bulk).
Menge-Gegenstaende koennen jetzt ebenfalls einer Gruppe zugeordnet werden.

Thumbnail: .thumb display:block, damit die Zeilenhoehe beim Nachladen nicht
mehr minimal springt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 20:39:15 +02:00
parent 6c870584dd
commit 4087277bb0
2 changed files with 207 additions and 146 deletions

View File

@@ -23,7 +23,7 @@ import {
const EMPTY = { const EMPTY = {
barcode: "", name: "", brand: "", image_url: "", barcode: "", name: "", brand: "", image_url: "",
unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "", unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "",
group_id: "", category_id: "", shop_id: "", product_url: "", individual: false, group_id: "", category_id: "", shop_id: "", product_url: "", individual: false, bulk: false,
}; };
// (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes) // (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes)
@@ -108,6 +108,12 @@ export default function ProductForm() {
// Der Umschalter bestimmt die Verwaltungsart; die Kategorieliste ist darauf // Der Umschalter bestimmt die Verwaltungsart; die Kategorieliste ist darauf
// gefiltert, also passt die gewählte Kategorie immer zum Modus. // gefiltert, also passt die gewählte Kategorie immer zum Modus.
const isObject = modus === "object"; const isObject = modus === "object";
// Verwaltungsart eines Gegenstands: Menge je Lagerort (count), Einzelstücke
// (individual) oder Verbrauchsgegenstand (bulk wie ein Lebensmittel als Charge).
const objMode = form.individual ? "individual" : form.bulk ? "bulk" : "count";
// foodLike nutzt die Lebensmittel-Pfade (Chargen, Einheit, MHD, Mindestbestand):
// gilt für echte Lebensmittel UND Verbrauchsgegenstände.
const foodLike = !isObject || form.bulk;
// Gegenstände gleicht das Backend gegen Open Products Facts ab, Lebensmittel // Gegenstände gleicht das Backend gegen Open Products Facts ab, Lebensmittel
// gegen Open Food Facts die Beschriftung folgt dem Modus. // gegen Open Food Facts die Beschriftung folgt dem Modus.
const datenquelle = isObject ? "Open Products Facts" : "Open Food Facts"; const datenquelle = isObject ? "Open Products Facts" : "Open Food Facts";
@@ -126,6 +132,11 @@ export default function ProductForm() {
} }
} }
// Verwaltungsart eines Gegenstands setzen (individual/bulk schließen sich aus).
function setObjMode(mode) {
setForm((f) => ({ ...f, individual: mode === "individual", bulk: mode === "bulk" }));
}
function setField(fieldId, value) { function setField(fieldId, value) {
setFieldValues((v) => ({ ...v, [fieldId]: value })); setFieldValues((v) => ({ ...v, [fieldId]: value }));
} }
@@ -390,6 +401,7 @@ export default function ProductForm() {
shop_id: p.shop_id ?? "", shop_id: p.shop_id ?? "",
product_url: p.product_url || "", product_url: p.product_url || "",
individual: Boolean(p.individual), individual: Boolean(p.individual),
bulk: Boolean(p.bulk),
}); });
setFieldValues(p.field_values || {}); setFieldValues(p.field_values || {});
setModus(p.tracking === "object" ? "object" : "food"); setModus(p.tracking === "object" ? "object" : "food");
@@ -437,35 +449,44 @@ export default function ProductForm() {
shop_id: form.shop_id === "" ? null : Number(form.shop_id), shop_id: form.shop_id === "" ? null : Number(form.shop_id),
product_url: form.product_url.trim() || null, product_url: form.product_url.trim() || null,
}; };
if (isObject) { const minStock = {
// Gegenstände: keine Lebensmittel-Felder (Einheit/Packung/MHD/Gruppe).
// Nur die geltenden eigenen Felder mitschicken Werte fremder Kategorien
// (nach einem Kategoriewechsel) fallen dabei weg.
const erlaubt = new Set(effFields.map((f) => String(f.id)));
const fv = {};
Object.entries(fieldValues).forEach(([k, v]) => {
if (erlaubt.has(String(k))) fv[k] = v;
});
// Auch Gegenstände dürfen einen Mindestbestand haben (Verbrauchsmaterial):
// gleiche Umrechnung wie bei Lebensmitteln, für Stück-Artikel = Stückzahl.
return {
...base, individual: form.individual, field_values: fv,
min_stock: minBase, min_stock: minBase,
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel), min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
min_stock_in_packages: sel === "package", min_stock_in_packages: sel === "package",
}; };
} // Gegenstände: nur die geltenden eigenen Felder mitschicken Werte fremder
// Kategorien (nach einem Kategoriewechsel) fallen dabei weg.
const erlaubt = new Set(effFields.map((f) => String(f.id)));
const fieldPayload = {};
Object.entries(fieldValues).forEach(([k, v]) => {
if (erlaubt.has(String(k))) fieldPayload[k] = v;
});
if (foodLike) {
// Lebensmittel ODER Verbrauchsgegenstand: als Charge mit Einheit/Packung/
// MHD/Mindestbestand/Gruppe geführt. bulk nur bei Gegenständen.
return { return {
...base, ...base,
individual: false, individual: false,
bulk: isObject,
unit_id: form.unit_id === "" ? null : Number(form.unit_id), unit_id: form.unit_id === "" ? null : Number(form.unit_id),
package_size: form.package_size === "" ? null : Number(form.package_size), package_size: form.package_size === "" ? null : Number(form.package_size),
package_label: form.package_label.trim() || null, package_label: form.package_label.trim() || null,
date_precision: form.date_precision || "day", date_precision: form.date_precision || "day",
min_stock: minBase,
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
min_stock_in_packages: sel === "package",
group_id: form.group_id === "" ? null : Number(form.group_id), group_id: form.group_id === "" ? null : Number(form.group_id),
...minStock,
...(isObject ? { field_values: fieldPayload } : {}),
};
}
// Gegenstand ohne Charge: Menge je Lagerort oder Einzelstücke. Mindestbestand
// in Stück (kein Einheit-Dropdown), gleiche Umrechnung liefert die Stückzahl.
// Menge-Gegenstände dürfen (wie Verbrauchsgegenstände) in eine Gruppe.
return {
...base,
individual: form.individual,
bulk: false,
group_id: form.group_id === "" ? null : Number(form.group_id),
field_values: fieldPayload,
...minStock,
}; };
} }
@@ -604,7 +625,100 @@ export default function ProductForm() {
onSchliessen={() => setOffDaten(null)} onSchliessen={() => setOffDaten(null)}
/> />
)} )}
{!isObject && (<> {/* Typ und Kategorie an fester Position VOR den typ-spezifischen
Feldern, damit der Umschalter nicht je nach Auswahl springt. */}
<div className="row">
<label className="seg-label">
Typ
<div className="segmented">
<button type="button" className={modus === "food" ? "active" : ""}
onClick={() => changeModus("food")} disabled={readOnly}>
Lebensmittel
</button>
<button type="button" className={modus === "object" ? "active" : ""}
onClick={() => changeModus("object")} disabled={readOnly}>
Gegenstand
</button>
</div>
</label>
</div>
<div className="row">
<label className="grow" style={{ maxWidth: 460 }}>
<span className="tip" title="Nur Kategorien des gewählten Typs. Gegenstands-Kategorien zeigen Menge je Lagerort, Lebensmittel MHD/Chargen.">
Kategorie
</span>
<CategorySelect
value={form.category_id === "" ? null : Number(form.category_id)}
nodes={asTree(categories).filter((c) => c.tracking === modus)}
rootLabel=" keine "
disabled={readOnly}
onChange={(id) => set("category_id", id == null ? "" : String(id))}
/>
</label>
{isAdmin && (
<div style={{ display: "flex", alignItems: "flex-end" }}>
<button type="button" className="btn" style={{ marginBottom: 2 }}
onClick={() => (newCat ? setNewCat(null) : openNewCat())}>
<Icon name="plus" size={16} />Neue Kategorie
</button>
</div>
)}
</div>
{isAdmin && newCat && (
<div className="card-sub" style={{ marginBottom: "var(--sp-3)" }}>
<div className="card-head"><Icon name="tag" />
<h3>Neue {modus === "object" ? "Gegenstands" : "Lebensmittel"}-Kategorie</h3>
<button type="button" className="btn-icon" style={{ marginLeft: "auto" }}
onClick={() => setNewCat(null)}><Icon name="close" size={16} /></button>
</div>
<div className="row">
<label className="grow">Name
<input value={newCat.name} placeholder="z.B. Powerbank"
onChange={(e) => setNewCat({ ...newCat, name: e.target.value })} />
</label>
<label className="grow">Untergeordnet (optional)
<CategorySelect
value={newCat.parent_id === "" ? null : Number(newCat.parent_id)}
nodes={asTree(categories).filter((c) => c.tracking === modus)}
rootLabel=" oberste Ebene "
onChange={(id) => setNewCat({ ...newCat, parent_id: id == null ? "" : String(id) })}
/>
</label>
</div>
<button type="button" className="btn primary" onClick={createCategoryInline}>
Anlegen &amp; auswählen
</button>
</div>
)}
{/* Verwaltungsart nur für Gegenstände: Menge je Lagerort / Einzelstücke /
Verbrauchsgegenstand (= wie ein Lebensmittel als Charge geführt). */}
{isObject && (
<div className="row">
<label className="grow seg-label">
Verwaltung
<div className="segmented seg-wrap">
<button type="button" className={objMode === "count" ? "active" : ""}
onClick={() => setObjMode("count")} disabled={readOnly}>Menge je Lagerort</button>
<button type="button" className={objMode === "individual" ? "active" : ""}
onClick={() => setObjMode("individual")} disabled={readOnly}>Einzelstücke</button>
<button type="button" className={objMode === "bulk" ? "active" : ""}
onClick={() => setObjMode("bulk")} disabled={readOnly}>Verbrauchsgegenstand</button>
</div>
</label>
</div>
)}
{isObject && objMode === "bulk" && (
<p className="muted small mt-0" style={{ marginBottom: "var(--sp-2)" }}>
Wird wie ein Lebensmittel als Charge mit Menge und Einheit geführt
(z.B. Sonnencreme in ml) nur ohne eindeutigen Code.
</p>
)}
{/* Lebensmittel ODER Verbrauchsgegenstand: Einheit, Packung, MHD,
Mindestbestand (mit Einheit), Gruppe, Mindestbestand je Lagerort. */}
{foodLike && (<>
<div className="row"> <div className="row">
<label className="grow"> <label className="grow">
Einheit Einheit
@@ -709,86 +823,13 @@ export default function ProductForm() {
</div> </div>
)} )}
</>)} </>)}
<div className="row">
<label className="seg-label">
Typ
<div className="segmented">
<button type="button" className={modus === "food" ? "active" : ""}
onClick={() => changeModus("food")} disabled={readOnly}>
Lebensmittel
</button>
<button type="button" className={modus === "object" ? "active" : ""}
onClick={() => changeModus("object")} disabled={readOnly}>
Gegenstand
</button>
</div>
</label>
</div>
<div className="row">
<label className="grow" style={{ maxWidth: 460 }}>
<span className="tip" title="Nur Kategorien des gewählten Typs. Gegenstands-Kategorien zeigen Menge je Lagerort, Lebensmittel MHD/Chargen.">
Kategorie
</span>
<CategorySelect
value={form.category_id === "" ? null : Number(form.category_id)}
nodes={asTree(categories).filter((c) => c.tracking === modus)}
rootLabel=" keine "
disabled={readOnly}
onChange={(id) => set("category_id", id == null ? "" : String(id))}
/>
</label>
{isAdmin && (
<div style={{ display: "flex", alignItems: "flex-end" }}>
<button type="button" className="btn" style={{ marginBottom: 2 }}
onClick={() => (newCat ? setNewCat(null) : openNewCat())}>
<Icon name="plus" size={16} />Neue Kategorie
</button>
</div>
)}
</div>
{isAdmin && newCat && (
<div className="card-sub" style={{ marginBottom: "var(--sp-3)" }}>
<div className="card-head"><Icon name="tag" />
<h3>Neue {modus === "object" ? "Gegenstands" : "Lebensmittel"}-Kategorie</h3>
<button type="button" className="btn-icon" style={{ marginLeft: "auto" }}
onClick={() => setNewCat(null)}><Icon name="close" size={16} /></button>
</div>
<div className="row">
<label className="grow">Name
<input value={newCat.name} placeholder="z.B. Powerbank"
onChange={(e) => setNewCat({ ...newCat, name: e.target.value })} />
</label>
<label className="grow">Untergeordnet (optional)
<CategorySelect
value={newCat.parent_id === "" ? null : Number(newCat.parent_id)}
nodes={asTree(categories).filter((c) => c.tracking === modus)}
rootLabel=" oberste Ebene "
onChange={(id) => setNewCat({ ...newCat, parent_id: id == null ? "" : String(id) })}
/>
</label>
</div>
<button type="button" className="btn primary" onClick={createCategoryInline}>
Anlegen &amp; auswählen
</button>
</div>
)}
{/* Gegenstand-spezifische Zusätze: Bezugsquelle/Link und die
Stück-Mindestbestände (nur Menge je Lagerort / Einzelstücke). */}
{isObject && (<> {isObject && (<>
{objMode !== "bulk" && (
<div className="row"> <div className="row">
<label className="grow seg-label"> {objMode === "count" && (
Verwaltung
<div className="segmented">
<button type="button" className={!form.individual ? "active" : ""}
onClick={() => set("individual", false)} disabled={readOnly}>Menge je Lagerort</button>
<button type="button" className={form.individual ? "active" : ""}
onClick={() => set("individual", true)} disabled={readOnly}>Einzelstücke</button>
</div>
</label>
<div className="grow" />
</div>
<div className="row">
{!form.individual && (
<label className="grow"> <label className="grow">
Gekauft bei Gekauft bei
<select value={form.shop_id} onChange={(e) => set("shop_id", e.target.value)} <select value={form.shop_id} onChange={(e) => set("shop_id", e.target.value)}
@@ -810,6 +851,8 @@ export default function ProductForm() {
</div> </div>
</label> </label>
</div> </div>
)}
{objMode !== "bulk" && (
<div className="row"> <div className="row">
<label className="grow" style={{ maxWidth: 300 }}> <label className="grow" style={{ maxWidth: 300 }}>
Mindestbestand (Stück) Mindestbestand (Stück)
@@ -817,8 +860,21 @@ export default function ProductForm() {
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly}
placeholder="z.B. 3" /> placeholder="z.B. 3" />
</label> </label>
{objMode === "count" && (
<label className="grow" style={{ maxWidth: 300 }}>
<span className="tip" title="Zählt Bestände mehrerer Gegenstände zusammen (in Stück).">
Gruppe
</span>
<select value={form.group_id} onChange={(e) => set("group_id", e.target.value)}
disabled={readOnly}>
<option value=""> keine </option>
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</label>
)}
</div> </div>
{!isNew && product && isAdmin && ( )}
{objMode !== "bulk" && !isNew && product && isAdmin && (
<div style={{ marginTop: "var(--sp-2)", marginBottom: "var(--sp-4)" }}> <div style={{ marginTop: "var(--sp-2)", marginBottom: "var(--sp-4)" }}>
<div className="card-head" style={{ marginBottom: "var(--sp-1)" }}> <div className="card-head" style={{ marginBottom: "var(--sp-1)" }}>
<Icon name="location" size={16} /> <Icon name="location" size={16} />
@@ -911,7 +967,22 @@ export default function ProductForm() {
{readOnly && <p className="muted small mt-0">Nur Administratoren können Produkte bearbeiten.</p>} {readOnly && <p className="muted small mt-0">Nur Administratoren können Produkte bearbeiten.</p>}
</form> </form>
{!isNew && (isObject ? ( {!isNew && (foodLike ? (
// Lebensmittel und Verbrauchsgegenstände: Chargen (Menge, MHD, FEFO).
<LotsCard
product={product}
lots={lots}
baseShort={baseShort}
warnDays={warnDays}
isAdmin={isAdmin}
onChanged={async () => {
setLots(await api.listLots(id));
setProduct(await api.getProduct(id));
}}
onError={setError}
/>
) : (
// Gegenstand als Menge je Lagerort (Einzelstücke stehen unten).
product && !form.individual && ( product && !form.individual && (
<ObjektBestand <ObjektBestand
product={product} product={product}
@@ -925,19 +996,6 @@ export default function ProductForm() {
onError={setError} onError={setError}
/> />
) )
) : (
<LotsCard
product={product}
lots={lots}
baseShort={baseShort}
warnDays={warnDays}
isAdmin={isAdmin}
onChanged={async () => {
setLots(await api.listLots(id));
setProduct(await api.getProduct(id));
}}
onError={setError}
/>
))} ))}
</div> </div>

View File

@@ -397,6 +397,7 @@ td select { width: auto; min-width: 0; max-width: 100%; }
.strong { font-weight: 640; } .strong { font-weight: 640; }
.thumb-cell { width: 44px; } .thumb-cell { width: 44px; }
.thumb { .thumb {
display: block; /* sonst hebt die Inline-Grundlinie die Zeile beim Nachladen minimal */
width: 34px; height: 34px; object-fit: cover; width: 34px; height: 34px; object-fit: cover;
border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface-2); border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface-2);
} }
@@ -1007,6 +1008,8 @@ tr.row-active { background: var(--surface-2); }
direkt daneben geklebt. */ direkt daneben geklebt. */
.seg-label { display: flex; align-items: center; gap: var(--sp-3); } .seg-label { display: flex; align-items: center; gap: var(--sp-3); }
.seg-label .segmented { margin-top: 0; } .seg-label .segmented { margin-top: 0; }
/* Dreifach-Umschalter (Verwaltung) darf bei Platznot umbrechen. */
.segmented.seg-wrap { flex-wrap: wrap; }
/* Artikelfoto mit Aufnahme-/Galerie-Knöpfen */ /* Artikelfoto mit Aufnahme-/Galerie-Knöpfen */
.produkt-foto { display: flex; flex-direction: column; gap: var(--sp-2); align-items: center; } .produkt-foto { display: flex; flex-direction: column; gap: var(--sp-2); align-items: center; }