Verwaltbare Einheiten mit Umrechnung + Chargen bearbeiten + gezieltes Auslagern

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>
This commit is contained in:
Scarriffle
2026-07-22 12:33:14 +02:00
parent d4e7ff8e3d
commit 0310cdfbd6
26 changed files with 1015 additions and 177 deletions

View File

@@ -9,6 +9,7 @@ import CheckIn from "./pages/CheckIn";
import CheckOut from "./pages/CheckOut";
import Groups from "./pages/Groups";
import Locations from "./pages/Locations";
import Units from "./pages/Units";
import Users from "./pages/Users";
import ShoppingList from "./pages/ShoppingList";
import History from "./pages/History";
@@ -52,6 +53,7 @@ function Sidebar() {
<>
<div className="nav-section">Verwaltung</div>
<NavItem to="/locations" icon="location" label="Lagerorte" />
<NavItem to="/units" icon="box" label="Einheiten" />
<NavItem to="/users" icon="users" label="Benutzer" />
<NavItem to="/settings" icon="settings" label="Einstellungen" />
</>
@@ -104,6 +106,7 @@ export default function App() {
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
<Route path="/history" element={<Protected><History /></Protected>} />
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
<Route path="/units" element={<Protected adminOnly><Units /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />
<Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -88,6 +88,8 @@ export const api = {
checkOut: (body) => request("/stock/checkout", { method: "POST", body }),
listLots: (productId) =>
request(`/lots${productId ? `?product_id=${productId}` : ""}`),
updateLot: (id, body) => request(`/lots/${id}`, { method: "PATCH", body }),
deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }),
// Views
shoppingList: () => request("/shopping-list"),
@@ -105,6 +107,10 @@ export const api = {
updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }),
deleteGroup: (id) => request(`/groups/${id}`, { method: "DELETE" }),
listUnits: () => request("/units"),
createUnit: (body) => request("/units", { method: "POST", body }),
deleteUnit: (id) => request(`/units/${id}`, { method: "DELETE" }),
// Benutzer
listUsers: () => request("/users"),
createUser: (body) => request("/users", { method: "POST", body }),

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, isExpired, unitOptions, unitShort } from "../units";
import { buildUnitOptions, fmt, isExpired } from "../units";
const emptyLine = () => ({ quantity: "", best_before: "" });
@@ -18,6 +18,7 @@ export default function CheckIn() {
const [search, setSearch] = useState("");
const [locations, setLocations] = useState([]);
const [groups, setGroups] = useState([]);
const [units, setUnits] = useState([]);
const [unit, setUnit] = useState("");
const [locationId, setLocationId] = useState("");
@@ -30,6 +31,7 @@ export default function CheckIn() {
useEffect(() => {
api.listLocations().then(setLocations).catch(() => {});
api.listGroups().then(setGroups).catch(() => {});
api.listUnits().then(setUnits).catch(() => {});
}, []);
function resetLookup() {
@@ -41,7 +43,8 @@ export default function CheckIn() {
setProduct(p);
setResults([]);
resetLookup();
setUnit(unitOptions(p)[0].value);
const opts = buildUnitOptions(p, units);
setUnit(p.unit_name || (opts[0] && opts[0].value) || "");
setLines([emptyLine()]);
setLocationId("");
}
@@ -118,7 +121,7 @@ export default function CheckIn() {
const res = await api.checkInBatch({ product_id: product.id, unit, lines: payloadLines });
setInfo(
`${payloadLines.length} Charge(n) eingelagert. Neuer Bestand: ` +
`${fmt(res.product_stock)} ${unitShort(product.base_unit)}`
`${fmt(res.product_stock / (product.unit_factor || 1))} ${product.unit_name}`
);
setLines([emptyLine()]);
setProduct(await api.getProduct(product.id));
@@ -197,7 +200,7 @@ export default function CheckIn() {
{results.map((p) => (
<li key={p.id}>
<button className="link-btn" onClick={() => selectProduct(p)}>
{p.name} <span className="muted">({fmt(p.stock)} {unitShort(p.base_unit)})</span>
{p.name} <span className="muted">({fmt(p.stock / (p.unit_factor || 1))} {p.unit_name})</span>
</button>
</li>
))}
@@ -214,7 +217,7 @@ export default function CheckIn() {
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
<div className="info">
<div className="title">{product.name}</div>
<div className="muted small">Bestand: {fmt(product.stock)} {unitShort(product.base_unit)}</div>
<div className="muted small">Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}</div>
</div>
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
</div>
@@ -223,7 +226,7 @@ export default function CheckIn() {
<label className="grow">
Einheit
<select value={unit} onChange={(e) => setUnit(e.target.value)}>
{unitOptions(product).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
{buildUnitOptions(product, units).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
<label className="grow">
@@ -274,7 +277,7 @@ export default function CheckIn() {
</button>
{totalQty > 0 && (
<span className="muted small">Summe: {fmt(totalQty)} {
unitOptions(product).find((o) => o.value === unit)?.label || ""
buildUnitOptions(product, units).find((o) => o.value === unit)?.label || ""
}</span>
)}
</div>

View File

@@ -1,25 +1,38 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { api } from "../api";
import Icon from "../components/Icon";
import { fmt, unitOptions, unitShort } from "../units";
import { buildUnitOptions, fmt, isExpired, unitShort } from "../units";
export default function CheckOut() {
const [barcode, setBarcode] = useState("");
const [search, setSearch] = useState("");
const [results, setResults] = useState([]);
const [product, setProduct] = useState(null);
const [units, setUnits] = useState([]);
const [lots, setLots] = useState([]);
const [quantity, setQuantity] = useState("");
const [unit, setUnit] = useState("");
const [lotId, setLotId] = useState(""); // "" = automatisch (FEFO)
const [error, setError] = useState(null);
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState(false);
function selectProduct(p) {
useEffect(() => {
api.listUnits().then(setUnits).catch(() => {});
}, []);
async function selectProduct(p) {
setProduct(p);
setResults([]);
setUnit(unitOptions(p)[0].value);
setLotId("");
setQuantity("");
const opts = buildUnitOptions(p, units);
setUnit(p.unit_name || (opts[0] && opts[0].value) || "");
try {
setLots(await api.listLots(p.id));
} catch { setLots([]); }
}
async function doLookup() {
@@ -28,7 +41,7 @@ export default function CheckOut() {
try {
const res = await api.lookup(barcode.trim());
if (res.found && res.existing_product) {
selectProduct(res.existing_product);
await selectProduct(res.existing_product);
} else {
setError("Kein bekanntes Produkt zu diesem Barcode im Lager.");
}
@@ -54,10 +67,17 @@ export default function CheckOut() {
product_id: product.id,
quantity: Number(quantity),
unit,
lot_id: lotId === "" ? null : Number(lotId),
});
setInfo(`Ausgelagert (${res.affected_lots.length} Charge(n)). Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`);
setInfo(
`Ausgelagert (${res.affected_lots.length} Charge(n)). Neuer Bestand: ` +
`${fmt(res.product_stock / (product.unit_factor || 1))} ${product.unit_name}`
);
setQuantity("");
setProduct(await api.getProduct(product.id));
const fresh = await api.getProduct(product.id);
setProduct(fresh);
setLots(await api.listLots(fresh.id));
setLotId("");
} catch (err) {
setError(err.message);
} finally {
@@ -65,6 +85,10 @@ export default function CheckOut() {
}
}
const unitOpts = product ? buildUnitOptions(product, units) : [];
const selectedLot = lots.find((l) => String(l.id) === String(lotId)) || null;
const baseShort = product ? unitShort(product.base_unit) : "";
return (
<div>
<div className="page-head"><h1>Auslagern</h1></div>
@@ -96,7 +120,7 @@ export default function CheckOut() {
{results.map((p) => (
<li key={p.id}>
<button className="link-btn" onClick={() => selectProduct(p)}>
{p.name} <span className="muted">({fmt(p.stock)} {unitShort(p.base_unit)})</span>
{p.name} <span className="muted">({fmt(p.stock / (p.unit_factor || 1))} {p.unit_name})</span>
</button>
</li>
))}
@@ -113,11 +137,35 @@ export default function CheckOut() {
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
<div className="info">
<div className="title">{product.name}</div>
<div className="muted small">Verfügbar: {fmt(product.stock)} {unitShort(product.base_unit)}</div>
<div className="muted small">
Verfügbar: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}
</div>
</div>
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
</div>
<label>
Charge
<select value={lotId} onChange={(e) => setLotId(e.target.value)}>
<option value="">Automatisch zuerst ablaufende zuerst (FEFO)</option>
{lots.map((l) => (
<option key={l.id} value={l.id}>
{l.best_before ? `MHD ${l.best_before}` : "ohne MHD"} · {fmt(l.quantity)} {baseShort}
{isExpired(l.best_before) ? " · abgelaufen" : ""}
</option>
))}
</select>
</label>
{selectedLot && (
<div className={`alert ${isExpired(selectedLot.best_before) ? "error" : "info"}`}>
<Icon name="alert" size={16} />
Gewählte Charge: {fmt(selectedLot.quantity)} {baseShort}
{selectedLot.best_before ? ` · MHD ${selectedLot.best_before}` : " · ohne MHD"}
{isExpired(selectedLot.best_before) ? " (abgelaufen)" : ""}
</div>
)}
<div className="row">
<label className="grow">
Menge
@@ -127,14 +175,19 @@ export default function CheckOut() {
<label className="grow">
Einheit
<select value={unit} onChange={(e) => setUnit(e.target.value)}>
{unitOptions(product).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
{unitOpts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
</div>
<p className="muted small mt-0">
Es wird automatisch die zuerst ablaufende Charge zuerst entnommen (FEFO).
</p>
<button className="btn primary" disabled={busy}><Icon name="checkout" size={16} />{busy ? "…" : "Auslagern"}</button>
{!selectedLot && (
<p className="muted small mt-0">
Ohne Auswahl wird automatisch die zuerst ablaufende Charge zuerst entnommen (FEFO).
</p>
)}
<button className="btn primary" disabled={busy}>
<Icon name="checkout" size={16} />{busy ? "…" : "Auslagern"}
</button>
</form>
)}
</div>

View File

@@ -114,8 +114,8 @@ export default function Dashboard() {
{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)}</td>
<td className="num strong">{fmt(it.deficit)}</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) => (

View File

@@ -7,13 +7,15 @@ import { fmt } from "../units";
export default function Groups() {
const { isAdmin } = useAuth();
const [groups, setGroups] = useState([]);
const [name, setName] = useState("");
const [minStock, setMinStock] = useState("");
const [units, setUnits] = useState([]);
const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" });
const [error, setError] = useState(null);
async function load() {
try {
setGroups(await api.listGroups());
const [gs, us] = await Promise.all([api.listGroups(), api.listUnits()]);
setGroups(gs);
setUnits(us);
} catch (err) {
setError(err.message);
}
@@ -25,18 +27,21 @@ export default function Groups() {
e.preventDefault();
setError(null);
try {
await api.createGroup({ name, min_stock: minStock === "" ? null : Number(minStock) });
setName("");
setMinStock("");
await api.createGroup({
name: form.name,
min_stock: form.min_stock === "" ? null : Number(form.min_stock),
min_stock_unit_id: form.min_stock_unit_id === "" ? null : Number(form.min_stock_unit_id),
});
setForm({ name: "", min_stock: "", min_stock_unit_id: form.min_stock_unit_id });
load();
} catch (err) {
setError(err.message);
}
}
async function saveMin(group, value) {
async function patch(group, body) {
try {
await api.updateGroup(group.id, { min_stock: value === "" ? null : Number(value) });
await api.updateGroup(group.id, body);
load();
} catch (err) {
setError(err.message);
@@ -58,7 +63,7 @@ export default function Groups() {
<div className="page-head">
<div>
<h1>Gruppen</h1>
<div className="sub">Fasse Produkte zusammen (z.B. Nudeln, Mehl) und setze einen Gruppen-Mindestbestand</div>
<div className="sub">Produkte zusammenfassen (z.B. Nudeln) und einen Gruppen-Mindestbestand mit Einheit setzen</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
@@ -68,7 +73,14 @@ export default function Groups() {
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Gruppe</th><th className="num">Produkte</th><th className="num">Bestand</th><th>Mindestbestand</th><th></th></tr>
<tr>
<th>Gruppe</th>
<th className="num">Produkte</th>
<th className="num">Bestand</th>
<th>Mindestbestand</th>
<th>Einheit</th>
<th></th>
</tr>
</thead>
<tbody>
{groups.map((g) => {
@@ -80,17 +92,30 @@ export default function Groups() {
{low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>}
</td>
<td className="num muted">{g.product_count}</td>
<td className="num">{fmt(g.stock)}</td>
<td className="num">{fmt(g.stock)} {g.min_stock_unit_name || ""}</td>
<td>
{isAdmin ? (
<input type="number" step="any" defaultValue={g.min_stock ?? ""}
style={{ maxWidth: 110, marginTop: 0 }}
style={{ maxWidth: 100, marginTop: 0 }}
onBlur={(e) => {
const v = e.target.value;
if (v !== String(g.min_stock ?? "")) saveMin(g, v);
if (v !== String(g.min_stock ?? "")) {
patch(g, { min_stock: v === "" ? null : Number(v) });
}
}} />
) : (g.min_stock != null ? fmt(g.min_stock) : "")}
</td>
<td>
{isAdmin ? (
<select value={g.min_stock_unit_id ?? ""} style={{ maxWidth: 140, marginTop: 0 }}
onChange={(e) => patch(g, {
min_stock_unit_id: e.target.value === "" ? null : Number(e.target.value),
})}>
<option value=""> Basiseinheit </option>
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
) : (g.min_stock_unit_name || "")}
</td>
<td className="num">
{isAdmin && (
<button className="btn-icon danger" onClick={() => remove(g)} title="Löschen">
@@ -101,7 +126,7 @@ export default function Groups() {
</tr>
);
})}
{groups.length === 0 && <tr><td colSpan={5} className="empty">Noch keine Gruppen.</td></tr>}
{groups.length === 0 && <tr><td colSpan={6} className="empty">Noch keine Gruppen.</td></tr>}
</tbody>
</table>
</div>
@@ -112,18 +137,27 @@ export default function Groups() {
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
<label>
Name
<input placeholder="z.B. Nudeln" value={name} onChange={(e) => setName(e.target.value)} required />
</label>
<label>
Mindestbestand (optional)
<input type="number" step="any" value={minStock} onChange={(e) => setMinStock(e.target.value)}
placeholder="Gesamtmenge über alle Produkte der Gruppe" />
<input placeholder="z.B. Nudeln" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label>
<div className="row">
<label className="grow">
Mindestbestand (optional)
<input type="number" step="any" value={form.min_stock}
onChange={(e) => setForm({ ...form, min_stock: e.target.value })} placeholder="z.B. 2" />
</label>
<label className="grow">
Einheit
<select value={form.min_stock_unit_id}
onChange={(e) => setForm({ ...form, min_stock_unit_id: e.target.value })}>
<option value=""> Basiseinheit </option>
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</label>
</div>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
<p className="muted small">
Produkte ordnest du einer Gruppe im jeweiligen Produkt-Formular zu.
Der Gruppen-Bestand ist die Summe der Produktbestände sinnvoll, wenn
die Produkte dieselbe Basiseinheit haben.
Der Gruppen-Bestand summiert nur Produkte, die zur gewählten Einheit passen
(Gewicht/Volumen/Stück). Produkte ordnest du im Produkt-Formular einer Gruppe zu.
</p>
</form>
)}

View File

@@ -4,13 +4,16 @@ import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { guessGroup } from "../offUtils";
import { BASE_UNITS, daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units";
import { daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units";
const EMPTY = {
barcode: "", name: "", brand: "", image_url: "",
base_unit: "piece", package_size: "", min_stock: "", group_id: "",
unit_id: "", package_size: "", min_stock: "", group_id: "",
};
// Kanonische Einheit je Basiseinheit (für OFF-Vorschläge und Altbestand).
const CANONICAL_NAME = { piece: "Stück", gram: "Gramm", milliliter: "Milliliter" };
export default function ProductForm() {
const { id } = useParams();
const isNew = !id;
@@ -22,28 +25,45 @@ export default function ProductForm() {
const [product, setProduct] = useState(null);
const [lots, setLots] = useState([]);
const [groups, setGroups] = useState([]);
const [units, setUnits] = useState([]);
const [error, setError] = useState(null);
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState(false);
// Einheit, in der der Mindestbestand eingegeben wird: "base" oder "package".
const [minUnit, setMinUnit] = useState("base");
// Einheitr die Mindestbestand-Eingabe: "unit" (gewählte Einheit) oder "package".
const [minUnit, setMinUnit] = useState("unit");
const [warnDays, setWarnDays] = useState(7);
const selectedUnit = units.find((u) => String(u.id) === String(form.unit_id)) || null;
const unitFactor = selectedUnit ? selectedUnit.factor : 1;
const unitName = selectedUnit ? selectedUnit.name : "";
function set(k, v) {
setForm((f) => ({ ...f, [k]: v }));
}
// Wechselt die Mindestbestand-Einheit und rechnet den angezeigten Wert um.
function canonicalUnitId(unitList, baseUnit) {
const name = CANONICAL_NAME[baseUnit];
const hit = unitList.find((u) => u.name === name);
return hit ? String(hit.id) : "";
}
// Faktor, mit dem der angezeigte Mindestbestand in Basiseinheiten umgerechnet wird.
function minFactor(mode, pkgSize, uFactor) {
if (mode === "package") return Number(pkgSize) > 0 ? Number(pkgSize) : 1;
return uFactor || 1;
}
function changeMinUnit(newUnit) {
const ps = Number(form.package_size);
if (form.min_stock !== "" && ps > 0 && newUnit !== minUnit) {
const v = Number(form.min_stock);
set("min_stock", String(newUnit === "package" ? v / ps : v * ps));
if (form.min_stock !== "" && newUnit !== minUnit) {
const oldF = minFactor(minUnit, form.package_size, unitFactor);
const newF = minFactor(newUnit, form.package_size, unitFactor);
const base = Number(form.min_stock) * oldF;
set("min_stock", String(base / newF));
}
setMinUnit(newUnit);
}
function applySuggestion(s, groupsList) {
function applySuggestion(s, groupsList, unitList) {
const groupGuess = guessGroup(groupsList, s);
setForm((f) => ({
...f,
@@ -51,7 +71,7 @@ export default function ProductForm() {
name: s.name || f.name,
brand: s.brand || f.brand,
image_url: s.image_url || f.image_url,
base_unit: s.base_unit || f.base_unit,
unit_id: f.unit_id || canonicalUnitId(unitList, s.base_unit || "piece"),
package_size: s.package_size != null ? String(s.package_size) : f.package_size,
group_id: f.group_id || groupGuess,
}));
@@ -63,7 +83,7 @@ export default function ProductForm() {
);
}
async function runLookup(code, groupsList) {
async function runLookup(code, groupsList, unitList) {
if (!code) return;
setError(null);
setInfo(null);
@@ -73,7 +93,7 @@ export default function ProductForm() {
setInfo("Dieses Produkt existiert bereits.");
navigate(`/products/${res.existing_product.id}`);
} else if (res.found && res.suggestion) {
applySuggestion(res.suggestion, groupsList);
applySuggestion(res.suggestion, groupsList, unitList);
} else {
setInfo("Barcode unbekannt bitte Daten selbst eingeben.");
}
@@ -85,29 +105,30 @@ export default function ProductForm() {
useEffect(() => {
async function load() {
try {
const gs = await api.listGroups();
const [gs, us] = await Promise.all([api.listGroups(), api.listUnits()]);
setGroups(gs);
setUnits(us);
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 */ }
} catch { /* optional */ }
if (!isNew) {
const p = await api.getProduct(id);
setProduct(p);
// Mindestbestand ist in Basiseinheiten gespeichert; bei Packungsprodukten
// zeigen wir ihn zur besseren Verständlichkeit in Packungen an.
let minDisplay = p.min_stock ?? "";
if (p.min_stock != null && p.package_size && p.package_size > 0) {
minDisplay = p.min_stock / p.package_size;
setMinUnit("package");
} else {
setMinUnit("base");
}
const uid = p.display_unit_id
? String(p.display_unit_id)
: canonicalUnitId(us, p.base_unit);
const uf = us.find((u) => String(u.id) === uid)?.factor || 1;
const mode = p.package_size && p.package_size > 0 ? "package" : "unit";
const f = mode === "package" ? p.package_size : uf;
setMinUnit(mode);
setForm({
barcode: p.barcode || "", name: p.name, brand: p.brand || "",
image_url: p.image_url || "", base_unit: p.base_unit,
package_size: p.package_size ?? "", min_stock: minDisplay,
image_url: p.image_url || "", unit_id: uid,
package_size: p.package_size ?? "",
min_stock: p.min_stock != null ? p.min_stock / f : "",
group_id: p.group_id ?? "",
});
setLots(await api.listLots(id));
@@ -115,7 +136,7 @@ export default function ProductForm() {
const bc = searchParams.get("barcode");
if (bc) {
set("barcode", bc);
await runLookup(bc, gs);
await runLookup(bc, gs, us);
}
}
} catch (err) {
@@ -127,18 +148,16 @@ export default function ProductForm() {
}, [id]);
function buildPayload() {
const ps = Number(form.package_size);
let minBase = null;
if (form.min_stock !== "") {
const v = Number(form.min_stock);
minBase = minUnit === "package" && ps > 0 ? v * ps : v;
minBase = Number(form.min_stock) * minFactor(minUnit, form.package_size, unitFactor);
}
return {
barcode: form.barcode || null,
name: form.name,
brand: form.brand || null,
image_url: form.image_url || null,
base_unit: form.base_unit,
unit_id: form.unit_id === "" ? null : Number(form.unit_id),
package_size: form.package_size === "" ? null : Number(form.package_size),
min_stock: minBase,
group_id: form.group_id === "" ? null : Number(form.group_id),
@@ -157,6 +176,7 @@ export default function ProductForm() {
await api.updateProduct(id, buildPayload());
setInfo("Gespeichert.");
setProduct(await api.getProduct(id));
setLots(await api.listLots(id));
}
} catch (err) {
setError(err.message);
@@ -176,14 +196,18 @@ export default function ProductForm() {
}
const readOnly = !isAdmin;
const unitLabel = unitShort(form.base_unit);
const baseShort = product ? unitShort(product.base_unit) : "";
return (
<div>
<div className="page-head">
<div>
<h1>{isNew ? "Neues Produkt" : form.name || "Produkt"}</h1>
{!isNew && <div className="sub">Bestand: {fmt(product?.stock)} {unitLabel}</div>}
{!isNew && product && (
<div className="sub">
Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}
</div>
)}
</div>
<button className="btn ghost" onClick={() => navigate(-1)}>Zurück</button>
</div>
@@ -199,7 +223,7 @@ export default function ProductForm() {
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
</label>
{isAdmin && (
<button type="button" className="btn" onClick={() => runLookup(form.barcode, groups)}>
<button type="button" className="btn" onClick={() => runLookup(form.barcode, groups, units)}>
<Icon name="search" size={16} />Nachschlagen
</button>
)}
@@ -214,16 +238,16 @@ export default function ProductForm() {
</label>
<div className="row">
<label className="grow">
Basiseinheit
<select value={form.base_unit} onChange={(e) => set("base_unit", e.target.value)} disabled={readOnly}>
{BASE_UNITS.map((u) => <option key={u.value} value={u.value}>{u.label}</option>)}
Einheit
<select value={form.unit_id} onChange={(e) => set("unit_id", e.target.value)} disabled={readOnly} required>
<option value=""> wählen </option>
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</label>
<label className="grow">
Packungsgröße{unitLabel !== "Stk" ? ` (in ${unitLabel})` : ""}
Packungsgröße{baseShort ? ` (in ${baseShort})` : ""}
<input type="number" step="any" value={form.package_size}
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly}
placeholder="z.B. 500" />
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly} placeholder="z.B. 500" />
</label>
</div>
<div className="row">
@@ -231,11 +255,10 @@ export default function ProductForm() {
Mindestbestand
<div className="field-inline">
<input type="number" step="any" value={form.min_stock}
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly}
placeholder="z.B. 2" />
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" />
<select value={minUnit} onChange={(e) => changeMinUnit(e.target.value)}
disabled={readOnly} style={{ maxWidth: 140, marginTop: 0 }}>
<option value="base">{unitShort(form.base_unit)}</option>
disabled={readOnly} style={{ maxWidth: 150, marginTop: 0 }}>
<option value="unit">{unitName || "Einheit"}</option>
{form.package_size && <option value="package">Packung(en)</option>}
</select>
</div>
@@ -263,40 +286,127 @@ export default function ProductForm() {
</form>
{!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) => {
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>
</div>
</section>
<LotsCard
productId={id}
lots={lots}
baseShort={baseShort}
warnDays={warnDays}
isAdmin={isAdmin}
imageUrl={form.image_url}
onChanged={async () => {
setLots(await api.listLots(id));
setProduct(await api.getProduct(id));
}}
onError={setError}
/>
)}
</div>
</div>
);
}
/** Chargen-Karte mit Bearbeiten/Löschen. */
function LotsCard({ productId, lots, baseShort, warnDays, isAdmin, imageUrl, onChanged, onError }) {
const [editId, setEditId] = useState(null);
const [draft, setDraft] = useState({ quantity: "", best_before: "" });
function startEdit(l) {
setEditId(l.id);
setDraft({ quantity: l.quantity, best_before: l.best_before || "" });
}
async function saveEdit(l) {
try {
await api.updateLot(l.id, {
quantity: Number(draft.quantity),
best_before: draft.best_before || null,
});
setEditId(null);
await onChanged();
} catch (err) {
onError(err.message);
}
}
async function removeLot(l) {
if (!confirm("Charge wirklich löschen?")) return;
try {
await api.deleteLot(l.id);
await onChanged();
} catch (err) {
onError(err.message);
}
}
return (
<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>
)}
{imageUrl && <img className="product-img" src={imageUrl} alt="" />}
<div className="table-wrap">
<table className="table">
<thead>
<tr><th className="num">Menge</th><th>MHD</th><th>Eingelagert</th><th></th></tr>
</thead>
<tbody>
{lots.map((l) => {
const cls = expiryRowClass(l.best_before, warnDays);
const dLeft = daysUntil(l.best_before);
const editing = editId === l.id;
return (
<tr key={l.id} className={cls}>
<td className="num">
{editing ? (
<input type="number" step="any" min="0" value={draft.quantity}
onChange={(e) => setDraft({ ...draft, quantity: e.target.value })}
style={{ maxWidth: 100, marginTop: 0 }} />
) : (
<>{fmt(l.quantity)} {baseShort}</>
)}
</td>
<td>
{editing ? (
<input type="date" value={draft.best_before}
onChange={(e) => setDraft({ ...draft, best_before: e.target.value })}
style={{ maxWidth: 150, marginTop: 0 }} />
) : (
<>
{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="num">
{isAdmin && (editing ? (
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
<button className="btn sm primary" onClick={() => saveEdit(l)}>Speichern</button>
<button className="btn sm" onClick={() => setEditId(null)}>Abbrechen</button>
</div>
) : (
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
<button className="btn-icon" onClick={() => startEdit(l)} title="Charge bearbeiten">
<Icon name="edit" size={16} />
</button>
<button className="btn-icon danger" onClick={() => removeLot(l)} title="Charge löschen">
<Icon name="trash" size={16} />
</button>
</div>
))}
</td>
</tr>
);
})}
{lots.length === 0 && <tr><td colSpan={4} className="empty">Keine Chargen im Bestand.</td></tr>}
</tbody>
</table>
</div>
<p className="muted small">Mengen sind in der Basiseinheit ({baseShort}) angegeben.</p>
</section>
);
}

View File

@@ -84,17 +84,22 @@ export default function Products() {
)}
</td>
<td className="muted">{p.brand || ""}</td>
<td>{unitShort(p.base_unit)}{p.package_size ? ` · Pkg ${fmt(p.package_size)}` : ""}</td>
<td>
{p.unit_name || unitShort(p.base_unit)}
{p.package_size ? ` · Pkg ${fmt(p.package_size)} ${unitShort(p.base_unit)}` : ""}
</td>
<td className="num">
{fmt(p.stock)} {unitShort(p.base_unit)}
{fmt(p.stock / (p.unit_factor || 1))} {p.unit_name || 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)}`}
: `${fmt(p.stock / (p.unit_factor || 1))} ${p.unit_name || unitShort(p.base_unit)}`}
</td>
<td className="num muted">
{p.min_stock != null ? `${fmt(p.min_stock / (p.unit_factor || 1))}` : ""}
</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>
);

View File

@@ -46,7 +46,8 @@ export default function ShoppingList() {
<span className="item-name">{it.name}</span>
</label>
<span className="muted small">
fehlt <strong>{fmt(it.deficit)}</strong> (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)})
fehlt <strong>{fmt(it.deficit)} {it.unit_name}</strong>{" "}
(Bestand {fmt(it.stock)} / min {fmt(it.min_stock)} {it.unit_name})
</span>
</li>
);

107
web/src/pages/Units.jsx Normal file
View File

@@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import Icon from "../components/Icon";
import { fmt } from "../units";
const KIND_LABEL = { count: "Anzahl (Stück)", weight: "Gewicht (Basis: Gramm)", volume: "Volumen (Basis: Milliliter)" };
export default function Units() {
const [units, setUnits] = useState([]);
const [form, setForm] = useState({ name: "", kind: "weight", factor: "" });
const [error, setError] = useState(null);
async function load() {
try {
setUnits(await api.listUnits());
} catch (err) {
setError(err.message);
}
}
useEffect(() => { load(); }, []);
async function add(e) {
e.preventDefault();
setError(null);
try {
await api.createUnit({ name: form.name, kind: form.kind, factor: Number(form.factor) });
setForm({ name: "", kind: form.kind, factor: "" });
load();
} catch (err) {
setError(err.message);
}
}
async function remove(u) {
if (!confirm(`Einheit "${u.name}" löschen?`)) return;
try {
await api.deleteUnit(u.id);
load();
} catch (err) {
setError(err.message);
}
}
const baseUnitOfKind = { count: "Stück", weight: "Gramm", volume: "Milliliter" };
return (
<div>
<div className="page-head">
<div>
<h1>Einheiten</h1>
<div className="sub">Faktor = wie viele Basiseinheiten 1 dieser Einheit entsprechen</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="grid-2">
<div className="card" style={{ padding: 0 }}>
<div className="table-wrap">
<table className="table">
<thead><tr><th>Name</th><th>Art</th><th className="num">Faktor</th><th></th></tr></thead>
<tbody>
{units.map((u) => (
<tr key={u.id}>
<td className="strong">{u.name}{u.is_builtin && <span className="badge" style={{ marginLeft: 6 }}>eingebaut</span>}</td>
<td className="muted">{KIND_LABEL[u.kind] || u.kind}</td>
<td className="num">{fmt(u.factor)} {baseUnitOfKind[u.kind]}</td>
<td className="num">
{!u.is_builtin && (
<button className="btn-icon danger" onClick={() => remove(u)} title="Löschen">
<Icon name="trash" size={16} />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<form className="card" onSubmit={add}>
<div className="card-head"><Icon name="settings" /><h2>Neue Einheit</h2></div>
<label>
Name
<input placeholder="z.B. Pfund" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label>
<label>
Art
<select value={form.kind} onChange={(e) => setForm({ ...form, kind: e.target.value })}>
<option value="count">Anzahl (Stück)</option>
<option value="weight">Gewicht (Basis: Gramm)</option>
<option value="volume">Volumen (Basis: Milliliter)</option>
</select>
</label>
<label>
Faktor zur Basiseinheit
<input type="number" step="any" min="0" value={form.factor}
onChange={(e) => setForm({ ...form, factor: e.target.value })} required
placeholder={`z.B. 500 (= 500 ${baseUnitOfKind[form.kind]})`} />
</label>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
</form>
</div>
</div>
);
}

View File

@@ -69,3 +69,23 @@ export function amountText(qty, packageSize, baseUnit) {
}
return `${fmt(qty)} ${unitShort(baseUnit)}`;
}
// Einheiten-Optionen fürs Ein-/Auslagern: verwaltete Einheiten der Produkt-Art + Packung.
export function buildUnitOptions(product, units) {
const opts = (units || [])
.filter((u) => u.kind === product.kind)
.map((u) => ({ value: u.name, label: u.name }));
if (product.package_size) {
opts.push({
value: "package",
label: `Packung (${fmt(product.package_size)} ${unitShort(product.base_unit)})`,
});
}
return opts;
}
// Bestand eines Produkts in seiner Anzeigeeinheit, z.B. "1,5 Kilogramm".
export function stockLabel(product) {
const factor = product.unit_factor || 1;
return `${fmt((product.stock || 0) / factor)} ${product.unit_name || unitShort(product.base_unit)}`;
}