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