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

@@ -2,10 +2,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import date
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import Product from .models import Lot, Product
from .schemas import ProductOut from .schemas import ProductOut
from .services.stock import current_stock from .services.stock import current_stock
@@ -13,6 +15,16 @@ from .services.stock import current_stock
def product_to_out(db: Session, product: Product) -> ProductOut: def product_to_out(db: Session, product: Product) -> ProductOut:
out = ProductOut.model_validate(product) out = ProductOut.model_validate(product)
out.stock = current_stock(db, product.id) out.stock = current_stock(db, product.id)
out.expired_count = (
db.query(Lot)
.filter(
Lot.product_id == product.id,
Lot.best_before.isnot(None),
Lot.best_before < date.today(),
Lot.quantity > 0,
)
.count()
)
return out return out

View File

@@ -110,6 +110,7 @@ class ProductOut(BaseModel):
created_at: datetime created_at: datetime
# angereichert: # angereichert:
stock: float = 0.0 stock: float = 0.0
expired_count: int = 0
class LookupResult(BaseModel): class LookupResult(BaseModel):

View File

@@ -4,7 +4,7 @@ import { api } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { guessGroup, suggestionToProduct } from "../offUtils"; import { guessGroup, suggestionToProduct } from "../offUtils";
import { fmt, unitOptions, unitShort } from "../units"; import { fmt, isExpired, unitOptions, unitShort } from "../units";
const emptyLine = () => ({ quantity: "", best_before: "" }); const emptyLine = () => ({ quantity: "", best_before: "" });
@@ -251,7 +251,11 @@ export default function CheckIn() {
<label className="grow" style={{ margin: 0 }}> <label className="grow" style={{ margin: 0 }}>
{i === 0 && <span className="line-label">MHD (optional)</span>} {i === 0 && <span className="line-label">MHD (optional)</span>}
<input type="date" value={line.best_before} <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> </label>
<button type="button" className="btn-icon danger" title="Charge entfernen" <button type="button" className="btn-icon danger" title="Charge entfernen"
onClick={() => removeLine(i)} disabled={lines.length === 1}> 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>} {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-row">
<div className="stat"> <div className="stat">
<div className="stat-label"><Icon name="clock" size={14} />Bald ablaufend</div> <div className="stat-label"><Icon name="clock" size={14} />Bald ablaufend</div>
@@ -75,7 +81,7 @@ export default function Dashboard() {
</thead> </thead>
<tbody> <tbody>
{expiring.map((it) => ( {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>{it.product_name}</td>
<td className="num">{fmt(it.quantity)} {unitShort(it.base_unit)}</td> <td className="num">{fmt(it.quantity)} {unitShort(it.base_unit)}</td>
<td>{it.best_before}</td> <td>{it.best_before}</td>

View File

@@ -4,7 +4,7 @@ import { api } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { guessGroup } from "../offUtils"; import { guessGroup } from "../offUtils";
import { BASE_UNITS, fmt, unitShort } from "../units"; import { BASE_UNITS, daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units";
const EMPTY = { const EMPTY = {
barcode: "", name: "", brand: "", image_url: "", barcode: "", name: "", brand: "", image_url: "",
@@ -27,6 +27,7 @@ export default function ProductForm() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Einheit, in der der Mindestbestand eingegeben wird: "base" oder "package". // Einheit, in der der Mindestbestand eingegeben wird: "base" oder "package".
const [minUnit, setMinUnit] = useState("base"); const [minUnit, setMinUnit] = useState("base");
const [warnDays, setWarnDays] = useState(7);
function set(k, v) { function set(k, v) {
setForm((f) => ({ ...f, [k]: v })); setForm((f) => ({ ...f, [k]: v }));
@@ -86,6 +87,11 @@ export default function ProductForm() {
try { try {
const gs = await api.listGroups(); const gs = await api.listGroups();
setGroups(gs); 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) { if (!isNew) {
const p = await api.getProduct(id); const p = await api.getProduct(id);
setProduct(p); setProduct(p);
@@ -259,18 +265,31 @@ export default function ProductForm() {
{!isNew && ( {!isNew && (
<section className="card"> <section className="card">
<div className="card-head"><Icon name="box" /><h2>Chargen im Bestand</h2></div> <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="" />} {form.image_url && <img className="product-img" src={form.image_url} alt="" />}
<div className="table-wrap"> <div className="table-wrap">
<table className="table"> <table className="table">
<thead><tr><th className="num">Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead> <thead><tr><th className="num">Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead>
<tbody> <tbody>
{lots.map((l) => ( {lots.map((l) => {
<tr key={l.id}> 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 className="num">{fmt(l.quantity)} {unitLabel}</td>
<td>{l.best_before || ""}</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> <td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
</tr> </tr>
))} );
})}
{lots.length === 0 && <tr><td colSpan={3} className="empty">Keine Chargen im Bestand.</td></tr>} {lots.length === 0 && <tr><td colSpan={3} className="empty">Keine Chargen im Bestand.</td></tr>}
</tbody> </tbody>
</table> </table>

View File

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

View File

@@ -260,6 +260,8 @@ input::placeholder { color: var(--muted); opacity: 0.7; }
.line { display: flex; gap: var(--sp-2); align-items: flex-end; margin-bottom: var(--sp-2); } .line { display: flex; gap: var(--sp-2); align-items: flex-end; margin-bottom: var(--sp-2); }
.line .btn-icon { margin-bottom: 1px; } .line .btn-icon { margin-bottom: 1px; }
.line-label { display: block; font-size: 0.72rem; color: var(--muted); font-weight: 580; margin-bottom: 3px; } .line-label { display: block; font-size: 0.72rem; color: var(--muted); font-weight: 580; margin-bottom: 3px; }
.input-danger { border-color: var(--danger) !important; }
.hint-danger { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); font-size: 0.72rem; margin-top: 3px; }
/* ---------- Stat tiles ---------- */ /* ---------- Stat tiles ---------- */
.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: var(--sp-3); margin-bottom: var(--sp-5); } .stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: var(--sp-3); margin-bottom: var(--sp-5); }

View File

@@ -37,6 +37,31 @@ export function fmt(n) {
return Number(n).toLocaleString("de-DE", { maximumFractionDigits: 2 }); return Number(n).toLocaleString("de-DE", { maximumFractionDigits: 2 });
} }
// Tage bis zum MHD (negativ = überfällig, null = kein MHD).
export function daysUntil(dateStr) {
if (!dateStr) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(dateStr);
d.setHours(0, 0, 0, 0);
return Math.round((d - today) / 86400000);
}
// Ist ein MHD (YYYY-MM-DD) bereits abgelaufen (vor heute)?
export function isExpired(dateStr) {
const d = daysUntil(dateStr);
return d != null && d < 0;
}
// CSS-Zeilenklasse je MHD: rot wenn abgelaufen, gelb wenn innerhalb der Warnfrist.
export function expiryRowClass(dateStr, warnDays = 7) {
const d = daysUntil(dateStr);
if (d == null) return "";
if (d < 0) return "row-danger";
if (d <= warnDays) return "row-warn";
return "";
}
// Menge lesbar darstellen: bei Packungsprodukten in Packungen (+ Basiseinheit in Klammern). // Menge lesbar darstellen: bei Packungsprodukten in Packungen (+ Basiseinheit in Klammern).
export function amountText(qty, packageSize, baseUnit) { export function amountText(qty, packageSize, baseUnit) {
if (packageSize && packageSize > 0) { if (packageSize && packageSize > 0) {