Files
Vorrania/web/src/components/ObjektBestand.jsx
Scarriffle 5b952524d7 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>
2026-07-25 15:33:56 +02:00

296 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}