Karte 14 Schritt 2 (Admin-Teil) + Frontend für Anhänger-Kopplung
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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user