Die Lagerort-Auswahl schrieb in jeder Zeile den ganzen Pfad aus
("Hedingen → Guido Zimmer → Kallax → Fach Oben Links → Oben"). Bei einem
tiefen Baum ist das eine Wand aus Wiederholungen, und Geschwister stehen
nur zufaellig beieinander.
Jetzt dasselbe Auswahlfeld wie bei den Kategorien: eingerueckter Baum,
Aeste ein- und ausklappbar. Geschlossen steht weiter der volle Pfad da -
"Oben" allein waere nicht eindeutig, genau dafuer war der Pfad gedacht.
CategorySelect konnte das schon und war auf Lagerort-Codes vorbereitet
(seine IDs duerfen Zeichenketten sein). Dazu kam nur LocationSelect als
duenne Huelle: Baumaufbau und die Umrechnung "" <-> null, damit die 16
Aufrufstellen weiter mit dem leeren String fuer "kein Ort" arbeiten.
Zwei Kleinigkeiten dabei: CategorySelect bekommt showRoot, weil bei den
Mindestbestaenden "nichts gewaehlt" kein gueltiger Wert ist - dort heisst
die oberste Zeile "Ueberall". Und LocationSelect sortiert Geschwister
selbst nach Namen; der Server sortiert zeichenweise, dort stuende "Fach
oben Rechts" hinter "Fach Unten Rechts".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
469 lines
21 KiB
React
469 lines
21 KiB
React
import { useEffect, useMemo, useState } from "react";
|
||
import { api } from "../api";
|
||
import { useAuth } from "../auth";
|
||
import { useConfirm } from "../confirm";
|
||
import Icon from "../components/Icon";
|
||
import { locationOptions, locationPathById } from "../locationPath";
|
||
import LocationSelect from "../components/LocationSelect";
|
||
import { gruppenOptionen } from "../groupGraph";
|
||
import { anzahlWort, fmt, gebinde, grpFactor, grpPkgMode, grpUnit, kindShort } from "../units";
|
||
|
||
// Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm,
|
||
// Milliliter, Stück) – NICHT die Packung. „1 Packung" ist nichtssagend, weil eine
|
||
// Packung 500 g, 1 kg oder 25 ml sein kann; deshalb rechnen wir alles in die
|
||
// echte Einheit um. dispFactor = Basiseinheiten je Anzeigeeinheit.
|
||
const dispFactor = (p) => (p.unit_factor && p.unit_factor > 0 ? p.unit_factor : 1);
|
||
const dispLabel = (p) => (p.unit_name || "Stück");
|
||
// Mindestbestand gibt es nur bei Charge-Artikeln: Lebensmittel und
|
||
// Verbrauchsgegenstände (bulk). „Menge je Lagerort" und Einzelstücke nicht.
|
||
const canHaveMin = (p) => p.tracking !== "object" || p.bulk === true;
|
||
// Gebinde-Umschaltung: hat das Produkt eine Packungsgröße, lässt sich der
|
||
// Mindestbestand wahlweise in Packungen (Glas/Dose) statt in g/ml erfassen.
|
||
const hasPkg = (p) => !!(p.package_size && p.package_size > 0);
|
||
const pkgMode = (p) => hasPkg(p) && !!p.min_stock_in_packages;
|
||
// Basiseinheiten je gewählter Erfassungseinheit (Packung ODER Anzeigeeinheit).
|
||
const effFactor = (p) => (pkgMode(p) ? p.package_size : dispFactor(p));
|
||
// Einheitslabel der gewählten Erfassungseinheit, passend zur Menge (1 Glas / 3 Gläser).
|
||
const effUnit = (p, menge = 1) => (pkgMode(p) ? gebinde(menge, p.package_label || "Packung") : dispLabel(p));
|
||
|
||
// Gruppen rechnen in ihrer verwalteten Einheit ODER im Gruppen-Gebinde –
|
||
// grpPkgMode/grpFactor/grpUnit stehen in units.js, weil Groups.jsx sie genauso
|
||
// braucht (und ohne sie den Bestand falsch beschriftet hat).
|
||
|
||
// „Überall" ist der Ort NULL. Im <select> braucht es einen Wert, deshalb ein
|
||
// Platzhalter, der beim Speichern wieder zu null wird.
|
||
const UEBERALL = "__ueberall__";
|
||
const zuId = (wert) => (wert === UEBERALL || wert === "" ? null : wert);
|
||
const vonId = (id) => (id == null ? UEBERALL : String(id));
|
||
const gleicherOrt = (a, b) => vonId(a) === vonId(b);
|
||
|
||
/**
|
||
* Mindestbestände – nach LAGERORT gruppiert.
|
||
*
|
||
* Ein Mindestbestand ist immer ein Bedarf an einem Ort. „Überall" (egal wo,
|
||
* Hauptsache im Haus) ist dabei ein Ort wie jeder andere, nämlich der oberste:
|
||
* Käufe für einen einzelnen Lagerort decken ihn mit ab. Den früheren separaten
|
||
* „Gesamt"-Wert gibt es deshalb nicht mehr.
|
||
*
|
||
* Alle Mengen kommen vom Server in Basiseinheiten und werden hier in die
|
||
* Erfassungseinheit des Ziels umgerechnet – eine einzige Umrechnung statt der
|
||
* drei verschiedenen, die es vorher gab.
|
||
*/
|
||
const EMPTY_DRAFT = { locId: UEBERALL, kind: "product", targetId: "", menge: "", pkg: false };
|
||
|
||
export default function MinStock() {
|
||
const { isAdmin } = useAuth();
|
||
const confirm = useConfirm();
|
||
const [products, setProducts] = useState([]);
|
||
const [groups, setGroups] = useState([]);
|
||
const [locations, setLocations] = useState([]);
|
||
const [error, setError] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
// „+ Mindestbestand": Ort steht meist schon fest (Abschnitt), dann fehlen nur
|
||
// noch Ziel und Menge.
|
||
const [draft, setDraft] = useState(null);
|
||
// Gruppen-Gebinde festlegen/ändern: { id, size, label, baseShort }.
|
||
const [gebindeDlg, setGebindeDlg] = useState(null);
|
||
|
||
async function load() {
|
||
setLoading(true);
|
||
try {
|
||
const [ps, gs, ls] = await Promise.all([
|
||
api.listProducts("", ""), api.listGroups(), api.listLocations(),
|
||
]);
|
||
setProducts(ps); setGroups(gs); setLocations(ls);
|
||
} catch (err) { setError(err.message); } finally { setLoading(false); }
|
||
}
|
||
useEffect(() => { load(); }, []);
|
||
|
||
// Eine Zeile je vorhandenem Eintrag – keine Leerzeilen mehr für jeden Artikel.
|
||
const zeilen = useMemo(() => {
|
||
const out = [];
|
||
const bauen = (kind, entity, name, faktor, einheit) => {
|
||
for (const e of entity.location_min_stocks || []) {
|
||
const soll = e.min_stock / faktor;
|
||
out.push({
|
||
key: `${kind}:${entity.id}:${vonId(e.location_id)}`,
|
||
kind, entity, name, locId: e.location_id,
|
||
soll, unit: einheit(soll),
|
||
bestand: e.stock != null ? e.stock / faktor : null,
|
||
bestandUnit: einheit(e.stock != null ? e.stock / faktor : 1),
|
||
});
|
||
}
|
||
};
|
||
for (const p of products.filter(canHaveMin)) {
|
||
bauen("product", p, p.name, effFactor(p), (m) => effUnit(p, m));
|
||
}
|
||
for (const g of groups) {
|
||
bauen("group", g, g.name, grpFactor(g), (m) => grpUnit(g, m));
|
||
}
|
||
return out;
|
||
}, [products, groups]);
|
||
|
||
// Abschnitte: „Überall" zuerst, danach die Lagerorte alphabetisch nach Pfad.
|
||
const abschnitte = useMemo(() => {
|
||
const orte = [{ id: UEBERALL, label: "Überall (egal wo)" }, ...locationOptions(locations)];
|
||
const belegt = new Set(zeilen.map((r) => vonId(r.locId)));
|
||
return orte
|
||
.filter((o) => belegt.has(String(o.id)) || o.id === UEBERALL)
|
||
.map((o) => ({
|
||
...o,
|
||
zeilen: zeilen
|
||
.filter((r) => vonId(r.locId) === String(o.id))
|
||
.sort((a, b) => a.name.localeCompare(b.name, "de")),
|
||
}));
|
||
}, [zeilen, locations]);
|
||
|
||
// Eine Bedarfsliste mit einem geänderten/neuen/entfernten Eintrag neu bauen.
|
||
// Der Endpunkt ersetzt die komplette Liste – neue Orte müssen also ergänzt
|
||
// (nicht nur bestehende geändert) werden, sonst wird nie einer angelegt.
|
||
const mergeLoc = (list, locId, menge) => {
|
||
const out = (list || []).map((e) => ({ location_id: e.location_id, min_stock: e.min_stock }));
|
||
const treffer = out.find((e) => gleicherOrt(e.location_id, locId));
|
||
if (treffer) treffer.min_stock = menge;
|
||
else out.push({ location_id: zuId(vonId(locId)), min_stock: menge });
|
||
return out.filter((e) => e.min_stock != null && e.min_stock > 0);
|
||
};
|
||
|
||
const faktorVon = (row) => (row.kind === "product" ? effFactor(row.entity) : grpFactor(row.entity));
|
||
|
||
async function speichern(kind, entity, locId, mengeBase) {
|
||
const liste = mergeLoc(entity.location_min_stocks, locId, mengeBase);
|
||
if (kind === "product") await api.setProductLocationMinStock(entity.id, liste);
|
||
else await api.setGroupLocationMinStock(entity.id, liste);
|
||
await load();
|
||
}
|
||
|
||
async function saveSoll(row, value) {
|
||
const roh = String(value).trim().replace(",", ".");
|
||
const num = roh === "" ? null : Number(roh);
|
||
if (num !== null && (Number.isNaN(num) || num < 0)) return;
|
||
if (num === (row.soll ?? null)) return;
|
||
setError(null);
|
||
try {
|
||
await speichern(row.kind, row.entity, row.locId, num == null ? null : num * faktorVon(row));
|
||
} catch (err) { setError(err.message); }
|
||
}
|
||
|
||
// Einheit umschalten (Anzeigeeinheit ↔ Packung). Der gespeicherte Wert bleibt
|
||
// physisch gleich – er steht in Basiseinheiten, es wechselt nur die Anzeige.
|
||
async function toggleProductUnit(p, wantPkg) {
|
||
if (wantPkg === pkgMode(p)) return;
|
||
setError(null);
|
||
try {
|
||
await api.updateProduct(p.id, { min_stock_in_packages: wantPkg });
|
||
await load();
|
||
} catch (err) { setError(err.message); }
|
||
}
|
||
|
||
async function toggleGroupPkg(g, wantPkg) {
|
||
if (wantPkg === grpPkgMode(g)) return;
|
||
if (wantPkg && !(g.package_size > 0)) { openGebinde(g); return; }
|
||
setError(null);
|
||
try {
|
||
await api.updateGroup(g.id, { min_stock_in_packages: wantPkg });
|
||
await load();
|
||
} catch (err) { setError(err.message); }
|
||
}
|
||
|
||
function openGebinde(g) {
|
||
setGebindeDlg({
|
||
id: g.id, size: g.package_size ?? "", label: g.package_label ?? "",
|
||
baseShort: kindShort(g.kind),
|
||
});
|
||
}
|
||
|
||
async function saveGebinde() {
|
||
if (!gebindeDlg) return;
|
||
const size = Number(String(gebindeDlg.size).trim().replace(",", "."));
|
||
if (!size || size <= 0) { setError("Bitte eine Gebinde-Größe größer 0 angeben."); return; }
|
||
setError(null);
|
||
try {
|
||
await api.updateGroup(gebindeDlg.id, {
|
||
package_size: size,
|
||
package_label: (gebindeDlg.label || "Packung").trim(),
|
||
min_stock_in_packages: true,
|
||
});
|
||
setGebindeDlg(null);
|
||
await load();
|
||
} catch (err) { setError(err.message); }
|
||
}
|
||
|
||
async function deleteRow(row) {
|
||
const ziel = row.kind === "group" ? `Gruppe „${row.name}“` : `„${row.name}“`;
|
||
const ort = row.locId == null ? "„Überall“" : `am Lagerort „${locationPathById(row.locId, locations)}“`;
|
||
if (!(await confirm({
|
||
title: "Mindestbestand löschen?",
|
||
message: `Mindestbestand von ${ziel} ${ort} entfernen?`,
|
||
confirmLabel: "Löschen", danger: true,
|
||
}))) return;
|
||
setError(null);
|
||
try {
|
||
await speichern(row.kind, row.entity, row.locId, null);
|
||
} catch (err) { setError(err.message); }
|
||
}
|
||
|
||
// Einen Eintrag an einen anderen Ort verschieben. Der Wert bleibt (beides in
|
||
// Basiseinheiten), der alte Ort fällt weg – so entsteht keine Dublette.
|
||
async function moveRow(row, zielOrt) {
|
||
if (gleicherOrt(row.locId, zielOrt)) return;
|
||
const belegt = (row.entity.location_min_stocks || [])
|
||
.some((e) => gleicherOrt(e.location_id, zielOrt));
|
||
if (belegt) {
|
||
const ok = await confirm({
|
||
title: "Ort schon belegt",
|
||
message: `Für „${row.name}“ gibt es dort bereits einen Mindestbestand. Mit dem verschobenen Wert überschreiben?`,
|
||
confirmLabel: "Überschreiben", danger: true,
|
||
});
|
||
if (!ok) return;
|
||
}
|
||
setError(null);
|
||
try {
|
||
const base = row.soll * faktorVon(row);
|
||
let liste = mergeLoc(row.entity.location_min_stocks, row.locId, null);
|
||
liste = mergeLoc(liste, zielOrt, base);
|
||
if (row.kind === "product") await api.setProductLocationMinStock(row.entity.id, liste);
|
||
else await api.setGroupLocationMinStock(row.entity.id, liste);
|
||
await load();
|
||
} catch (err) { setError(err.message); }
|
||
}
|
||
|
||
// ---- Hinzufügen ----
|
||
|
||
const draftTarget = !draft ? null : (draft.kind === "product"
|
||
? products.find((p) => String(p.id) === String(draft.targetId))
|
||
: groups.find((g) => String(g.id) === String(draft.targetId)));
|
||
const draftUsePkg = !!draft && draft.kind === "product" && !!draftTarget && hasPkg(draftTarget) && draft.pkg;
|
||
const draftUnit = !draftTarget ? "" : (draft.kind === "product"
|
||
? (draftUsePkg ? (draftTarget.package_label || "Packung") : dispLabel(draftTarget))
|
||
: (grpUnit(draftTarget) || "Stück"));
|
||
|
||
async function saveNew() {
|
||
setError(null);
|
||
const roh = String(draft.menge).trim().replace(",", ".");
|
||
const num = roh === "" ? null : Number(roh);
|
||
if (!draft.targetId) { setError("Bitte ein Ziel wählen."); return; }
|
||
if (num == null || Number.isNaN(num) || num <= 0) { setError("Bitte eine Menge größer 0 angeben."); return; }
|
||
try {
|
||
const faktor = draft.kind === "product"
|
||
? (draftUsePkg ? draftTarget.package_size : dispFactor(draftTarget))
|
||
: grpFactor(draftTarget);
|
||
await speichern(draft.kind, draftTarget, zuId(draft.locId), num * faktor);
|
||
setDraft(null);
|
||
} catch (err) { setError(err.message); }
|
||
}
|
||
|
||
const zielListe = !draft ? [] : (draft.kind === "product"
|
||
? products.filter(canHaveMin).slice().sort((a, b) => a.name.localeCompare(b.name, "de"))
|
||
: groups.slice().sort((a, b) => a.name.localeCompare(b.name, "de")));
|
||
|
||
function zeile(r) {
|
||
const g = r.kind === "group" ? r.entity : null;
|
||
return (
|
||
<div className="min-zeile" key={r.key}>
|
||
<span className="name">
|
||
<span className="strong">{r.name}</span>
|
||
{g && <span className="badge accent" style={{ marginLeft: 6 }}>Gruppe</span>}
|
||
{g?.child_ids?.length > 0 && (
|
||
<span className="badge" style={{ marginLeft: 6 }}
|
||
title="Bestand und Mindestbestand zählen die Untergruppen mit.">
|
||
inkl. {anzahlWort(g.child_ids.length, "Untergruppe", "Untergruppen")}
|
||
</span>
|
||
)}
|
||
</span>
|
||
|
||
{isAdmin ? (
|
||
<input type="number" step="any" min="0"
|
||
key={r.soll ?? "none"} defaultValue={r.soll ?? ""}
|
||
onBlur={(e) => saveSoll(r, e.target.value)} />
|
||
) : <span>{fmt(r.soll)}</span>}
|
||
|
||
{/* Einheit: bei Packungsartikeln und Gruppen mit Einheit umschaltbar. */}
|
||
{isAdmin && r.kind === "product" && hasPkg(r.entity) ? (
|
||
<select value={pkgMode(r.entity) ? "pkg" : "disp"}
|
||
title="Einheit für den Mindestbestand umschalten"
|
||
onChange={(e) => toggleProductUnit(r.entity, e.target.value === "pkg")}>
|
||
<option value="disp">{dispLabel(r.entity)}</option>
|
||
<option value="pkg">{r.entity.package_label || "Packung"}</option>
|
||
</select>
|
||
) : isAdmin && g && g.min_stock_unit_id ? (
|
||
<select value={grpPkgMode(g) ? "pkg" : "unit"}
|
||
title="Einheit für den Mindestbestand umschalten"
|
||
onChange={(e) => {
|
||
const v = e.target.value;
|
||
if (v === "unit") toggleGroupPkg(g, false);
|
||
else if (v === "pkg") toggleGroupPkg(g, true);
|
||
else openGebinde(g);
|
||
}}>
|
||
<option value="unit">{g.min_stock_unit_name || "Einheit"}</option>
|
||
{g.package_size > 0 && <option value="pkg">{g.package_label || "Packung"}</option>}
|
||
<option value="edit">{g.package_size > 0 ? "Gebinde ändern…" : "Gebinde festlegen…"}</option>
|
||
</select>
|
||
) : (
|
||
<span className="muted small">{r.unit || "–"}</span>
|
||
)}
|
||
|
||
<span className="muted small bestand">
|
||
{r.bestand != null ? `Bestand ${fmt(r.bestand)} ${r.bestandUnit || ""}` : ""}
|
||
</span>
|
||
|
||
{isAdmin && (
|
||
<>
|
||
<LocationSelect value={vonId(r.locId)} locations={locations}
|
||
title="Ort ändern – der Mindestbestand wird verschoben"
|
||
showRoot={false} extraOptions={[{ value: UEBERALL, label: "Überall" }]}
|
||
onChange={(id) => moveRow(r, id)} />
|
||
<button type="button" className="btn-icon danger" title="Mindestbestand löschen"
|
||
onClick={() => deleteRow(r)}><Icon name="trash" size={15} /></button>
|
||
</>
|
||
)}
|
||
{/* Ohne Adminrechte bleiben die letzten beiden Spalten leer, damit die
|
||
Zeilen trotzdem fluchten. */}
|
||
{!isAdmin && (<><span /><span /></>)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div className="page-head">
|
||
<div>
|
||
<h1>Mindestbestände</h1>
|
||
<div className="sub">
|
||
Was soll wo immer da sein? „Überall“ heißt: egal wo, Hauptsache im Haus —
|
||
Käufe für einen Lagerort decken ihn mit ab.
|
||
</div>
|
||
</div>
|
||
{isAdmin && (
|
||
<button className="btn primary" onClick={() => setDraft(draft ? null : { ...EMPTY_DRAFT })}>
|
||
<Icon name={draft ? "close" : "plus"} size={16} />
|
||
{draft ? "Abbrechen" : "Mindestbestand"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||
|
||
{isAdmin && draft && (
|
||
<div className="card" style={{ marginBottom: "var(--sp-3)" }}>
|
||
<div className="card-head"><Icon name="plus" /><h2>Mindestbestand hinzufügen</h2></div>
|
||
<div className="row">
|
||
<label className="grow" style={{ maxWidth: 360 }}>
|
||
Wo
|
||
<LocationSelect value={draft.locId} locations={locations}
|
||
showRoot={false} extraOptions={[{ value: UEBERALL, label: "Überall (egal wo)" }]}
|
||
onChange={(id) => setDraft({ ...draft, locId: id })} />
|
||
</label>
|
||
<label className="seg-label">
|
||
Was
|
||
<div className="segmented">
|
||
<button type="button" className={draft.kind === "product" ? "active" : ""}
|
||
onClick={() => setDraft({ ...draft, kind: "product", targetId: "" })}>Artikel</button>
|
||
<button type="button" className={draft.kind === "group" ? "active" : ""}
|
||
onClick={() => setDraft({ ...draft, kind: "group", targetId: "" })}>Gruppe</button>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
<div className="row">
|
||
<label className="grow">
|
||
{draft.kind === "product" ? "Lebensmittel / Verbrauchsgegenstand" : "Gruppe"}
|
||
<select value={draft.targetId} onChange={(e) => setDraft({ ...draft, targetId: e.target.value })}>
|
||
<option value="">– wählen –</option>
|
||
{/* Gruppen als Baum, damit sichtbar ist, was unter was hängt. */}
|
||
{draft.kind === "group"
|
||
? gruppenOptionen(groups).map((o) => (
|
||
<option key={o.key} value={o.id}>
|
||
{o.label}
|
||
{o.gruppe.child_ids?.length
|
||
? ` (inkl. ${anzahlWort(o.gruppe.child_ids.length, "Untergruppe", "Untergruppen")})` : ""}
|
||
</option>
|
||
))
|
||
: zielListe.map((t) => (
|
||
<option key={t.id} value={t.id}>
|
||
{t.name}{t.brand ? ` · ${t.brand}` : ""}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
{draft.kind === "product" && draftTarget && hasPkg(draftTarget) && (
|
||
<div className="row">
|
||
<label className="seg-label">
|
||
Einheit
|
||
<div className="segmented">
|
||
<button type="button" className={!draft.pkg ? "active" : ""}
|
||
onClick={() => setDraft({ ...draft, pkg: false })}>{dispLabel(draftTarget)}</button>
|
||
<button type="button" className={draft.pkg ? "active" : ""}
|
||
onClick={() => setDraft({ ...draft, pkg: true })}>{draftTarget.package_label || "Packung"}</button>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
)}
|
||
<div className="row">
|
||
<label className="grow" style={{ maxWidth: 300 }}>
|
||
Menge{draftUnit ? ` (in ${draftUnit})` : ""}
|
||
<input type="number" step="any" min="0" value={draft.menge}
|
||
onChange={(e) => setDraft({ ...draft, menge: e.target.value })} placeholder="z.B. 2" />
|
||
</label>
|
||
</div>
|
||
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
|
||
<button className="btn primary" onClick={saveNew}>Speichern</button>
|
||
<button className="btn" onClick={() => setDraft(null)}>Abbrechen</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isAdmin && gebindeDlg && (
|
||
<div className="card" style={{ marginBottom: "var(--sp-3)" }}>
|
||
<div className="card-head"><Icon name="package" /><h2>Gruppen-Gebinde festlegen</h2></div>
|
||
<p className="muted small" style={{ marginTop: 0 }}>
|
||
Wie viel zählt „1 Packung“ dieser Gruppe? Ein Richtwert – die Produkte
|
||
der Gruppe dürfen unterschiedlich große Packungen haben.
|
||
</p>
|
||
<div className="row">
|
||
<label style={{ maxWidth: 200 }}>
|
||
Bezeichnung
|
||
<input value={gebindeDlg.label} placeholder="z.B. Glas"
|
||
onChange={(e) => setGebindeDlg({ ...gebindeDlg, label: e.target.value })} />
|
||
</label>
|
||
<label style={{ maxWidth: 220 }}>
|
||
Größe{gebindeDlg.baseShort ? ` (in ${gebindeDlg.baseShort})` : ""}
|
||
<input type="number" step="any" min="0" value={gebindeDlg.size} placeholder="z.B. 150"
|
||
onChange={(e) => setGebindeDlg({ ...gebindeDlg, size: e.target.value })} />
|
||
</label>
|
||
</div>
|
||
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
|
||
<button className="btn primary" onClick={saveGebinde}>Speichern</button>
|
||
<button className="btn" onClick={() => setGebindeDlg(null)}>Abbrechen</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{loading && <div className="card"><p className="muted">Wird geladen…</p></div>}
|
||
|
||
{!loading && abschnitte.map((a) => (
|
||
<section className="card" key={a.id} style={{ marginBottom: "var(--sp-3)" }}>
|
||
<div className="card-head">
|
||
<Icon name="location" />
|
||
<h2>{a.label}</h2>
|
||
<span className="muted small">{a.zeilen.length} {a.zeilen.length === 1 ? "Eintrag" : "Einträge"}</span>
|
||
</div>
|
||
<div className="stack">
|
||
{a.zeilen.length === 0 && (
|
||
<p className="muted small mt-0">Hier ist noch nichts hinterlegt.</p>
|
||
)}
|
||
{a.zeilen.map(zeile)}
|
||
{isAdmin && (
|
||
<div className="field-inline" style={{ marginBottom: 0 }}>
|
||
<button type="button" className="btn"
|
||
onClick={() => setDraft({ ...EMPTY_DRAFT, locId: String(a.id) })}>
|
||
<Icon name="plus" size={16} />hinzufügen
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|