Web: Obergruppen zuordnen, Mindestbestaende nach Lagerort

Mindestbestaende-Seite ist jetzt nach Lagerort gegliedert statt nach Artikel -
"Ueberall (egal wo)" zuerst, danach die Lagerorte. Es gibt keine automatische
Leerzeile je Artikel mehr; angelegt wird ueber "+ hinzufuegen" im jeweiligen
Abschnitt, dann steht der Ort schon fest und es fehlen nur Ziel und Menge.
Das Zeilenmodell faellt damit von vier Faellen auf zwei, und weil der Server
einheitlich Basiseinheiten liefert, bleibt genau eine Umrechnung uebrig statt
der drei gegenlaeufigen von vorher.

Gruppen: neue Spalte "Obergruppen" mit Mehrfachauswahl (GroupParentSelect).
Bewusst kein Baum - CategorySelect und die tree-Prop der DataTable koennen nur
einen Elternteil und wuerden eine Gruppe mit zwei Obergruppen doppelt anzeigen.
Die eigene Gruppe und ihre Untergruppen sind in der Auswahl gesperrt, damit die
Ringregel sichtbar wird, bevor der Server sie ablehnt. Produktzahl zeigt
transitiv und direkt ("12 (3 direkt)"), dazu Badges fuer Untergruppen und eine
Warnung, wenn eine Gruppe mit Untergruppen keine Einheit hat - sonst summiert
sie Gramm und Stueck zu einer sinnlosen Zahl.

Einkaufsliste: "Gesamt" heisst "Ueberall", Gruppen zeigen ihre mitgezaehlten
Untergruppen, und eine Fussnote benennt die eine Stelle, die sich nicht
verrechnen laesst (zwei Obergruppen mit gemeinsamer Untergruppe).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-15 23:56:18 +02:00
parent 54261b98c0
commit 0537a2b53f
9 changed files with 603 additions and 357 deletions

View File

@@ -820,24 +820,31 @@ export default function ProductForm() {
</label>
</div>
<div className="row">
<label className="grow">
Mindestbestand
<div className="field-inline">
<input type="number" step="any" value={form.min_stock}
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" />
<select value={minUnit || form.unit_id} onChange={(e) => changeMinUnit(e.target.value)}
disabled={readOnly} style={{ marginTop: 0 }}>
{units
.filter((u) => !selectedUnit || u.kind === selectedUnit.kind)
.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
{form.package_size && (
<option value="package">
{form.package_label.trim() || "Packung"} à {fmt(form.package_size)} {baseShort}
</option>
)}
</select>
</div>
</label>
{/* Beim Anlegen gibt es den Artikel noch nicht, also auch keine
Ort-Liste hier bleibt das eine Feld, das als „Überall“ landet.
Beim Bearbeiten steht darunter die vollständige Ort-Liste. */}
{isNew && (
<label className="grow">
<span className="tip" title="Gilt „überall“ egal wo im Haus. Weitere Orte lassen sich nach dem Anlegen ergänzen.">
Mindestbestand (überall)
</span>
<div className="field-inline">
<input type="number" step="any" value={form.min_stock}
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" />
<select value={minUnit || form.unit_id} onChange={(e) => changeMinUnit(e.target.value)}
disabled={readOnly} style={{ marginTop: 0 }}>
{units
.filter((u) => !selectedUnit || u.kind === selectedUnit.kind)
.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
{form.package_size && (
<option value="package">
{form.package_label.trim() || "Packung"} à {fmt(form.package_size)} {baseShort}
</option>
)}
</select>
</div>
</label>
)}
<label className="grow">
<span className="tip" title="Zählt Bestände mehrerer Artikel zusammen. Der EAN-Code dieses Artikels erscheint danach automatisch bei der Gruppe.">
Gruppe
@@ -853,25 +860,27 @@ export default function ProductForm() {
<div style={{ marginTop: "var(--sp-2)", marginBottom: "var(--sp-5)" }}>
<div className="card-head" style={{ marginBottom: "var(--sp-1)" }}>
<Icon name="location" size={16} />
<h3 style={{ margin: 0 }}>Mindestbestand je Lagerort</h3>
<h3 style={{ margin: 0 }}>Mindestbestand</h3>
</div>
<p className="muted small mt-0">
Zusätzlich zum Gesamt-Mindestbestand: eigener Bedarf je Lagerort (z.B.
Ferienhaus, Zuhause). Menge in {product.unit_name || "Stück"}.
Wird separat gespeichert.
Was soll wo immer da sein? Überall heißt: egal wo, Hauptsache im
Haus Käufe für einen Lagerort decken ihn mit ab. Menge in{" "}
{product.unit_name || "Stück"}. Wird sofort gespeichert.
</p>
<LocationMinStock
locations={locations}
// Der Server liefert Basiseinheiten; hier in der Anzeigeeinheit
// des Artikels erfassen (g/ml/Stück), wie die Beschriftung sagt.
initial={(product.location_min_stocks || []).map(
(e) => ({ ...e, min_stock: e.min_stock * artToDisp(product) }))}
(e) => ({ ...e, min_stock: e.min_stock / (product.unit_factor || 1) }))}
unitLabel={product.unit_name || "Stück"}
onError={(m) => toast(m, "warn")}
onSave={async (list) => {
const f = artToDisp(product);
const artList = list.map((e) => ({ ...e, min_stock: e.min_stock / f }));
const updated = await api.setProductLocationMinStock(product.id, artList);
const f = product.unit_factor || 1;
const basisListe = list.map((e) => ({ ...e, min_stock: e.min_stock * f }));
const updated = await api.setProductLocationMinStock(product.id, basisListe);
setProduct(updated);
toast("Bedarfe je Lagerort gespeichert.");
toast("Mindestbestände gespeichert.");
}}
/>
</div>