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:
@@ -23,7 +23,7 @@ import {
|
||||
const EMPTY = {
|
||||
barcode: "", name: "", brand: "", image_url: "",
|
||||
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)
|
||||
@@ -108,6 +108,12 @@ export default function ProductForm() {
|
||||
// Der Umschalter bestimmt die Verwaltungsart; die Kategorieliste ist darauf
|
||||
// gefiltert, also passt die gewählte Kategorie immer zum Modus.
|
||||
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
|
||||
// gegen Open Food Facts – die Beschriftung folgt dem Modus.
|
||||
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) {
|
||||
setFieldValues((v) => ({ ...v, [fieldId]: value }));
|
||||
}
|
||||
@@ -390,6 +401,7 @@ export default function ProductForm() {
|
||||
shop_id: p.shop_id ?? "",
|
||||
product_url: p.product_url || "",
|
||||
individual: Boolean(p.individual),
|
||||
bulk: Boolean(p.bulk),
|
||||
});
|
||||
setFieldValues(p.field_values || {});
|
||||
setModus(p.tracking === "object" ? "object" : "food");
|
||||
@@ -437,35 +449,44 @@ export default function ProductForm() {
|
||||
shop_id: form.shop_id === "" ? null : Number(form.shop_id),
|
||||
product_url: form.product_url.trim() || null,
|
||||
};
|
||||
if (isObject) {
|
||||
// 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_unit_id: sel === "package" || sel === "" ? null : Number(sel),
|
||||
min_stock_in_packages: sel === "package",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
individual: false,
|
||||
unit_id: form.unit_id === "" ? null : Number(form.unit_id),
|
||||
package_size: form.package_size === "" ? null : Number(form.package_size),
|
||||
package_label: form.package_label.trim() || null,
|
||||
date_precision: form.date_precision || "day",
|
||||
const minStock = {
|
||||
min_stock: minBase,
|
||||
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
|
||||
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 {
|
||||
...base,
|
||||
individual: false,
|
||||
bulk: isObject,
|
||||
unit_id: form.unit_id === "" ? null : Number(form.unit_id),
|
||||
package_size: form.package_size === "" ? null : Number(form.package_size),
|
||||
package_label: form.package_label.trim() || null,
|
||||
date_precision: form.date_precision || "day",
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
{!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 & 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">
|
||||
<label className="grow">
|
||||
Einheit
|
||||
@@ -709,116 +823,58 @@ export default function ProductForm() {
|
||||
</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>
|
||||
|
||||
{/* Gegenstand-spezifische Zusätze: Bezugsquelle/Link und die
|
||||
Stück-Mindestbestände (nur Menge je Lagerort / Einzelstücke). */}
|
||||
{isObject && (<>
|
||||
{objMode !== "bulk" && (
|
||||
<div className="row">
|
||||
{objMode === "count" && (
|
||||
<label className="grow">
|
||||
Gekauft bei
|
||||
<select value={form.shop_id} onChange={(e) => set("shop_id", e.target.value)}
|
||||
disabled={readOnly}>
|
||||
<option value="">– unbekannt / mehrere –</option>
|
||||
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="grow">
|
||||
Produktlink
|
||||
<div className="field-inline">
|
||||
<input type="url" value={form.product_url} placeholder="https://…"
|
||||
onChange={(e) => set("product_url", e.target.value)} disabled={readOnly} />
|
||||
{form.product_url && (
|
||||
<a className="btn" href={form.product_url} target="_blank" rel="noreferrer"
|
||||
title="Im Onlineshop öffnen">Öffnen</a>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</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>
|
||||
{objMode !== "bulk" && (
|
||||
<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 className="grow" style={{ maxWidth: 300 }}>
|
||||
Mindestbestand (Stück)
|
||||
<input type="number" step="any" min="0" value={form.min_stock}
|
||||
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly}
|
||||
placeholder="z.B. 3" />
|
||||
</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>
|
||||
<button type="button" className="btn primary" onClick={createCategoryInline}>
|
||||
Anlegen & auswählen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isObject && (<>
|
||||
<div className="row">
|
||||
<label className="grow seg-label">
|
||||
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">
|
||||
Gekauft bei
|
||||
<select value={form.shop_id} onChange={(e) => set("shop_id", e.target.value)}
|
||||
disabled={readOnly}>
|
||||
<option value="">– unbekannt / mehrere –</option>
|
||||
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="grow">
|
||||
Produktlink
|
||||
<div className="field-inline">
|
||||
<input type="url" value={form.product_url} placeholder="https://…"
|
||||
onChange={(e) => set("product_url", e.target.value)} disabled={readOnly} />
|
||||
{form.product_url && (
|
||||
<a className="btn" href={form.product_url} target="_blank" rel="noreferrer"
|
||||
title="Im Onlineshop öffnen">Öffnen</a>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="grow" style={{ maxWidth: 300 }}>
|
||||
Mindestbestand (Stück)
|
||||
<input type="number" step="any" min="0" value={form.min_stock}
|
||||
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly}
|
||||
placeholder="z.B. 3" />
|
||||
</label>
|
||||
</div>
|
||||
{!isNew && product && isAdmin && (
|
||||
)}
|
||||
{objMode !== "bulk" && !isNew && product && isAdmin && (
|
||||
<div style={{ marginTop: "var(--sp-2)", marginBottom: "var(--sp-4)" }}>
|
||||
<div className="card-head" style={{ marginBottom: "var(--sp-1)" }}>
|
||||
<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>}
|
||||
</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 && (
|
||||
<ObjektBestand
|
||||
product={product}
|
||||
@@ -925,19 +996,6 @@ export default function ProductForm() {
|
||||
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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user