Ablauf-Warnungen + Packungen-Spalte + Farbcodierung

- Backend: ProductOut.expired_count (Anzahl abgelaufener Chargen je Produkt).
- Einlagern: Inline-Warnung pro Charge, wenn MHD in der Vergangenheit liegt.
- Produkte-Liste: Badge "N abgelaufen"; neue Spalte "Packungen" (Bestand/Packungsgroesse,
  bei Stueck-Produkten = Bestand).
- Produktdetail: abgelaufene Chargen rot, Warnfrist gelb (Warnfrist aus Settings),
  Warnbanner bei abgelaufenem Bestand.
- Uebersicht: alle Ablaufzeilen gelb, abgelaufene rot; deutlicher Warnbanner.
- units.js: daysUntil/isExpired/expiryRowClass/amountText.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 11:21:14 +02:00
parent a3dbb971f7
commit d4e7ff8e3d
8 changed files with 96 additions and 14 deletions

View File

@@ -4,7 +4,7 @@ import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { guessGroup, suggestionToProduct } from "../offUtils";
import { fmt, unitOptions, unitShort } from "../units";
import { fmt, isExpired, unitOptions, unitShort } from "../units";
const emptyLine = () => ({ quantity: "", best_before: "" });
@@ -251,7 +251,11 @@ export default function CheckIn() {
<label className="grow" style={{ margin: 0 }}>
{i === 0 && <span className="line-label">MHD (optional)</span>}
<input type="date" value={line.best_before}
onChange={(e) => setLine(i, "best_before", e.target.value)} />
onChange={(e) => setLine(i, "best_before", e.target.value)}
className={isExpired(line.best_before) ? "input-danger" : ""} />
{isExpired(line.best_before) && (
<span className="hint-danger"><Icon name="alert" size={12} />bereits abgelaufen</span>
)}
</label>
<button type="button" className="btn-icon danger" title="Charge entfernen"
onClick={() => removeLine(i)} disabled={lines.length === 1}>

View File

@@ -45,6 +45,12 @@ export default function Dashboard() {
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{overdue > 0 && (
<div className="alert error"><Icon name="alert" size={16} />
{overdue} Charge(n) im Lager sind bereits abgelaufen bitte aussortieren.
</div>
)}
<div className="stat-row">
<div className="stat">
<div className="stat-label"><Icon name="clock" size={14} />Bald ablaufend</div>
@@ -75,7 +81,7 @@ export default function Dashboard() {
</thead>
<tbody>
{expiring.map((it) => (
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : it.days_left <= 2 ? "row-warn" : ""}>
<tr key={it.lot_id} className={it.days_left < 0 ? "row-danger" : "row-warn"}>
<td>{it.product_name}</td>
<td className="num">{fmt(it.quantity)} {unitShort(it.base_unit)}</td>
<td>{it.best_before}</td>

View File

@@ -4,7 +4,7 @@ import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { guessGroup } from "../offUtils";
import { BASE_UNITS, fmt, unitShort } from "../units";
import { BASE_UNITS, daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units";
const EMPTY = {
barcode: "", name: "", brand: "", image_url: "",
@@ -27,6 +27,7 @@ export default function ProductForm() {
const [busy, setBusy] = useState(false);
// Einheit, in der der Mindestbestand eingegeben wird: "base" oder "package".
const [minUnit, setMinUnit] = useState("base");
const [warnDays, setWarnDays] = useState(7);
function set(k, v) {
setForm((f) => ({ ...f, [k]: v }));
@@ -86,6 +87,11 @@ export default function ProductForm() {
try {
const gs = await api.listGroups();
setGroups(gs);
try {
const s = await api.listSettings();
const row = s.find((x) => x.key === "expiry_warning_days");
if (row) setWarnDays(parseInt(row.value, 10) || 7);
} catch { /* Einstellungen optional */ }
if (!isNew) {
const p = await api.getProduct(id);
setProduct(p);
@@ -259,18 +265,31 @@ export default function ProductForm() {
{!isNew && (
<section className="card">
<div className="card-head"><Icon name="box" /><h2>Chargen im Bestand</h2></div>
{lots.some((l) => isExpired(l.best_before)) && (
<div className="alert error"><Icon name="alert" size={16} />
Dieses Produkt hat abgelaufene Chargen im Bestand.
</div>
)}
{form.image_url && <img className="product-img" src={form.image_url} alt="" />}
<div className="table-wrap">
<table className="table">
<thead><tr><th className="num">Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead>
<tbody>
{lots.map((l) => (
<tr key={l.id}>
<td className="num">{fmt(l.quantity)} {unitLabel}</td>
<td>{l.best_before || ""}</td>
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
</tr>
))}
{lots.map((l) => {
const cls = expiryRowClass(l.best_before, warnDays);
const dLeft = daysUntil(l.best_before);
return (
<tr key={l.id} className={cls}>
<td className="num">{fmt(l.quantity)} {unitLabel}</td>
<td>
{l.best_before || ""}
{cls === "row-danger" && <span className="badge danger" style={{ marginLeft: 6 }}>abgelaufen</span>}
{cls === "row-warn" && <span className="badge warn" style={{ marginLeft: 6 }}>{dLeft}d</span>}
</td>
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
</tr>
);
})}
{lots.length === 0 && <tr><td colSpan={3} className="empty">Keine Chargen im Bestand.</td></tr>}
</tbody>
</table>

View File

@@ -60,6 +60,7 @@ export default function Products() {
<th>Marke</th>
<th>Einheit</th>
<th className="num">Bestand</th>
<th className="num">Packungen</th>
<th className="num">Mindest</th>
<th></th>
</tr>
@@ -74,20 +75,32 @@ export default function Products() {
? <img className="thumb" src={p.image_url} alt="" />
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
</td>
<td className="strong">{p.name}</td>
<td className="strong">
{p.name}
{p.expired_count > 0 && (
<span className="badge danger" style={{ marginLeft: 6 }}>
<Icon name="alert" size={12} />{p.expired_count} abgelaufen
</span>
)}
</td>
<td className="muted">{p.brand || ""}</td>
<td>{unitShort(p.base_unit)}{p.package_size ? ` · Pkg ${fmt(p.package_size)}` : ""}</td>
<td className="num">
{fmt(p.stock)} {unitShort(p.base_unit)}
{low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>}
</td>
<td className="num">
{p.package_size
? `${fmt(p.stock / p.package_size)} Pkg`
: `${fmt(p.stock)} ${unitShort(p.base_unit)}`}
</td>
<td className="num muted">{p.min_stock != null ? fmt(p.min_stock) : ""}</td>
<td className="num"><Link to={`/products/${p.id}`}>Details</Link></td>
</tr>
);
})}
{products.length === 0 && (
<tr><td colSpan={7} className="empty">Keine Produkte gefunden.</td></tr>
<tr><td colSpan={8} className="empty">Keine Produkte gefunden.</td></tr>
)}
</tbody>
</table>