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 datetime import date
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from .models import Product
from .models import Lot, Product
from .schemas import ProductOut
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:
out = ProductOut.model_validate(product)
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

View File

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

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>

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 .btn-icon { margin-bottom: 1px; }
.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-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 });
}
// 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).
export function amountText(qty, packageSize, baseUnit) {
if (packageSize && packageSize > 0) {