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:
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user