Karte 14 Schritt 2 (Admin-Teil) + Frontend für Anhänger-Kopplung
CI / backend-tests (push) Failing after 1m21s
CI / frontend-build (push) Successful in 22s

Backend: geraet_instanz-CRUD (Liste/Anlegen/Status ändern inkl. optionalem
Fehlbestand pro Gerät/Löschen), pruefintervall_monate in Objektposition-
Schemas verdrahtet (Prüfdatum -> naechste_pruefung wird automatisch
berechnet, Monatsrechnung ohne neue Dependency).

Frontend: GeraeteInstanzenListe ersetzt das Einzel-SN-Feld im Admin-Portal
(ObjektPositionenPanel) durch eine Instanz-Liste mit Status/Prüfdatum/
Bemerkung je Exemplar - löst das Karte-14-Kernproblem (mehrere Geräte pro
Position). ObjektSection: Anhänger-Kopplung bei Anlage und nachträglich
änderbar.

Kontroll-Erfassung (PositionCard/KontrollPage) bleibt unverändert - die
komplett neue Erfassungs-UI für geraet_sn (Umsetzungsschritt 4, Karte 14)
ist bewusst nicht Teil dieses Commits.

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 20:55:09 +02:00
co-authored by Claude Sonnet 5
parent eff9fe4956
commit 6934276e8a
14 changed files with 685 additions and 7 deletions
@@ -0,0 +1,177 @@
import { useEffect, useState } from "react";
import { apiRequest } from "../../api/client";
import type { GeraetInstanz } from "../../api/types";
interface Props {
objektpositionId: string;
pruefintervallMonate: number | null;
onFehler: (text: string) => void;
}
const STATUS_LABEL: Record<GeraetInstanz["status"], string> = {
einsatzbereit: "OK",
defekt: "Defekt",
in_reparatur: "In Reparatur",
};
/** Karte 14: löst das Einzelfeld objektposition.seriennummer ab - beliebig
* viele Geräte-Exemplare pro Position (z.B. 2 Pulsoxymeter im selben
* Rucksack), je mit eigenem Prüfdatum/Status. */
export function GeraeteInstanzenListe({ objektpositionId, pruefintervallMonate, onFehler }: Props) {
const [instanzen, setInstanzen] = useState<GeraetInstanz[]>([]);
const [neueSeriennummer, setNeueSeriennummer] = useState("");
const [bearbeiteId, setBearbeiteId] = useState<string | null>(null);
const [statusAuswahl, setStatusAuswahl] = useState<GeraetInstanz["status"]>("einsatzbereit");
const [pruefdatum, setPruefdatum] = useState("");
const [bemerkung, setBemerkung] = useState("");
const [erzeugeFehlbestand, setErzeugeFehlbestand] = useState(false);
async function laden() {
try {
const daten = await apiRequest<GeraetInstanz[]>(`/objektpositionen/${objektpositionId}/geraete`);
setInstanzen(daten);
} catch {
onFehler("Geräte-Liste konnte nicht geladen werden.");
}
}
useEffect(() => {
laden();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [objektpositionId]);
async function hinzufuegen() {
if (!neueSeriennummer) return;
try {
await apiRequest(`/objektpositionen/${objektpositionId}/geraete`, {
method: "POST",
body: { seriennummer: neueSeriennummer },
});
setNeueSeriennummer("");
await laden();
} catch {
onFehler("Gerät konnte nicht hinzugefügt werden (Seriennummer evtl. schon vergeben).");
}
}
function bearbeiten(instanz: GeraetInstanz) {
setBearbeiteId(bearbeiteId === instanz.id ? null : instanz.id);
setStatusAuswahl(instanz.status);
setPruefdatum("");
setBemerkung(instanz.bemerkung ?? "");
setErzeugeFehlbestand(false);
}
async function speichernStatus(instanz: GeraetInstanz) {
try {
await apiRequest(`/geraete/${instanz.id}`, {
method: "PATCH",
body: {
status: statusAuswahl,
pruefdatum: pruefdatum || null,
bemerkung: bemerkung || null,
erzeuge_fehlbestand: statusAuswahl !== "einsatzbereit" && erzeugeFehlbestand,
},
});
setBearbeiteId(null);
await laden();
} catch {
onFehler("Status konnte nicht geändert werden.");
}
}
async function entfernen(id: string) {
try {
await apiRequest(`/geraete/${id}`, { method: "DELETE" });
await laden();
} catch {
onFehler("Gerät konnte nicht entfernt werden.");
}
}
return (
<div style={{ marginTop: "0.5rem" }}>
{pruefintervallMonate === null && (
<p className="text-muted" style={{ fontSize: "0.85rem" }}>
Kein Prüfintervall gesetzt Prüfdatum wird gespeichert, nächste Fälligkeit aber nicht berechnet.
</p>
)}
<ul className="card-list">
{instanzen.map((instanz) => (
<li key={instanz.id} style={{ padding: "0.35rem 0" }}>
<div className="row-between">
<span>
{instanz.status === "einsatzbereit" ? "✓" : "✗"} {instanz.seriennummer}{" "}
<span className="badge badge-neutral">{STATUS_LABEL[instanz.status]}</span>{" "}
{instanz.naechste_pruefung && (
<span className="text-muted">nächste Prüfung: {instanz.naechste_pruefung}</span>
)}
</span>
<div className="row">
<button className="btn btn-secondary" onClick={() => bearbeiten(instanz)}>
{bearbeiteId === instanz.id ? "Schließen" : "Bearbeiten"}
</button>
<button className="btn btn-danger" onClick={() => entfernen(instanz.id)}>
Entfernen
</button>
</div>
</div>
{bearbeiteId === instanz.id && (
<div className="stack" style={{ marginTop: "0.4rem", paddingLeft: "1rem" }}>
<div className="row" style={{ flexWrap: "wrap" }}>
<select
className="input"
value={statusAuswahl}
onChange={(e) => setStatusAuswahl(e.target.value as GeraetInstanz["status"])}
>
<option value="einsatzbereit">Einsatzbereit</option>
<option value="defekt">Defekt</option>
<option value="in_reparatur">In Reparatur</option>
</select>
<input
type="date"
className="input"
value={pruefdatum}
onChange={(e) => setPruefdatum(e.target.value)}
placeholder="Prüfdatum"
/>
<input
className="input"
value={bemerkung}
onChange={(e) => setBemerkung(e.target.value)}
placeholder="Bemerkung"
/>
</div>
{statusAuswahl !== "einsatzbereit" && (
<label className="row" style={{ gap: "0.4rem" }}>
<input
type="checkbox"
checked={erzeugeFehlbestand}
onChange={(e) => setErzeugeFehlbestand(e.target.checked)}
/>
Fehlbestand erzeugen
</label>
)}
<button className="btn btn-primary" onClick={() => speichernStatus(instanz)}>
Speichern
</button>
</div>
)}
</li>
))}
</ul>
<div className="row" style={{ marginTop: "0.5rem" }}>
<input
className="input"
placeholder="Neue Seriennummer"
value={neueSeriennummer}
onChange={(e) => setNeueSeriennummer(e.target.value)}
/>
<button className="btn btn-secondary" onClick={hinzufuegen} disabled={!neueSeriennummer}>
Gerät hinzufügen
</button>
</div>
</div>
);
}
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { apiRequest } from "../../api/client";
import type { Material, Objektposition } from "../../api/types";
import { GeraeteInstanzenListe } from "./GeraeteInstanzenListe";
interface Props {
objektId: number;
@@ -12,14 +13,14 @@ interface Props {
interface ZeilenFormular {
ablaufdatum: string;
chargennummer: string;
seriennummer: string;
pruefintervallMonate: string;
}
function leeresFormular(p: Objektposition): ZeilenFormular {
return {
ablaufdatum: p.ablaufdatum ?? "",
chargennummer: p.chargennummer ?? "",
seriennummer: p.seriennummer ?? "",
pruefintervallMonate: p.pruefintervall_monate?.toString() ?? "",
};
}
@@ -62,7 +63,7 @@ export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props
body: {
ablaufdatum: formular.ablaufdatum || null,
chargennummer: formular.chargennummer || null,
seriennummer: formular.seriennummer || null,
pruefintervall_monate: formular.pruefintervallMonate ? Number(formular.pruefintervallMonate) : null,
},
});
setPositionen((bisherige) => bisherige.map((p) => (p.id === position.id ? aktualisiert : p)));
@@ -113,18 +114,30 @@ export function ObjektPositionenPanel({ objektId, materialien, onFehler }: Props
)}
{typ === "geraet_sn" && (
<input
type="number"
min="0"
className="input"
placeholder="Seriennummer"
value={formular.seriennummer}
placeholder="Prüfintervall (Monate)"
value={formular.pruefintervallMonate}
onChange={(e) =>
setFormulare((f) => ({ ...f, [p.id]: { ...formular, seriennummer: 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
{typ === "geraet_sn" ? "Prüfintervall speichern" : "Speichern"}
</button>
</div>
{typ === "geraet_sn" && (
<GeraeteInstanzenListe
objektpositionId={p.id}
pruefintervallMonate={p.pruefintervall_monate}
onFehler={onFehler}
/>
)}
</li>
);
})}
@@ -26,6 +26,9 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
const [neuerCode, setNeuerCode] = useState("");
const [neuerName, setNeuerName] = useState("");
const [neuerStandortId, setNeuerStandortId] = useState<number | "">("");
const [gezogenVonId, setGezogenVonId] = useState<number | "">("");
const [kopplungBearbeitenId, setKopplungBearbeitenId] = useState<number | null>(null);
const [kopplungAuswahl, setKopplungAuswahl] = useState<number | "">("");
async function codeVorschlagen() {
if (!praefix) {
@@ -73,11 +76,13 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
objekttyp_id: vorlage?.objekttyp_id,
vorlage_id: vorlageId,
standort_id: standortId,
gezogen_von_objekt_id: gezogenVonId || null,
},
});
setPraefix("");
setCode("");
setName("");
setGezogenVonId("");
await onGeaendert();
} catch {
onFehler("Objekt konnte nicht angelegt werden (Code evtl. schon vergeben).");
@@ -86,6 +91,30 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
}
}
function objektName(id: number | null): string {
if (id === null) return "";
const o = objekte.find((x) => x.id === id);
return o ? `${o.name} (${o.code})` : `Objekt ${id}`;
}
function kopplungBearbeiten(o: Objekt) {
setKopplungBearbeitenId(kopplungBearbeitenId === o.id ? null : o.id);
setKopplungAuswahl(o.gezogen_von_objekt_id ?? "");
}
async function kopplungSpeichern(objektId: number) {
try {
await apiRequest(`/objekte/${objektId}`, {
method: "PATCH",
body: { gezogen_von_objekt_id: kopplungAuswahl || null },
});
setKopplungBearbeitenId(null);
await onGeaendert();
} catch {
onFehler("Kopplung konnte nicht geändert werden.");
}
}
function duplizierenStarten(o: Objekt) {
setDuplizierId(duplizierId === o.id ? null : o.id);
setNeuerPraefix("");
@@ -167,6 +196,21 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
<label>Name</label>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="field">
<label>Gezogen von (optional, Anhänger-Kopplung)</label>
<select
className="input"
value={gezogenVonId}
onChange={(e) => setGezogenVonId(Number(e.target.value) || "")}
>
<option value="">Kein Zugfahrzeug</option>
{objekte.map((o) => (
<option key={o.id} value={o.id}>
{o.name} ({o.code})
</option>
))}
</select>
</div>
<button className="btn btn-primary" onClick={anlegen} disabled={!code || !name || wirdAngelegt}>
Anlegen
</button>
@@ -180,6 +224,9 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
<div className="row-between" style={{ padding: "0.4rem 0" }}>
<span>
{o.name} <span className="text-muted">({o.code})</span>
{o.gezogen_von_objekt_id !== null && (
<span className="text-muted"> gezogen von {objektName(o.gezogen_von_objekt_id)}</span>
)}
</span>
<div className="row">
<button
@@ -188,11 +235,35 @@ export function ObjektSection({ objekte, vorlagen, standorte, materialien, onGea
>
{ausgeklapptId === o.id ? "Positionen ausblenden" : "Ablauf/Charge/SN pflegen"}
</button>
<button className="btn btn-secondary" onClick={() => kopplungBearbeiten(o)}>
{kopplungBearbeitenId === o.id ? "Schließen" : "Kopplung ändern"}
</button>
<button className="btn btn-secondary" onClick={() => duplizierenStarten(o)}>
{duplizierId === o.id ? "Schließen" : "Duplizieren"}
</button>
</div>
</div>
{kopplungBearbeitenId === o.id && (
<div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}>
<select
className="input"
value={kopplungAuswahl}
onChange={(e) => setKopplungAuswahl(Number(e.target.value) || "")}
>
<option value="">Kein Zugfahrzeug</option>
{objekte
.filter((x) => x.id !== o.id)
.map((x) => (
<option key={x.id} value={x.id}>
{x.name} ({x.code})
</option>
))}
</select>
<button className="btn btn-primary" onClick={() => kopplungSpeichern(o.id)}>
Speichern
</button>
</div>
)}
{ausgeklapptId === o.id && (
<div style={{ marginTop: "0.5rem" }}>
<ObjektPositionenPanel objektId={o.id} materialien={materialien} onFehler={onFehler} />