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:
295
web/src/components/ObjektBestand.jsx
Normal file
295
web/src/components/ObjektBestand.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user