Anbrechen und Teilverbrauch bekommen Knoepfe

Das Backend konnte das seit dem letzten Commit, aber ohne Oberflaeche kam
niemand dran. Jetzt traegt jede Charge zwei Knoepfe:

"Anbrechen" spaltet eine Packung ab, gibt ihr ein "zu verbrauchen bis" und
laesst den Rest ungeoeffnet mit seinem eigenen MHD - mit Rueckfrage, weil
sich die abgespaltene Packung danach anders verhaelt.

"Verbrauchen" nimmt die VERBRAUCHTE Menge ("100 ml genommen", "2 von 6
Broetchen"), nicht den Rest - das ist die Zahl, die man kennt. War die
Charge noch zu, wird sie dabei angebrochen; der Haken dafuer ist
abwaehlbar, falls man nur eine Buchung nachtraegt. Die Einheitenauswahl
holt die Hausmasse der Kategorie dazu, deshalb laesst sich "2 EL" buchen
und landet bei Mehl als 20 g im Bestand.

Die Liste zeigt den Zustand jetzt auch: Spalte "Zustand" (angebrochen),
Spalte "Verbrauchen bis", und bei einer offenen Packung die Restmenge in
Basiseinheiten statt im Gebinde - "980 g von 1.000 g" sagt etwas,
"0,98 Packungen" nicht.

Gewarnt wird nach dem FRUEHEREN von MHD und Verbrauchsdatum (units.js::
faelligAm, Spiegelbild von Lot.faellig_am): Eine angebrochene Milch ist
nicht mehr bis zum aufgedruckten Datum gut. Dafuer ein eigenes Farb-Token
--open in ruhigem Blaugrau - angebrochen ist ein Zustand, keine Warnung.

Dazu ein Fehler, der die ganze Anzeige wertlos gemacht haette: In
list_all_lots fehlten opened_on, consume_by und opened_quantity. Die
Liste haette eine offene Packung wie jede andere Charge gezeigt, samt
falscher Ablaufwarnung. Test dagegen.

Von Hand durchgeklickt: 4x1l Milch anbrechen ergibt 1 l offen mit
Verbrauchsdatum 5.10. und 3 l zu mit MHD 15.11.; "2 EL" aus dem Mehl
ergibt 980 g offen und 1 Packung zu. Suite bei 495.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-02 14:17:20 +02:00
co-authored by Claude Opus 5
parent fe6327c913
commit 53a8b9e0b6
7 changed files with 325 additions and 5 deletions
+91 -4
View File
@@ -5,12 +5,13 @@ import { useAuth } from "../auth";
import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
import SplitLotDialog from "../components/SplitLotDialog";
import VerbrauchDialog from "../components/VerbrauchDialog";
import { ProduktThumb } from "../components/ProduktBild";
import { useSettings } from "../settings";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import { locationOptions, locationPathById } from "../locationPath";
import { expiryRowClass, fmt, gebinde, unitShort } from "../units";
import { expiryRowClass, faelligAm, fmt, gebinde, unitShort } from "../units";
// Artikeleinheit einer Charge (Gebinde, sonst Produkteinheit).
const artFactor = (l) => (l.package_size && l.package_size > 0 ? l.package_size : (l.unit_factor || 1));
@@ -40,6 +41,7 @@ export default function Charges() {
const [selected, setSelected] = useState(() => new Set());
const [visible, setVisible] = useState([]); // aktuell gefiltert sichtbare Chargen
const [splitting, setSplitting] = useState(null); // Charge, die gerade aufgeteilt wird
const [verbrauchen, setVerbrauchen] = useState(null); // Charge, aus der verbraucht wird
const [teilmenge, setTeilmenge] = useState(""); // Menge für „von jeder abbuchen"
const [warnDays, setWarnDays] = useState(7); // Warnfrist für „bald ablaufend"
@@ -76,6 +78,27 @@ export default function Charges() {
function selectAll(ids) { setSelected(new Set(ids)); }
function clearSelection() { setSelected(new Set()); }
// Anbrechen spaltet eine Packung ab; der Rest bleibt zu und behaelt sein MHD.
// Mit Rueckfrage, weil die abgespaltene Packung ein Verbrauchsdatum bekommt
// und sich damit anders verhaelt als vorher.
async function anbrechen(lot) {
const ok = await confirm({
title: "Eine Packung anbrechen?",
message: 'Eine Packung wird als eigene, offene Charge geführt und bekommt '
+ 'ein „zu verbrauchen bis". Der Rest bleibt ungeöffnet mit seinem MHD.',
confirmLabel: "Anbrechen",
});
if (!ok) return;
setError(null);
try {
const offen = await api.openLot(lot.id);
await load();
toast(offen.consume_by
? `Angebrochen – zu verbrauchen bis ${new Date(offen.consume_by).toLocaleDateString("de-DE")}.`
: "Angebrochen.");
} catch (err) { setError(err.message); }
}
async function setLocation(lotId, locId) {
setError(null);
try {
@@ -181,12 +204,41 @@ export default function Charges() {
render: (l) => (
<span className="badge nowrap">{l.tracking === "object" ? "Gegenstand" : "Lebensmittel"}</span>
) },
{ key: "menge", header: "Menge", width: 150, align: "num",
sortValue: (l) => l.quantity / artFactor(l), render: mengeText },
{ key: "menge", header: "Menge", width: 170, align: "num",
sortValue: (l) => l.quantity / artFactor(l),
// Bei einer offenen Packung zaehlt die Restmenge, nicht das Gebinde:
// "980 g von 1.000 g" sagt etwas, "0,98 Packungen" nicht.
render: (l) => (
<span className="cell-row">
<span>
{l.opened_on
? `${fmt(l.quantity)} ${unitShort(l.base_unit)}`
: mengeText(l)}
</span>
{l.opened_on && (
<span className="muted small nowrap">
offen{l.opened_quantity ? ` · von ${fmt(l.opened_quantity)} ${unitShort(l.base_unit)}` : ""}
</span>
)}
</span>
) },
{ key: "offen", header: "Zustand", width: 120,
filterText: (l) => (l.opened_on ? "angebrochen" : "ungeöffnet"),
filterValues: (l) => [l.opened_on ? "angebrochen" : "ungeöffnet"],
sortValue: (l) => (l.opened_on ? 0 : 1),
render: (l) => (l.opened_on
? <span className="badge open nowrap">angebrochen</span>
: <span className="muted">–</span>) },
{ key: "mhd", header: "MHD", width: 150,
filterText: (l) => (l.best_before ? formatBestBefore(l.best_before, l.best_before_precision) : ""),
sortValue: (l) => l.best_before || "9999-99-99",
render: (l) => (l.best_before ? formatBestBefore(l.best_before, l.best_before_precision) : <span className="muted">–</span>) },
{ key: "consume", header: "Verbrauchen bis", width: 150,
filterText: (l) => (l.consume_by ? new Date(l.consume_by).toLocaleDateString("de-DE") : ""),
sortValue: (l) => l.consume_by || "9999-99-99",
render: (l) => (l.consume_by
? <span className="nowrap">{new Date(l.consume_by).toLocaleDateString("de-DE")}</span>
: <span className="muted">–</span>) },
{ key: "location", header: "Lagerort", width: 240,
filterText: (l) => (l.location_id ? (locationPathById(l.location_id, locations) || "") : "(ohne)"),
filterValues: (l) => [l.location_id ? (locationPathById(l.location_id, locations) || "?") : "(ohne)"],
@@ -202,6 +254,21 @@ export default function Charges() {
{ key: "created", header: "Eingelagert", width: 130,
sortValue: (l) => new Date(l.created_at).getTime(),
render: (l) => <span className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</span> },
{ key: "verbrauch", header: "Verbrauch", label: "Verbrauch", width: 190, align: "num",
render: (l) => (
<span className="cell-row">
<button type="button" className="btn sm ghost" title="Teilmenge verbrauchen"
onClick={() => setVerbrauchen(l)}>
<Icon name="checkout" size={15} />Verbrauchen
</button>
{!l.opened_on && (
<button type="button" className="btn sm ghost" title="Eine Packung anbrechen"
onClick={() => anbrechen(l)}>
<Icon name="package" size={15} />Anbrechen
</button>
)}
</span>
) },
{ key: "split", header: "Aufteilen", label: "Aufteilen", width: 118, align: "num",
render: (l) => (isAdmin ? (
<button type="button" className="btn sm ghost" title="Charge aufteilen und Teil umlagern"
@@ -287,7 +354,9 @@ export default function Charges() {
<div className="card">
<DataTable id="charges" columns={columns} rows={lots} loading={loading}
onVisibleRows={setVisible}
rowClassName={(l) => [expiryRowClass(l.best_before, warnDays), selected.has(l.id) ? "row-active" : ""].filter(Boolean).join(" ")}
// Gewarnt wird nach dem fruheren von MHD und Verbrauchsdatum: Eine
// angebrochene Milch ist nicht mehr bis zum aufgedruckten Datum gut.
rowClassName={(l) => [expiryRowClass(faelligAm(l), warnDays), selected.has(l.id) ? "row-active" : ""].filter(Boolean).join(" ")}
onRowClick={(l, e) => {
// Klicks auf Bedienelemente (Checkbox, Lagerort-Dropdown, Link) nicht
// als Zeilenauswahl werten – überall sonst die Zeile umschalten.
@@ -296,6 +365,24 @@ export default function Charges() {
getRowKey={(l) => l.id} empty="Keine Chargen im Bestand." />
</div>
{verbrauchen && (
<VerbrauchDialog
lot={verbrauchen}
onClose={() => setVerbrauchen(null)}
onDone={async (antwort) => {
await load();
if (antwort?.opened) {
toast(antwort.lot?.consume_by
? `Verbraucht und angebrochen – zu verbrauchen bis ${new Date(antwort.lot.consume_by).toLocaleDateString("de-DE")}.`
: "Verbraucht und angebrochen.");
} else {
toast(antwort?.lot ? "Verbraucht." : "Verbraucht – die Charge ist damit leer.");
}
}}
onError={setError}
/>
)}
{splitting && (
<SplitLotDialog
lot={splitting}