Bald ablaufend
@@ -75,7 +81,7 @@ export default function Dashboard() {
{expiring.map((it) => (
-
+
| {it.product_name} |
{fmt(it.quantity)} {unitShort(it.base_unit)} |
{it.best_before} |
diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx
index 86ed8f3..9b9be09 100644
--- a/web/src/pages/ProductForm.jsx
+++ b/web/src/pages/ProductForm.jsx
@@ -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 && (
Chargen im Bestand
+ {lots.some((l) => isExpired(l.best_before)) && (
+
+ Dieses Produkt hat abgelaufene Chargen im Bestand.
+
+ )}
{form.image_url &&
}
| Menge | MHD | Eingelagert |
- {lots.map((l) => (
-
- | {fmt(l.quantity)} {unitLabel} |
- {l.best_before || "–"} |
- {new Date(l.created_at).toLocaleDateString("de-DE")} |
-
- ))}
+ {lots.map((l) => {
+ const cls = expiryRowClass(l.best_before, warnDays);
+ const dLeft = daysUntil(l.best_before);
+ return (
+
+ | {fmt(l.quantity)} {unitLabel} |
+
+ {l.best_before || "–"}
+ {cls === "row-danger" && abgelaufen}
+ {cls === "row-warn" && {dLeft}d}
+ |
+ {new Date(l.created_at).toLocaleDateString("de-DE")} |
+
+ );
+ })}
{lots.length === 0 && | Keine Chargen im Bestand. |
}
diff --git a/web/src/pages/Products.jsx b/web/src/pages/Products.jsx
index 4e8a4bd..8d444f9 100644
--- a/web/src/pages/Products.jsx
+++ b/web/src/pages/Products.jsx
@@ -60,6 +60,7 @@ export default function Products() {
Marke |
Einheit |
Bestand |
+
Packungen |
Mindest |
|
@@ -74,20 +75,32 @@ export default function Products() {
?
: }
- {p.name} |
+
+ {p.name}
+ {p.expired_count > 0 && (
+
+ {p.expired_count} abgelaufen
+
+ )}
+ |
{p.brand || "–"} |
{unitShort(p.base_unit)}{p.package_size ? ` · Pkg ${fmt(p.package_size)}` : ""} |
{fmt(p.stock)} {unitShort(p.base_unit)}
{low && niedrig}
|
+
+ {p.package_size
+ ? `${fmt(p.stock / p.package_size)} Pkg`
+ : `${fmt(p.stock)} ${unitShort(p.base_unit)}`}
+ |
{p.min_stock != null ? fmt(p.min_stock) : "–"} |
Details |
);
})}
{products.length === 0 && (
- | Keine Produkte gefunden. |
+ | Keine Produkte gefunden. |
)}
diff --git a/web/src/styles.css b/web/src/styles.css
index bb0584e..6364674 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -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); }
diff --git a/web/src/units.js b/web/src/units.js
index ca21c8a..9a7657d 100644
--- a/web/src/units.js
+++ b/web/src/units.js
@@ -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) {