Einheiten (neu):
- Tabelle units (name, kind=count|weight|volume, factor, is_builtin); eingebaut
Stueck/Gramm/Kilogramm/Milliliter/Liter, Admin kann eigene anlegen (z.B. Pfund=500g).
- Bestaende bleiben intern in kanonischer Basis (Stueck/Gramm/Milliliter);
Product.display_unit_id und Group.min_stock_unit_id als nullable FKs.
- Neuer Umrechnungs-Service (services/conversion.py) ersetzt die feste Einheitenlogik;
Ein-/Auslagern und Gruppen-Mindestbestand rechnen ueber den Faktor.
- Gruppen-Mindestbestand mit Einheit; Gruppenbestand summiert nur Produkte
passender Art. Neue Verwaltungsseite "Einheiten" (Admin).
- Schonende Migration beim Start: ADD COLUMN IF NOT EXISTS (Postgres), damit
bestehende Installationen ihre Daten behalten.
Chargen:
- PATCH /lots/{id} und DELETE /lots/{id}: Menge/MHD korrigieren, Charge loeschen
(wird als Korrektur-Bewegung protokolliert). Bearbeitung im Produktdetail.
Auslagern:
- Optionales lot_id: gezielt aus einer bestimmten Charge/MHD abbuchen statt FEFO;
Auswahl-Dropdown in der Auslagern-Seite.
Sonstiges: Roadmap aktualisiert, Tests fuer die Umrechnung.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
137 lines
5.0 KiB
JavaScript
137 lines
5.0 KiB
JavaScript
import { useEffect, useState } from "react";
|
||
import { Link } from "react-router-dom";
|
||
import { api } from "../api";
|
||
import Icon from "../components/Icon";
|
||
import { amountText, fmt, unitShort } from "../units";
|
||
|
||
export default function Dashboard() {
|
||
const [expiring, setExpiring] = useState([]);
|
||
const [shopping, setShopping] = useState([]);
|
||
const [groupShopping, setGroupShopping] = useState([]);
|
||
const [error, setError] = useState(null);
|
||
|
||
useEffect(() => {
|
||
async function load() {
|
||
try {
|
||
const [e, s, g] = await Promise.all([
|
||
api.expiring(),
|
||
api.shoppingList(),
|
||
api.groupShoppingList(),
|
||
]);
|
||
setExpiring(e);
|
||
setShopping(s);
|
||
setGroupShopping(g);
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
load();
|
||
}, []);
|
||
|
||
const overdue = expiring.filter((e) => e.days_left < 0).length;
|
||
|
||
return (
|
||
<div>
|
||
<div className="page-head">
|
||
<div>
|
||
<h1>Übersicht</h1>
|
||
<div className="sub">Bestand, Ablauf und Bedarf auf einen Blick</div>
|
||
</div>
|
||
<div className="actions">
|
||
<Link className="btn primary" to="/checkin"><Icon name="checkin" size={16} />Einlagern</Link>
|
||
<Link className="btn" to="/checkout"><Icon name="checkout" size={16} />Auslagern</Link>
|
||
</div>
|
||
</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">
|
||
<div className="stat-label"><Icon name="clock" size={14} />Bald ablaufend</div>
|
||
<div className="stat-value">{expiring.length}</div>
|
||
</div>
|
||
<div className="stat">
|
||
<div className="stat-label"><Icon name="alert" size={14} />Überfällig</div>
|
||
<div className="stat-value">{overdue}</div>
|
||
</div>
|
||
<div className="stat">
|
||
<div className="stat-label"><Icon name="cart" size={14} />Nachzukaufen</div>
|
||
<div className="stat-value">{shopping.length + groupShopping.length}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid-2">
|
||
<section className="card">
|
||
<div className="card-head"><Icon name="clock" />
|
||
<h2>Bald ablaufend</h2>
|
||
</div>
|
||
{expiring.length === 0 ? (
|
||
<div className="empty">Nichts läuft demnächst ab.</div>
|
||
) : (
|
||
<div className="table-wrap">
|
||
<table className="table">
|
||
<thead>
|
||
<tr><th>Produkt</th><th className="num">Menge</th><th>MHD</th><th className="num">Tage</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{expiring.map((it) => (
|
||
<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>
|
||
<td className="num">
|
||
{it.days_left < 0
|
||
? <span className="badge danger">{-it.days_left}d überf.</span>
|
||
: it.days_left}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="card">
|
||
<div className="card-head"><Icon name="cart" />
|
||
<h2>Einkaufsliste</h2>
|
||
</div>
|
||
{shopping.length === 0 && groupShopping.length === 0 ? (
|
||
<div className="empty">Alle Mindestbestände erreicht.</div>
|
||
) : (
|
||
<div className="table-wrap">
|
||
<table className="table">
|
||
<thead>
|
||
<tr><th>Bedarf</th><th className="num">Bestand</th><th className="num">Fehlt</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{groupShopping.map((it) => (
|
||
<tr key={`g${it.group_id}`}>
|
||
<td><span className="badge accent">Gruppe</span> {it.name}</td>
|
||
<td className="num">{fmt(it.stock)} {it.unit_name}</td>
|
||
<td className="num strong">{fmt(it.deficit)} {it.unit_name}</td>
|
||
</tr>
|
||
))}
|
||
{shopping.map((it) => (
|
||
<tr key={`p${it.product_id}`}>
|
||
<td>{it.name}</td>
|
||
<td className="num">{amountText(it.stock, it.package_size, it.base_unit)}</td>
|
||
<td className="num strong">{amountText(it.deficit, it.package_size, it.base_unit)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|