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:
co-authored by
Claude Opus 5
parent
fe6327c913
commit
53a8b9e0b6
@@ -280,6 +280,11 @@ def list_all_lots(
|
|||||||
unit_name=name, unit_factor=factor,
|
unit_name=name, unit_factor=factor,
|
||||||
best_before=lot.best_before, best_before_precision=lot.best_before_precision,
|
best_before=lot.best_before, best_before_precision=lot.best_before_precision,
|
||||||
location_id=lot.location_id, created_at=lot.created_at,
|
location_id=lot.location_id, created_at=lot.created_at,
|
||||||
|
# Angebrochen: ohne diese drei Felder saehe die Liste eine offene
|
||||||
|
# Packung wie jede andere Charge - samt falscher Ablaufwarnung,
|
||||||
|
# denn gewarnt wird nach dem fruheren der beiden Daten.
|
||||||
|
opened_on=lot.opened_on, consume_by=lot.consume_by,
|
||||||
|
opened_quantity=lot.opened_quantity,
|
||||||
))
|
))
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from app.models import (
|
|||||||
Setting,
|
Setting,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.routers.stock import consume_lot, open_lot
|
from app.routers.stock import consume_lot, list_all_lots, open_lot
|
||||||
from app.schemas import LotConsume, LotOpen
|
from app.schemas import LotConsume, LotOpen
|
||||||
from app.services.stock import check_in, current_stock
|
from app.services.stock import check_in, current_stock
|
||||||
from app.services.vererbung import frist_herkunft, oeffnungsfrist
|
from app.services.vererbung import frist_herkunft, oeffnungsfrist
|
||||||
@@ -274,3 +274,26 @@ def test_kaputte_globale_einstellung_faellt_auf_den_standard(db, milch):
|
|||||||
db.add(Setting(key="open_days", value="viele"))
|
db.add(Setting(key="open_days", value="viele"))
|
||||||
db.commit()
|
db.commit()
|
||||||
assert oeffnungsfrist(db, milch) == 3
|
assert oeffnungsfrist(db, milch) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_chargenliste_zeigt_den_offenen_zustand(db, user, milch):
|
||||||
|
"""Ohne diese Felder saehe die Liste eine offene Packung wie jede andere.
|
||||||
|
|
||||||
|
Die Zeile traegt dann auch die falsche Ablaufwarnung, denn gewarnt wird nach
|
||||||
|
dem fruheren von MHD und Verbrauchsdatum.
|
||||||
|
"""
|
||||||
|
check_in(db, milch, 4000, "ml", date.today() + timedelta(days=30), None, None)
|
||||||
|
db.commit()
|
||||||
|
[charge] = db.query(Lot).all()
|
||||||
|
|
||||||
|
open_lot(charge.id, LotOpen(quantity=None), db=db, _=user)
|
||||||
|
|
||||||
|
zeilen = {z.quantity: z for z in list_all_lots(db=db, _=user)}
|
||||||
|
offen = zeilen[1000]
|
||||||
|
zu = zeilen[3000]
|
||||||
|
|
||||||
|
assert offen.opened_on == date.today()
|
||||||
|
assert offen.consume_by == date.today() + timedelta(days=3)
|
||||||
|
assert offen.opened_quantity == 1000
|
||||||
|
assert zu.opened_on is None
|
||||||
|
assert zu.consume_by is None
|
||||||
|
|||||||
@@ -283,6 +283,16 @@ export const api = {
|
|||||||
deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }),
|
deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }),
|
||||||
// Charge aufteilen: Teilmenge (Basiseinheiten) an einen anderen Lagerort abzweigen.
|
// Charge aufteilen: Teilmenge (Basiseinheiten) an einen anderen Lagerort abzweigen.
|
||||||
splitLot: (id, body) => request(`/lots/${id}/split`, { method: "POST", body }),
|
splitLot: (id, body) => request(`/lots/${id}/split`, { method: "POST", body }),
|
||||||
|
// Anbrechen: spaltet eine Packung ab und gibt ihr ein Verbrauchsdatum.
|
||||||
|
// Ohne Menge ist es eine Artikeleinheit (bzw. die ganze Charge, wenn kleiner).
|
||||||
|
openLot: (id, quantity = null) =>
|
||||||
|
request(`/lots/${id}/open`, { method: "POST", body: { quantity } }),
|
||||||
|
// Teilverbrauch. `auto_open` bricht eine noch geschlossene Charge dabei an.
|
||||||
|
consumeLot: (id, body) => request(`/lots/${id}/consume`, { method: "POST", body }),
|
||||||
|
// Die fuer DIESEN Artikel wirksamen Hausmasse samt Herkunft - Grundlage der
|
||||||
|
// Einheitenauswahl beim Verbrauchen ("2 EL" sind bei Mehl und Zucker
|
||||||
|
// verschieden viel Gramm).
|
||||||
|
productMeasures: (id) => request(`/products/${id}/measures`),
|
||||||
|
|
||||||
// Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste).
|
// Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste).
|
||||||
setProductLocationMinStock: (id, list) =>
|
setProductLocationMinStock: (id, list) =>
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../api";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
import { buildUnitOptions, fmt, gebinde, unitShort } from "../units";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teilmenge aus einer Charge verbrauchen – „ich habe 100 ml von dieser Milch
|
||||||
|
* genommen", „2 von 6 Brötchen gegessen", „2 EL Mehl".
|
||||||
|
*
|
||||||
|
* Eingetippt wird die **verbrauchte** Menge, nicht der Rest: Das ist die Zahl,
|
||||||
|
* die man kennt. War die Charge noch zu, wird sie dabei angebrochen — eine
|
||||||
|
* Packung spaltet sich ab, bekommt ihr „zu verbrauchen bis" und der Rest bleibt
|
||||||
|
* unangetastet mit seinem eigenen MHD. Die Rückfrage dazu steht unten und ist
|
||||||
|
* abwählbar, falls man nur eine Buchung nachtragen will.
|
||||||
|
*
|
||||||
|
* Die Einheitenauswahl holt neben den verwalteten Einheiten auch die Hausmaße
|
||||||
|
* der Kategorie — „EL" taucht nur auf, wenn dafür eine Menge hinterlegt ist,
|
||||||
|
* denn ein Esslöffel Mehl und ein Esslöffel Zucker sind verschieden schwer.
|
||||||
|
*/
|
||||||
|
export default function VerbrauchDialog({ lot, onClose, onDone, onError }) {
|
||||||
|
const [produkt, setProdukt] = useState(null);
|
||||||
|
const [einheiten, setEinheiten] = useState([]);
|
||||||
|
const [masse, setMasse] = useState([]);
|
||||||
|
const [menge, setMenge] = useState("");
|
||||||
|
const [einheit, setEinheit] = useState("");
|
||||||
|
const [notiz, setNotiz] = useState("");
|
||||||
|
const [anbrechen, setAnbrechen] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [laedt, setLaedt] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let abgebrochen = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [p, us, ms] = await Promise.all([
|
||||||
|
api.getProduct(lot.product_id),
|
||||||
|
api.listUnits(),
|
||||||
|
api.productMeasures(lot.product_id),
|
||||||
|
]);
|
||||||
|
if (abgebrochen) return;
|
||||||
|
setProdukt(p);
|
||||||
|
setEinheiten(buildUnitOptions(p, us));
|
||||||
|
setMasse(ms);
|
||||||
|
// Vorbelegung: die Basiseinheit. Beim Verbrauchen ist das die
|
||||||
|
// naheliegende Angabe („100 ml"), nicht die ganze Packung.
|
||||||
|
setEinheit(unitStandard(p, us));
|
||||||
|
} catch (err) {
|
||||||
|
onError?.(err.message);
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
if (!abgebrochen) setLaedt(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { abgebrochen = true; };
|
||||||
|
}, [lot.product_id]);
|
||||||
|
|
||||||
|
const zahl = Number(String(menge).trim().replace(",", "."));
|
||||||
|
const gueltig = menge !== "" && Number.isFinite(zahl) && zahl > 0;
|
||||||
|
const offen = !!lot.opened_on;
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!gueltig || busy || !einheit) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const antwort = await api.consumeLot(lot.id, {
|
||||||
|
quantity: zahl,
|
||||||
|
unit: einheit,
|
||||||
|
note: notiz.trim() || null,
|
||||||
|
auto_open: offen ? false : anbrechen,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
await onDone(antwort);
|
||||||
|
} catch (err) {
|
||||||
|
onError?.(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const basis = unitShort(lot.base_unit);
|
||||||
|
const restText = lot.package_size > 0
|
||||||
|
? `${fmt(lot.quantity / lot.package_size)} ${gebinde(lot.quantity / lot.package_size, lot.package_label)} (${fmt(lot.quantity)} ${basis})`
|
||||||
|
: `${fmt(lot.quantity)} ${basis}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal" role="dialog" aria-modal="true">
|
||||||
|
<div className="modal-head"><Icon name="checkout" size={18} /><h2>Verbrauchen</h2></div>
|
||||||
|
<p className="modal-body mt-0">
|
||||||
|
{lot.product_name}: aktuell {restText}
|
||||||
|
{offen && lot.opened_quantity
|
||||||
|
? ` — angebrochen, ursprünglich ${fmt(lot.opened_quantity)} ${basis}.`
|
||||||
|
: "."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{laedt && <p className="modal-body muted small">Einheiten werden geladen…</p>}
|
||||||
|
|
||||||
|
{!laedt && (
|
||||||
|
<div className="modal-body" style={{ display: "grid", gap: "var(--sp-3)" }}>
|
||||||
|
<div className="field-inline" style={{ marginBottom: 0 }}>
|
||||||
|
<label style={{ margin: 0 }}>
|
||||||
|
Verbrauchte Menge
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||||
|
<input type="number" step="any" min="0" value={menge} autoFocus
|
||||||
|
onChange={(e) => setMenge(e.target.value)} placeholder="z.B. 100" />
|
||||||
|
</label>
|
||||||
|
<label style={{ margin: 0 }}>
|
||||||
|
Einheit
|
||||||
|
<select value={einheit} onChange={(e) => setEinheit(e.target.value)}>
|
||||||
|
{einheiten.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
{masse.length > 0 && (
|
||||||
|
<optgroup label="Hausmaße">
|
||||||
|
{masse.map((m) => (
|
||||||
|
<option key={`m${m.measure_id}`} value={m.name}>
|
||||||
|
{m.short ? `${m.name} (${m.short})` : m.name}
|
||||||
|
{` ≈ ${fmt(m.amount)} ${unitShort(m.base)}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{masse.length === 0 && produkt && (
|
||||||
|
<p className="muted small mt-0">
|
||||||
|
Für die Kategorie dieses Artikels sind keine Hausmaße hinterlegt –
|
||||||
|
„2 EL" lässt sich deshalb noch nicht buchen. Mengen dafür stehen
|
||||||
|
unter Stammdaten ▸ Maße.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!offen && (
|
||||||
|
<label className="check-inline" style={{ margin: 0 }}>
|
||||||
|
<input type="checkbox" checked={anbrechen}
|
||||||
|
onChange={(e) => setAnbrechen(e.target.checked)} />
|
||||||
|
<span>
|
||||||
|
Packung als angebrochen führen
|
||||||
|
<span className="muted small" style={{ display: "block" }}>
|
||||||
|
Eine Packung wird abgespalten und bekommt ein „zu verbrauchen
|
||||||
|
bis"; der Rest behält sein MHD. Ohne Haken wird nur die Menge
|
||||||
|
abgebucht.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label style={{ margin: 0 }}>
|
||||||
|
Notiz (freiwillig)
|
||||||
|
<input value={notiz} maxLength={255} placeholder="z.B. für Pfannkuchen"
|
||||||
|
onChange={(e) => setNotiz(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="btn-pair">
|
||||||
|
<button type="button" className="btn" onClick={onClose}>Abbrechen</button>
|
||||||
|
<button type="button" className="btn primary" disabled={!gueltig || busy || laedt}
|
||||||
|
onClick={submit}>
|
||||||
|
<Icon name="checkout" size={16} />Verbrauchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basiseinheit des Artikels als Vorauswahl – beim Verbrauchen zählt die kleine
|
||||||
|
// Menge, nicht das Gebinde.
|
||||||
|
function unitStandard(produkt, units) {
|
||||||
|
const basis = (units || []).find(
|
||||||
|
(u) => u.factor === 1 && u.kind === produkt.kind,
|
||||||
|
);
|
||||||
|
return basis ? basis.name : (produkt.package_size ? "package" : "");
|
||||||
|
}
|
||||||
@@ -5,12 +5,13 @@ import { useAuth } from "../auth";
|
|||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import DataTable from "../components/DataTable";
|
import DataTable from "../components/DataTable";
|
||||||
import SplitLotDialog from "../components/SplitLotDialog";
|
import SplitLotDialog from "../components/SplitLotDialog";
|
||||||
|
import VerbrauchDialog from "../components/VerbrauchDialog";
|
||||||
import { ProduktThumb } from "../components/ProduktBild";
|
import { ProduktThumb } from "../components/ProduktBild";
|
||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
import { useConfirm } from "../confirm";
|
import { useConfirm } from "../confirm";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { locationOptions, locationPathById } from "../locationPath";
|
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).
|
// Artikeleinheit einer Charge (Gebinde, sonst Produkteinheit).
|
||||||
const artFactor = (l) => (l.package_size && l.package_size > 0 ? l.package_size : (l.unit_factor || 1));
|
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 [selected, setSelected] = useState(() => new Set());
|
||||||
const [visible, setVisible] = useState([]); // aktuell gefiltert sichtbare Chargen
|
const [visible, setVisible] = useState([]); // aktuell gefiltert sichtbare Chargen
|
||||||
const [splitting, setSplitting] = useState(null); // Charge, die gerade aufgeteilt wird
|
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 [teilmenge, setTeilmenge] = useState(""); // Menge für „von jeder abbuchen"
|
||||||
const [warnDays, setWarnDays] = useState(7); // Warnfrist für „bald ablaufend"
|
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 selectAll(ids) { setSelected(new Set(ids)); }
|
||||||
function clearSelection() { setSelected(new Set()); }
|
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) {
|
async function setLocation(lotId, locId) {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -181,12 +204,41 @@ export default function Charges() {
|
|||||||
render: (l) => (
|
render: (l) => (
|
||||||
<span className="badge nowrap">{l.tracking === "object" ? "Gegenstand" : "Lebensmittel"}</span>
|
<span className="badge nowrap">{l.tracking === "object" ? "Gegenstand" : "Lebensmittel"}</span>
|
||||||
) },
|
) },
|
||||||
{ key: "menge", header: "Menge", width: 150, align: "num",
|
{ key: "menge", header: "Menge", width: 170, align: "num",
|
||||||
sortValue: (l) => l.quantity / artFactor(l), render: mengeText },
|
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,
|
{ key: "mhd", header: "MHD", width: 150,
|
||||||
filterText: (l) => (l.best_before ? formatBestBefore(l.best_before, l.best_before_precision) : ""),
|
filterText: (l) => (l.best_before ? formatBestBefore(l.best_before, l.best_before_precision) : ""),
|
||||||
sortValue: (l) => l.best_before || "9999-99-99",
|
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>) },
|
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,
|
{ key: "location", header: "Lagerort", width: 240,
|
||||||
filterText: (l) => (l.location_id ? (locationPathById(l.location_id, locations) || "") : "(ohne)"),
|
filterText: (l) => (l.location_id ? (locationPathById(l.location_id, locations) || "") : "(ohne)"),
|
||||||
filterValues: (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,
|
{ key: "created", header: "Eingelagert", width: 130,
|
||||||
sortValue: (l) => new Date(l.created_at).getTime(),
|
sortValue: (l) => new Date(l.created_at).getTime(),
|
||||||
render: (l) => <span className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</span> },
|
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",
|
{ key: "split", header: "Aufteilen", label: "Aufteilen", width: 118, align: "num",
|
||||||
render: (l) => (isAdmin ? (
|
render: (l) => (isAdmin ? (
|
||||||
<button type="button" className="btn sm ghost" title="Charge aufteilen und Teil umlagern"
|
<button type="button" className="btn sm ghost" title="Charge aufteilen und Teil umlagern"
|
||||||
@@ -287,7 +354,9 @@ export default function Charges() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<DataTable id="charges" columns={columns} rows={lots} loading={loading}
|
<DataTable id="charges" columns={columns} rows={lots} loading={loading}
|
||||||
onVisibleRows={setVisible}
|
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) => {
|
onRowClick={(l, e) => {
|
||||||
// Klicks auf Bedienelemente (Checkbox, Lagerort-Dropdown, Link) nicht
|
// Klicks auf Bedienelemente (Checkbox, Lagerort-Dropdown, Link) nicht
|
||||||
// als Zeilenauswahl werten – überall sonst die Zeile umschalten.
|
// 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." />
|
getRowKey={(l) => l.id} empty="Keine Chargen im Bestand." />
|
||||||
</div>
|
</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 && (
|
{splitting && (
|
||||||
<SplitLotDialog
|
<SplitLotDialog
|
||||||
lot={splitting}
|
lot={splitting}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
--warn: #b06f14;
|
--warn: #b06f14;
|
||||||
--warn-soft: #fbf3e6;
|
--warn-soft: #fbf3e6;
|
||||||
--ok: #2e7d55;
|
--ok: #2e7d55;
|
||||||
|
/* Angebrochen ist ein Zustand, keine Warnung - deshalb ein ruhiges
|
||||||
|
Blaugrau statt Gelb. Dringlichkeit bleibt --warn/--danger. */
|
||||||
|
--open: #4a6b86;
|
||||||
|
--open-soft: #eaf0f5;
|
||||||
|
|
||||||
--radius: 7px;
|
--radius: 7px;
|
||||||
--radius-sm: 5px;
|
--radius-sm: 5px;
|
||||||
@@ -535,6 +539,7 @@ td select { width: auto; min-width: 0; max-width: 100%; }
|
|||||||
.badge.danger { background: var(--danger-soft); color: var(--danger); }
|
.badge.danger { background: var(--danger-soft); color: var(--danger); }
|
||||||
.badge.warn { background: var(--warn-soft); color: var(--warn); }
|
.badge.warn { background: var(--warn-soft); color: var(--warn); }
|
||||||
.badge.ok { background: #e6f4ec; color: var(--ok); }
|
.badge.ok { background: #e6f4ec; color: var(--ok); }
|
||||||
|
.badge.open { background: var(--open-soft); color: var(--open); }
|
||||||
|
|
||||||
/* ---------- Lists ---------- */
|
/* ---------- Lists ---------- */
|
||||||
.simple-list, .picklist, .checklist { list-style: none; padding: 0; margin: var(--sp-3) 0 0; }
|
.simple-list, .picklist, .checklist { list-style: none; padding: 0; margin: var(--sp-3) 0 0; }
|
||||||
@@ -1069,6 +1074,8 @@ select.zeitraum:hover { color: var(--text); }
|
|||||||
--warn: #e0a955;
|
--warn: #e0a955;
|
||||||
--warn-soft: #2a2312;
|
--warn-soft: #2a2312;
|
||||||
--ok: #5fbf8c;
|
--ok: #5fbf8c;
|
||||||
|
--open: #8fb3cf;
|
||||||
|
--open-soft: #17222c;
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
.badge.ok { background: #14301f; }
|
.badge.ok { background: #14301f; }
|
||||||
@@ -1079,12 +1086,14 @@ select.zeitraum:hover { color: var(--text); }
|
|||||||
--border-strong: #333c4a; --text: #e6edf3; --muted: #8b949e; --accent: #7c8ae8;
|
--border-strong: #333c4a; --text: #e6edf3; --muted: #8b949e; --accent: #7c8ae8;
|
||||||
--accent-hover: #93a0ef; --accent-soft: #1e2340; --danger: #f0857a; --danger-soft: #2a1a1a;
|
--accent-hover: #93a0ef; --accent-soft: #1e2340; --danger: #f0857a; --danger-soft: #2a1a1a;
|
||||||
--warn: #e0a955; --warn-soft: #2a2312; --ok: #5fbf8c;
|
--warn: #e0a955; --warn-soft: #2a2312; --ok: #5fbf8c;
|
||||||
|
--open: #8fb3cf; --open-soft: #17222c;
|
||||||
}
|
}
|
||||||
:root[data-theme="light"] {
|
:root[data-theme="light"] {
|
||||||
--bg: #f6f7f9; --surface: #ffffff; --surface-2: #f1f3f5; --border: #e3e6ea;
|
--bg: #f6f7f9; --surface: #ffffff; --surface-2: #f1f3f5; --border: #e3e6ea;
|
||||||
--border-strong: #d1d6dc; --text: #1a1f27; --muted: #6b7480; --accent: #3f51b5;
|
--border-strong: #d1d6dc; --text: #1a1f27; --muted: #6b7480; --accent: #3f51b5;
|
||||||
--accent-hover: #34429a; --accent-soft: #eceefb; --danger: #c0392b; --danger-soft: #fbeceb;
|
--accent-hover: #34429a; --accent-soft: #eceefb; --danger: #c0392b; --danger-soft: #fbeceb;
|
||||||
--warn: #b06f14; --warn-soft: #fbf3e6; --ok: #2e7d55;
|
--warn: #b06f14; --warn-soft: #fbf3e6; --ok: #2e7d55;
|
||||||
|
--open: #4a6b86; --open-soft: #eaf0f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Dashboards: Unterpunkte in der Seitenleiste ---- */
|
/* ---- Dashboards: Unterpunkte in der Seitenleiste ---- */
|
||||||
|
|||||||
@@ -236,6 +236,15 @@ export function relativeExpiry(daysLeft) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CSS-Zeilenklasse je MHD: rot wenn abgelaufen, gelb wenn innerhalb der Warnfrist.
|
// CSS-Zeilenklasse je MHD: rot wenn abgelaufen, gelb wenn innerhalb der Warnfrist.
|
||||||
|
// Das Datum, nach dem eine Charge draengt: MHD und "zu verbrauchen bis" stehen
|
||||||
|
// nebeneinander, gewarnt wird nach dem FRUEHEREN. Eine angebrochene Milch ist
|
||||||
|
// nicht mehr bis zum aufgedruckten Datum gut. Spiegelbild von
|
||||||
|
// models.py::Lot.faellig_am - ISO-Daten lassen sich als Text vergleichen.
|
||||||
|
export function faelligAm(lot) {
|
||||||
|
const daten = [lot?.best_before, lot?.consume_by].filter(Boolean);
|
||||||
|
return daten.length ? daten.sort()[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function expiryRowClass(dateStr, warnDays = 7) {
|
export function expiryRowClass(dateStr, warnDays = 7) {
|
||||||
const d = daysUntil(dateStr);
|
const d = daysUntil(dateStr);
|
||||||
if (d == null) return "";
|
if (d == null) return "";
|
||||||
|
|||||||
Reference in New Issue
Block a user