Die Endpunkte standen seit dem Backend-Commit, aber eintragen liess sich
nichts - die Mengen gingen nur per API rein. Damit war "2 EL Mehl" im
Verbrauchen-Dialog zwar vorgesehen, aber fuer niemanden erreichbar.
Zwei Stellen dafuer:
Stammdaten > Masse verwaltet nur die NAMEN ("Essloeffel", "Tasse"). Die
Seite ist absichtlich kurz, denn die Mengen gehoeren nicht an das Mass:
Ein Essloeffel Mehl sind etwa 10 g, einer Zucker etwa 12 g, und eine
Tasse Milch ist Milliliter. Ein globaler Wert waere bei jedem zweiten
Artikel falsch.
Kategorien > "Masse & Frist" ist deshalb der Ort, an dem die Arbeit
passiert. Beides steckt in einem Bedienfeld, weil es dieselbe Mechanik
ist: Der Wert gilt fuer die Kategorie und alles darunter, und der
naechste Vorfahr mit einem Eintrag gewinnt. Leere Felder erben - und
zeigen als Platzhalter, WAS sie erben ("erbt 5 Tage (Kategorie: Mehl &
Zucker)", "10 g" mit Abzeichen "geerbt"). Ohne das muesste man den
Kategorienbaum im Kopf verfolgen, um eine leere Eingabe zu verstehen.
Dafuer zwei kleine Ergaenzungen am Backend: CategoryOut nennt die
geerbte Frist samt Herkunft, und /categories/{id}/measures nimmt
?vererbt=true, um neben den eigenen auch die von oben geerbten Mengen zu
liefern. Die Bearbeitungsansicht ohne Flagge bleibt wie sie war.
Von Hand durchgeklickt - und dabei einen echten Fehler gefunden: Der
API-Aufruf im Browser hat das vererbt-Flag verschluckt, weil die
Funktion nur ein Argument annahm. Die Unterkategorie zeigte dadurch
leere Felder, obwohl der Server die geerbten Werte lieferte. Jetzt
stehen dort "10 g" und "3 g" mit der Quelle im Tooltip.
4 neue Tests, Suite bei 516.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
164 lines
5.6 KiB
React
164 lines
5.6 KiB
React
import { useEffect, useState } from "react";
|
||
import { api } from "../api";
|
||
import { useConfirm } from "../confirm";
|
||
import Icon from "../components/Icon";
|
||
import DataTable from "../components/DataTable";
|
||
|
||
/**
|
||
* Hausmaße – Stammdaten.
|
||
*
|
||
* Hier stehen nur die **Namen** („Esslöffel", „Tasse"). Die Mengen gehören
|
||
* nicht hierher, sondern an die Kategorie oder den Artikel: Ein Esslöffel Mehl
|
||
* sind etwa 10 g, einer Zucker etwa 12 g, und eine Tasse Milch ist Milliliter.
|
||
* Ein globaler Wert wäre bei jedem zweiten Artikel falsch.
|
||
*
|
||
* Deshalb ist diese Seite absichtlich kurz. Die Arbeit passiert unter
|
||
* Kategorien ▸ Maße & Frist.
|
||
*/
|
||
export default function Masse() {
|
||
const confirm = useConfirm();
|
||
const [masse, setMasse] = useState([]);
|
||
const [form, setForm] = useState({ name: "", short: "" });
|
||
const [error, setError] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
async function load() {
|
||
setLoading(true);
|
||
try {
|
||
setMasse(await api.listMeasures());
|
||
setError(null);
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
useEffect(() => { load(); }, []);
|
||
|
||
async function add(e) {
|
||
e.preventDefault();
|
||
setError(null);
|
||
try {
|
||
await api.createMeasure({
|
||
name: form.name.trim(),
|
||
short: form.short.trim() || null,
|
||
position: masse.length,
|
||
});
|
||
setForm({ name: "", short: "" });
|
||
load();
|
||
} catch (err) { setError(err.message); }
|
||
}
|
||
|
||
async function patch(mass, body) {
|
||
setError(null);
|
||
try {
|
||
await api.updateMeasure(mass.id, {
|
||
name: body.name ?? mass.name,
|
||
short: body.short !== undefined ? body.short : mass.short,
|
||
position: mass.position,
|
||
});
|
||
load();
|
||
} catch (err) { setError(err.message); load(); }
|
||
}
|
||
|
||
async function remove(mass) {
|
||
const ok = await confirm({
|
||
title: `Maß „${mass.name}" löschen?`,
|
||
message: "Die bei Kategorien und Artikeln hinterlegten Mengen dieses Maßes "
|
||
+ "verschwinden mit. Buchungen, die damit gemacht wurden, bleiben.",
|
||
confirmLabel: "Löschen",
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api.deleteMeasure(mass.id);
|
||
load();
|
||
} catch (err) { setError(err.message); }
|
||
}
|
||
|
||
const columns = [
|
||
{ key: "name", header: "Name", grow: true, min: 160,
|
||
filterText: (m) => m.name, sortValue: (m) => m.name,
|
||
render: (m) => (
|
||
<span className="cell-row">
|
||
<input defaultValue={m.name} style={{ marginTop: 0, minWidth: 140 }}
|
||
onBlur={(e) => {
|
||
const v = e.target.value.trim();
|
||
if (v && v !== m.name) patch(m, { name: v });
|
||
}} />
|
||
{m.is_builtin && <span className="badge nowrap">eingebaut</span>}
|
||
</span>
|
||
) },
|
||
{ key: "short", header: "Kurzform", width: 160,
|
||
filterText: (m) => m.short || "",
|
||
render: (m) => (
|
||
<input defaultValue={m.short || ""} placeholder="z.B. EL"
|
||
style={{ marginTop: 0, minWidth: 90 }}
|
||
onBlur={(e) => {
|
||
const v = e.target.value.trim();
|
||
if (v !== (m.short || "")) patch(m, { short: v || null });
|
||
}} />
|
||
) },
|
||
{ key: "actions", header: "", label: "Aktionen", fixed: true, width: 56, align: "num",
|
||
render: (m) => (
|
||
<button className="btn-icon danger" onClick={() => remove(m)} title="Löschen">
|
||
<Icon name="trash" size={16} />
|
||
</button>
|
||
) },
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<div className="page-head">
|
||
<div>
|
||
<h1>Maße</h1>
|
||
<div className="sub">
|
||
Küchenmaße zum Verbrauchen: „2 EL Mehl", „1 Tasse Milch".
|
||
Wie viel das ist, steht je Kategorie bzw. Artikel.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||
|
||
<div className="grid-2 wide-aside">
|
||
<div className="card">
|
||
<DataTable id="masse" columns={columns} rows={masse} loading={loading}
|
||
getRowKey={(m) => m.id} empty="Noch keine Maße." />
|
||
</div>
|
||
|
||
<div className="stack">
|
||
<form className="card" onSubmit={add}>
|
||
<div className="card-head"><Icon name="box" /><h2>Neues Maß</h2></div>
|
||
<label>
|
||
Name
|
||
<input placeholder="z.B. Schöpfkelle" value={form.name} required
|
||
onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||
</label>
|
||
<label>
|
||
Kurzform (freiwillig)
|
||
<input placeholder="z.B. Kelle" value={form.short} maxLength={10}
|
||
onChange={(e) => setForm({ ...form, short: e.target.value })} />
|
||
</label>
|
||
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
|
||
<p className="muted small">
|
||
Ein Name darf nicht mit einer echten Einheit zusammenfallen: Ein
|
||
Hausmaß namens „Liter" würde die Einheit verdecken, und ein
|
||
geschätzter Liter wäre etwas anderes als ein gemessener.
|
||
</p>
|
||
</form>
|
||
|
||
<div className="card">
|
||
<div className="card-head"><Icon name="alert" /><h2>Wo die Mengen stehen</h2></div>
|
||
<p className="muted small mt-0">
|
||
Unter <strong>Kategorien</strong> öffnet der Knopf „Maße & Frist"
|
||
je Kategorie die Mengen – dort trägst du ein, dass ein Esslöffel bei
|
||
Mehl 10 g sind. Unterkategorien erben das; der nächste Vorfahr mit
|
||
einem Eintrag gewinnt. Einzelne Artikel können davon abweichen.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|