Bestückung eines Objekts vollständig pflegbar (Sollmenge/Status/Neuanlage)
CI / backend-tests (push) Failing after 1m18s
CI / frontend-build (push) Successful in 19s

ObjektPositionenPanel zeigte bisher nur ablauf_charge/geraet_sn-Positionen
und konnte weder Sollmenge-Override noch Status noch neues Material ändern
- Backend-Endpunkte (PATCH/POST .../positionen) existierten schon, waren im
Frontend nicht angebunden.

Jetzt: alle Positionen sichtbar, Sollmenge-Override je Position, Entfernen/
Reaktivieren (ist_status), zusätzliches Material hinzufügen (Dropdown mit
noch nicht vorhandenen Materialien + Sollmenge).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-04 22:35:24 +02:00
co-authored by Claude Sonnet 5
parent a2b201ddb8
commit a7b670f6f8
2 changed files with 173 additions and 63 deletions
@@ -11,6 +11,7 @@ interface Props {
}
interface ZeilenFormular {
sollmengeOverride: string;
ablaufdatum: string;
chargennummer: string;
pruefintervallMonate: string;
@@ -18,6 +19,7 @@ interface ZeilenFormular {
function leeresFormular(p: Objektposition): ZeilenFormular {
return {
sollmengeOverride: p.sollmenge_override ?? "",
ablaufdatum: p.ablaufdatum ?? "",
chargennummer: p.chargennummer ?? "",
pruefintervallMonate: p.pruefintervall_monate?.toString() ?? "",
@@ -25,24 +27,34 @@ function leeresFormular(p: Objektposition): ZeilenFormular {
}
/**
* Prompt 10: Ablaufdatum/Chargennummer (materialtyp 'ablauf_charge') und
* Seriennummer (materialtyp 'geraet_sn') gehören zur Objektposition, nicht
* zur Kontrolle selbst (Vier-Kernbegriffe: Kontrolle erfasst nur die Menge).
* Backend-Endpoint (PATCH .../positionen/{id}) existierte schon, war im
* Frontend bisher nirgends angebunden.
* Bestückung eines Objekts pflegen (Prompt 10): Sollmenge-Override je Position
* (Abweichung vom Vorlagen-Soll), Position entfernen/reaktivieren, zusätzliches
* Material hinzufügen das nicht in der Vorlage steht - plus Ablaufdatum/Charge/
* Prüfintervall/Geräte-Instanzen für die entsprechenden Materialtypen. Backend-
* Endpunkte existierten schon (PATCH/POST .../positionen), waren im Frontend
* bisher nur für ablauf_charge/geraet_sn und nicht für Sollmenge/Status/Neuanlage
* angebunden.
*/
export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props) {
const [positionen, setPositionen] = useState<Objektposition[]>([]);
const [formulare, setFormulare] = useState<Record<string, ZeilenFormular>>({});
const [speichertId, setSpeichertId] = useState<string | null>(null);
const [neuesMaterialId, setNeuesMaterialId] = useState<number | "">("");
const [neueSollmenge, setNeueSollmenge] = useState("");
const [wirdHinzugefuegt, setWirdHinzugefuegt] = useState(false);
async function laden() {
try {
const liste = await apiRequest<Objektposition[]>(`/objekte/${objektId}/positionen`);
setPositionen(liste);
setFormulare(Object.fromEntries(liste.map((p) => [p.id, leeresFormular(p)])));
} catch {
onFehler("Positionen konnten nicht geladen werden.");
}
}
useEffect(() => {
apiRequest<Objektposition[]>(`/objekte/${objektId}/positionen`)
.then((liste) => {
setPositionen(liste);
setFormulare(Object.fromEntries(liste.map((p) => [p.id, leeresFormular(p)])));
})
.catch(() => onFehler("Positionen konnten nicht geladen werden."));
laden();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [objektId]);
@@ -61,6 +73,7 @@ export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props
const aktualisiert = await apiRequest<Objektposition>(`/objekte/${objektId}/positionen/${position.id}`, {
method: "PATCH",
body: {
sollmenge_override: formular.sollmengeOverride === "" ? null : Number(formular.sollmengeOverride),
ablaufdatum: formular.ablaufdatum || null,
chargennummer: formular.chargennummer || null,
pruefintervall_monate: formular.pruefintervallMonate ? Number(formular.pruefintervallMonate) : null,
@@ -74,73 +87,157 @@ export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props
}
}
const relevante = positionen.filter((p) => materialtyp(p.material_id) !== "standard");
if (relevante.length === 0) {
return <p className="text-muted">Kein Material mit Ablaufdatum/Charge oder Seriennummer in diesem Objekt.</p>;
async function statusUmschalten(position: Objektposition) {
const neuerStatus = position.ist_status === "aktiv" ? "entfernt" : "aktiv";
try {
const aktualisiert = await apiRequest<Objektposition>(`/objekte/${objektId}/positionen/${position.id}`, {
method: "PATCH",
body: { ist_status: neuerStatus },
});
setPositionen((bisherige) => bisherige.map((p) => (p.id === position.id ? aktualisiert : p)));
} catch {
onFehler("Status konnte nicht geändert werden.");
}
}
async function materialHinzufuegen() {
if (neuesMaterialId === "" || !neueSollmenge) return;
setWirdHinzugefuegt(true);
try {
await apiRequest(`/objekte/${objektId}/positionen`, {
method: "POST",
body: { material_id: neuesMaterialId, sollmenge_override: Number(neueSollmenge) },
});
setNeuesMaterialId("");
setNeueSollmenge("");
await laden();
} catch {
onFehler("Material konnte nicht hinzugefügt werden (evtl. schon vorhanden).");
} finally {
setWirdHinzugefuegt(false);
}
}
const vorhandeneMaterialIds = new Set(positionen.map((p) => p.material_id));
const verfuegbareMaterialien = materialien.filter((m) => !vorhandeneMaterialIds.has(m.id));
return (
<ul className="card-list">
{relevante.map((p) => {
const typ = materialtyp(p.material_id);
const formular = formulare[p.id] ?? leeresFormular(p);
return (
<li key={p.id} className="card">
<div className="card-title-row">
<strong>{materialName(p.material_id)}</strong>
<span className="badge badge-neutral">{typ}</span>
</div>
<div className="row" style={{ marginTop: "0.6rem" }}>
{typ === "ablauf_charge" && (
<>
<div>
<div className="row" style={{ flexWrap: "wrap", marginBottom: "1rem" }}>
<select
className="input"
value={neuesMaterialId}
onChange={(e) => setNeuesMaterialId(Number(e.target.value) || "")}
>
<option value="">Material hinzufügen</option>
{verfuegbareMaterialien.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
<input
type="number"
min="0"
className="input"
style={{ maxWidth: "8rem" }}
placeholder="Sollmenge"
value={neueSollmenge}
onChange={(e) => setNeueSollmenge(e.target.value)}
/>
<button
className="btn btn-secondary"
onClick={materialHinzufuegen}
disabled={neuesMaterialId === "" || !neueSollmenge || wirdHinzugefuegt}
>
Hinzufügen
</button>
</div>
{positionen.length === 0 && <p className="text-muted">Keine Positionen in diesem Objekt.</p>}
<ul className="card-list">
{positionen.map((p) => {
const typ = materialtyp(p.material_id);
const formular = formulare[p.id] ?? leeresFormular(p);
const entfernt = p.ist_status === "entfernt";
return (
<li key={p.id} className="card" style={entfernt ? { opacity: 0.6 } : undefined}>
<div className="card-title-row">
<strong>{materialName(p.material_id)}</strong>
<span className="row" style={{ gap: "0.4rem" }}>
<span className="badge badge-neutral">{typ}</span>
{entfernt && <span className="badge badge-danger">entfernt</span>}
</span>
</div>
<div className="card-subtitle">Soll (Vorlage/aktuell wirksam): {p.sollmenge_effektiv}</div>
<div className="row" style={{ marginTop: "0.6rem", flexWrap: "wrap" }}>
<div className="field" style={{ maxWidth: "10rem" }}>
<label>Sollmenge-Override</label>
<input
type="date"
type="number"
min="0"
className="input"
value={formular.ablaufdatum}
placeholder="wie Vorlage"
value={formular.sollmengeOverride}
onChange={(e) =>
setFormulare((f) => ({ ...f, [p.id]: { ...formular, ablaufdatum: e.target.value } }))
setFormulare((f) => ({ ...f, [p.id]: { ...formular, sollmengeOverride: e.target.value } }))
}
/>
</div>
{typ === "ablauf_charge" && (
<>
<input
type="date"
className="input"
value={formular.ablaufdatum}
onChange={(e) =>
setFormulare((f) => ({ ...f, [p.id]: { ...formular, ablaufdatum: e.target.value } }))
}
/>
<input
className="input"
placeholder="Chargennummer"
value={formular.chargennummer}
onChange={(e) =>
setFormulare((f) => ({ ...f, [p.id]: { ...formular, chargennummer: e.target.value } }))
}
/>
</>
)}
{typ === "geraet_sn" && (
<input
type="number"
min="0"
className="input"
placeholder="Chargennummer"
value={formular.chargennummer}
placeholder="Prüfintervall (Monate)"
value={formular.pruefintervallMonate}
onChange={(e) =>
setFormulare((f) => ({ ...f, [p.id]: { ...formular, chargennummer: e.target.value } }))
setFormulare((f) => ({
...f,
[p.id]: { ...formular, pruefintervallMonate: e.target.value },
}))
}
/>
</>
)}
)}
<button className="btn btn-primary" onClick={() => speichern(p)} disabled={speichertId === p.id}>
Speichern
</button>
<button className="btn btn-secondary" onClick={() => statusUmschalten(p)}>
{entfernt ? "Reaktivieren" : "Entfernen"}
</button>
</div>
{typ === "geraet_sn" && (
<input
type="number"
min="0"
className="input"
placeholder="Prüfintervall (Monate)"
value={formular.pruefintervallMonate}
onChange={(e) =>
setFormulare((f) => ({
...f,
[p.id]: { ...formular, pruefintervallMonate: e.target.value },
}))
}
<GeraeteInstanzenListe
objektpositionId={p.id}
pruefintervallMonate={p.pruefintervall_monate}
onFehler={onFehler}
/>
)}
<button className="btn btn-primary" onClick={() => speichern(p)} disabled={speichertId === p.id}>
{typ === "geraet_sn" ? "Prüfintervall speichern" : "Speichern"}
</button>
</div>
{typ === "geraet_sn" && (
<GeraeteInstanzenListe
objektpositionId={p.id}
pruefintervallMonate={p.pruefintervall_monate}
onFehler={onFehler}
/>
)}
</li>
);
})}
</ul>
</li>
);
})}
</ul>
</div>
);
}