Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln

Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende:
Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen
mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder
je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine
verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich
ueber Open Products Facts.

Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in
Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-25 15:33:56 +02:00
parent 580afcc133
commit 5b952524d7
29 changed files with 3116 additions and 115 deletions

View File

@@ -0,0 +1,295 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "./Icon";
import { fmt } from "../units";
// Gründe zum Entfernen (Wert = Backend-Enum, Label = Anzeige).
export const REASONS = [
{ value: "broken", label: "kaputt" },
{ value: "lost", label: "verloren" },
{ value: "given_away", label: "verschenkt" },
{ value: "sold", label: "verkauft" },
{ value: "used_up", label: "aufgebraucht" },
{ value: "other", label: "sonstiges" },
];
const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v;
/**
* Bestand eines Gegenstands je Lagerort das Gegenstück zur Chargen-Karte der
* Lebensmittel. Erlaubt Hinzufügen, Umlagern (ohne Grund) und Entfernen (mit
* Pflicht-Grund) und zeigt eine kleine Entnahme-Statistik.
*/
export default function ObjektBestand({ product, lots, locations, isAdmin, onChanged, onError }) {
const confirm = useConfirm();
const toast = useToast();
const einheit = product?.unit_name || "Stück";
const nameById = Object.fromEntries((locations || []).map((l) => [l.id, l.name]));
const ortName = (id) => (id == null ? "Ohne Lagerort" : nameById[id] || `Ort ${id}`);
const [addForm, setAddForm] = useState({ location_id: "", quantity: "" });
const [move, setMove] = useState(null); // { from, to, quantity } | null
const [remove, setRemove] = useState(null); // { location_id, quantity, reason, note } | null
const [editId, setEditId] = useState(null);
const [editQty, setEditQty] = useState("");
const [removals, setRemovals] = useState({ stats: [], history: [] });
async function ladeRemovals() {
try {
setRemovals(await api.productRemovals(product.id));
} catch { /* Statistik ist optional */ }
}
useEffect(() => { ladeRemovals(); /* eslint-disable-next-line */ }, [product.id, lots]);
async function nachAktion() {
await onChanged();
await ladeRemovals();
}
async function hinzufuegen(e) {
e.preventDefault();
const menge = Number(addForm.quantity);
if (!menge || menge <= 0) return;
try {
await api.checkIn({
product_id: product.id,
quantity: menge,
unit: einheit,
location_id: addForm.location_id === "" ? null : Number(addForm.location_id),
});
setAddForm({ location_id: addForm.location_id, quantity: "" });
await nachAktion();
} catch (err) { onError(err.message); }
}
async function speichereMenge(lot) {
const menge = Number(editQty);
try {
await api.updateLot(lot.id, { quantity: menge });
setEditId(null);
await nachAktion();
} catch (err) { onError(err.message); }
}
async function loescheZeile(lot) {
const ok = await confirm({
title: `Bestand in „${ortName(lot.location_id)}“ entfernen?`,
message: "Die Menge wird als Korrektur im Verlauf protokolliert.",
confirmLabel: "Entfernen", danger: true,
});
if (!ok) return;
try {
await api.deleteLot(lot.id);
await nachAktion();
} catch (err) { onError(err.message); }
}
async function umlagern(e) {
e.preventDefault();
const menge = Number(move.quantity);
if (!menge || menge <= 0) return;
try {
await api.relocateStock({
product_id: product.id,
quantity: menge,
from_location_id: move.from === "" ? null : Number(move.from),
to_location_id: move.to === "" ? null : Number(move.to),
});
setMove(null);
toast("Umgelagert.");
await nachAktion();
} catch (err) { onError(err.message); }
}
async function entfernen(e) {
e.preventDefault();
const menge = Number(remove.quantity);
if (!menge || menge <= 0) return;
try {
await api.removeStock({
product_id: product.id,
quantity: menge,
location_id: remove.location_id === "" ? null : Number(remove.location_id),
reason: remove.reason,
note: remove.note || null,
});
setRemove(null);
toast("Aus dem Bestand entfernt.");
await nachAktion();
} catch (err) { onError(err.message); }
}
const gesamt = lots.reduce((s, l) => s + l.quantity, 0);
return (
<section className="card">
<div className="card-head">
<Icon name="location" /><h2>Bestand je Lagerort</h2>
<span className="muted small" style={{ marginLeft: "auto" }}>
gesamt {fmt(gesamt)} {einheit}
</span>
</div>
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Lagerort</th><th className="num">Menge</th><th></th></tr>
</thead>
<tbody>
{lots.map((l) => (
<tr key={l.id}>
<td data-label="Lagerort">{ortName(l.location_id)}</td>
<td data-label="Menge" className="num">
{editId === l.id ? (
<input type="number" step="any" min="0" value={editQty} style={{ marginTop: 0, width: 90 }}
onChange={(e) => setEditQty(e.target.value)} />
) : (
<>{fmt(l.quantity)} <span className="muted small">{einheit}</span></>
)}
</td>
<td className="num">
{isAdmin && (editId === l.id ? (
<div className="btn-pair">
<button className="btn sm primary" onClick={() => speichereMenge(l)}>OK</button>
<button className="btn sm" onClick={() => setEditId(null)}>Abbrechen</button>
</div>
) : (
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
<button className="btn-icon" title="Menge korrigieren"
onClick={() => { setEditId(l.id); setEditQty(String(l.quantity)); }}>
<Icon name="edit" size={16} />
</button>
<button className="btn-icon" title="Von hier umlagern"
onClick={() => setMove({ from: l.location_id ?? "", to: "", quantity: "" })}>
<Icon name="checkout" size={16} />
</button>
<button className="btn-icon" title="Mit Grund entfernen"
onClick={() => setRemove({ location_id: l.location_id ?? "", quantity: "", reason: "broken", note: "" })}>
<Icon name="trash" size={16} />
</button>
</div>
))}
</td>
</tr>
))}
{lots.length === 0 && <tr><td colSpan={3} className="empty">Noch kein Bestand.</td></tr>}
</tbody>
</table>
</div>
{isAdmin && (
<form onSubmit={hinzufuegen} className="row" style={{ marginTop: "var(--sp-3)" }}>
<label className="grow">
Lagerort
<select value={addForm.location_id}
onChange={(e) => setAddForm({ ...addForm, location_id: e.target.value })}>
<option value=""> ohne Lagerort </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label style={{ width: 120 }}>
Menge
<input type="number" step="any" min="0" value={addForm.quantity}
onChange={(e) => setAddForm({ ...addForm, quantity: e.target.value })} placeholder="z.B. 3" />
</label>
<button className="btn primary" style={{ alignSelf: "end" }}>
<Icon name="plus" size={16} />Hinzufügen
</button>
</form>
)}
{/* Umlagern-Dialog */}
{isAdmin && move && (
<form onSubmit={umlagern} className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
<div className="card-head"><Icon name="checkout" /><h3>Umlagern</h3>
<button type="button" className="btn-icon" style={{ marginLeft: "auto" }}
onClick={() => setMove(null)}><Icon name="close" size={16} /></button>
</div>
<div className="row">
<label className="grow">Von
<select value={move.from} onChange={(e) => setMove({ ...move, from: e.target.value })}>
<option value=""> ohne Lagerort </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label className="grow">Nach
<select value={move.to} onChange={(e) => setMove({ ...move, to: e.target.value })}>
<option value=""> ohne Lagerort </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label style={{ width: 110 }}>Menge
<input type="number" step="any" min="0" value={move.quantity}
onChange={(e) => setMove({ ...move, quantity: e.target.value })} />
</label>
</div>
<button className="btn primary">Umlagern</button>
</form>
)}
{/* Entfernen-Dialog (Grund Pflicht) */}
{isAdmin && remove && (
<form onSubmit={entfernen} className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
<div className="card-head"><Icon name="trash" /><h3>Aus Bestand entfernen</h3>
<button type="button" className="btn-icon" style={{ marginLeft: "auto" }}
onClick={() => setRemove(null)}><Icon name="close" size={16} /></button>
</div>
<div className="row">
<label className="grow">Lagerort
<select value={remove.location_id}
onChange={(e) => setRemove({ ...remove, location_id: e.target.value })}>
<option value=""> ohne Lagerort </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label style={{ width: 110 }}>Menge
<input type="number" step="any" min="0" value={remove.quantity}
onChange={(e) => setRemove({ ...remove, quantity: e.target.value })} />
</label>
<label className="grow">Grund
<select value={remove.reason}
onChange={(e) => setRemove({ ...remove, reason: e.target.value })}>
{REASONS.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</label>
</div>
<label>Notiz (optional)
<input value={remove.note} placeholder="z.B. runtergefallen"
onChange={(e) => setRemove({ ...remove, note: e.target.value })} />
</label>
<button className="btn danger">Entfernen</button>
</form>
)}
{/* Entnahme-Statistik */}
{removals.stats.length > 0 && (
<div style={{ marginTop: "var(--sp-3)" }}>
<div className="feldkopf" style={{ marginBottom: "var(--sp-2)" }}>
<span className="muted small">Bereits entnommen:</span>
{removals.stats.map((s) => (
<span key={s.reason} className="badge nowrap" title={`${s.count}×`}>
{reasonLabel(s.reason)}: {fmt(s.quantity)}
</span>
))}
</div>
{removals.history.length > 0 && (
<ul className="clean-list">
{removals.history.slice(0, 5).map((h, i) => (
<li key={i}>
<span className="badge nowrap">{reasonLabel(h.reason)}</span>
<span>{fmt(h.quantity)} {einheit}</span>
{h.location_name && <span className="muted small">aus {h.location_name}</span>}
{h.note && <span className="muted small"> {h.note}</span>}
<span className="muted small" style={{ marginLeft: "auto" }}>
{new Date(h.created_at).toLocaleDateString("de-DE")}
</span>
</li>
))}
</ul>
)}
</div>
)}
</section>
);
}