feat(objekt): Zugfahrzeug-Flag am Objekttyp + Leitungs-Dashboard-Kacheln
CI / backend-tests (push) Failing after 1m26s
CI / frontend-build (push) Successful in 24s

Objekttyp.ist_zugfahrzeug (Migration 0011) grenzt die Fahrzeug-Zuordnung
serverseitig auf tatsächliche Zugfahrzeuge ein (422 bei Verstoß), Frontend
filtert die Fahrzeug-Dropdowns entsprechend.

Neue Dashboard-Endpunkte /dashboard/einsatzbereitschaft und
/dashboard/prueftermine plus DashboardPage mit drei Kacheln
(Einsatzbereitschaft, Prüftermine, Ablaufdaten). Aktive
Mindermengen-Genehmigung zählt als "eingeschränkt einsatzbereit", nicht als
voll einsatzbereit (U2/Karte 08).

Neue FehlbestandListePage: Fehlbestände eigenständig nachfüllen/Mindermenge
genehmigen, ohne komplette Kontrolle des Objekts zu starten (Backend
existierte bereits, nur das Frontend fehlte).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC8HYvv6UkCVYheYiTw9DD
This commit is contained in:
2026-09-05 10:33:00 +02:00
co-authored by Claude Sonnet 5
parent 38b8ce1795
commit 54fc29690a
20 changed files with 894 additions and 19 deletions
+1
View File
@@ -143,6 +143,7 @@ export function AdminPage() {
{aktiverTab === "objekte" && (
<ObjektSection
objekte={objekte}
objekttypen={objekttypen}
vorlagen={vorlagen.filter((v) => v.status === "aktiv")}
standorte={standorte}
materialien={materialien}
+161
View File
@@ -0,0 +1,161 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { apiRequest } from "../api/client";
interface ObjektMangel {
objekt_id: number;
code: string;
name: string;
gruende: string[];
}
interface Einsatzbereitschaft {
einsatzbereit: number;
eingeschraenkt_einsatzbereit: number;
nicht_einsatzbereit: number;
gesamt: number;
details: ObjektMangel[];
}
interface Prueftermineintrag {
geraet_instanz_id: string;
objekt_id: number;
material_name: string;
seriennummer: string;
naechste_pruefung: string;
verbleibende_tage: number;
status: string;
}
interface Ablaufdatumeintrag {
objekt_id: number;
material_id: number;
ablaufdatum: string;
verbleibende_tage: number;
status: string;
}
const GRUND_TEXT: Record<string, string> = {
fehlbestand_offen: "Offener Fehlbestand",
ablaufdatum_abgelaufen: "Ablaufdatum überschritten",
pruefung_ueberfaellig: "Geräteprüfung überfällig",
kontrolle_laeuft: "Kontrolle läuft (gesperrt)",
mindermenge_genehmigt: "Mindermenge genehmigt",
};
// Leitungs-Dashboard (Karte-Nachtrag): zwei neue Kacheln (Einsatzbereitschaft,
// Prüftermine) plus die bestehende Ablaufdaten-Kachel, alle drei eigenständig -
// widersprechen sich nicht, da überfällige Prüfung/Ablauf beide zusätzlich als
// Grund in die Einsatzbereitschaft einfließen.
export function DashboardPage() {
const [bereitschaft, setBereitschaft] = useState<Einsatzbereitschaft | null>(null);
const [prueftermine, setPrueftermine] = useState<Prueftermineintrag[]>([]);
const [ablaufdaten, setAblaufdaten] = useState<Ablaufdatumeintrag[]>([]);
const [laedt, setLaedt] = useState(true);
const [fehler, setFehler] = useState<string | null>(null);
const [detailsOffen, setDetailsOffen] = useState(false);
useEffect(() => {
Promise.all([
apiRequest<Einsatzbereitschaft>("/dashboard/einsatzbereitschaft"),
apiRequest<Prueftermineintrag[]>("/dashboard/prueftermine"),
apiRequest<Ablaufdatumeintrag[]>("/dashboard/ablaufdaten"),
])
.then(([b, p, a]) => {
setBereitschaft(b);
setPrueftermine(p);
setAblaufdaten(a);
})
.catch(() => setFehler("Dashboard-Daten konnten nicht geladen werden."))
.finally(() => setLaedt(false));
}, []);
return (
<main className="page">
<h1>Dashboard</h1>
{fehler && <div className="alert alert-danger">{fehler}</div>}
{laedt && <p className="text-muted">Lade</p>}
{!laedt && bereitschaft && (
<div style={{ display: "flex", gap: "1.25rem", flexWrap: "wrap" }}>
<div className="card" style={{ flex: "1 1 18rem" }}>
<h3 style={{ marginTop: 0 }}>Einsatzbereites Material</h3>
<p style={{ fontSize: "1.5rem", margin: "0.25rem 0" }}>
{bereitschaft.einsatzbereit} von {bereitschaft.gesamt} Objekten einsatzbereit
</p>
{bereitschaft.eingeschraenkt_einsatzbereit > 0 && (
<p className="text-muted" style={{ color: "#b8860b" }}>
{bereitschaft.eingeschraenkt_einsatzbereit} eingeschränkt einsatzbereit (aktive
Mindermengen-Genehmigung)
</p>
)}
{bereitschaft.nicht_einsatzbereit > 0 && (
<p className="text-muted" style={{ color: "#b00020" }}>
{bereitschaft.nicht_einsatzbereit} nicht einsatzbereit
</p>
)}
{bereitschaft.details.length > 0 && (
<>
<button className="btn btn-secondary" onClick={() => setDetailsOffen(!detailsOffen)}>
{detailsOffen ? "Details ausblenden" : "Details anzeigen"}
</button>
{detailsOffen && (
<ul className="card-list" style={{ marginTop: "0.5rem" }}>
{bereitschaft.details.map((d) => (
<li key={d.objekt_id} className="row-between" style={{ padding: "0.3rem 0" }}>
<Link to={`/objekte/${d.objekt_id}/kontrolle`}>
{d.name} ({d.code})
</Link>
<span className="text-muted">
{d.gruende.map((g) => GRUND_TEXT[g] ?? g).join(", ")}
</span>
</li>
))}
</ul>
)}
</>
)}
</div>
<div className="card" style={{ flex: "1 1 18rem" }}>
<h3 style={{ marginTop: 0 }}>Nächste Prüftermine</h3>
{prueftermine.length === 0 ? (
<p className="text-muted">Keine anstehenden oder überfälligen Geräteprüfungen.</p>
) : (
<ul className="card-list">
{prueftermine.map((p) => (
<li key={p.geraet_instanz_id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
{p.material_name} <span className="text-muted">(SN {p.seriennummer})</span>
</span>
<span className={p.status === "abgelaufen" ? "text-muted" : "text-muted"} style={{ color: p.status === "abgelaufen" ? "#b00020" : undefined }}>
{p.naechste_pruefung} ({p.verbleibende_tage} Tage)
</span>
</li>
))}
</ul>
)}
</div>
<div className="card" style={{ flex: "1 1 18rem" }}>
<h3 style={{ marginTop: 0 }}>Bevorstehende Ablaufdaten</h3>
{ablaufdaten.length === 0 ? (
<p className="text-muted">Keine bald ablaufenden Chargen.</p>
) : (
<ul className="card-list">
{ablaufdaten.map((a) => (
<li key={`${a.objekt_id}-${a.material_id}`} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>Objekt #{a.objekt_id} Material #{a.material_id}</span>
<span className="text-muted" style={{ color: a.status === "abgelaufen" ? "#b00020" : undefined }}>
{a.ablaufdatum} ({a.verbleibende_tage} Tage)
</span>
</li>
))}
</ul>
)}
</div>
</div>
)}
</main>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useState } from "react";
import { apiRequest } from "../api/client";
import { useAuth } from "../auth/AuthContext";
import type { Fehlbestand, Material, Objekt } from "../api/types";
// Eigenständige Abarbeitung offener Fehlbestände (Nutzer-Vorgabe): Nachfüllen/
// Mindermenge-Genehmigung sollen ohne komplette Kontrolle möglich sein - Backend
// (GET /fehlbestaende, POST .../nachfuellungen, POST .../mindermenge) existierte
// bereits, hier nur das fehlende Frontend dafür.
export function FehlbestandListePage() {
const { istVerantwortlich } = useAuth();
const [fehlbestaende, setFehlbestaende] = useState<Fehlbestand[]>([]);
const [objekte, setObjekte] = useState<Objekt[]>([]);
const [materialien, setMaterialien] = useState<Material[]>([]);
const [laedt, setLaedt] = useState(true);
const [fehler, setFehler] = useState<string | null>(null);
const [nachfuellMengen, setNachfuellMengen] = useState<Record<string, string>>({});
const [begruendungen, setBegruendungen] = useState<Record<string, string>>({});
const [nurOffene, setNurOffene] = useState(true);
async function laden() {
setLaedt(true);
try {
const [fb, obj, mat] = await Promise.all([
apiRequest<Fehlbestand[]>("/fehlbestaende"),
apiRequest<Objekt[]>("/objekte"),
apiRequest<Material[]>("/materialien"),
]);
setFehlbestaende(fb);
setObjekte(obj);
setMaterialien(mat);
} catch {
setFehler("Fehlbestände konnten nicht geladen werden.");
} finally {
setLaedt(false);
}
}
useEffect(() => {
laden();
}, []);
function objektName(id: number): string {
const o = objekte.find((x) => x.id === id);
return o ? `${o.name} (${o.code})` : `#${id}`;
}
function materialName(id: number): string {
const m = materialien.find((x) => x.id === id);
return m ? m.name : `#${id}`;
}
async function nachfuellen(fb: Fehlbestand) {
const menge = nachfuellMengen[fb.id];
if (!menge) {
setFehler("Menge eingeben.");
return;
}
try {
await apiRequest(`/fehlbestaende/${fb.id}/nachfuellungen`, {
method: "POST",
body: { menge },
});
setNachfuellMengen((m) => ({ ...m, [fb.id]: "" }));
await laden();
} catch {
setFehler("Nachfüllung konnte nicht erfasst werden.");
}
}
async function mindermengeGenehmigen(fb: Fehlbestand) {
const begruendung = begruendungen[fb.id];
if (!begruendung) {
setFehler("Begründung eingeben.");
return;
}
try {
await apiRequest(`/fehlbestaende/${fb.id}/mindermenge`, {
method: "POST",
body: { begruendung },
});
setBegruendungen((b) => ({ ...b, [fb.id]: "" }));
await laden();
} catch {
setFehler("Mindermenge konnte nicht genehmigt werden (evtl. bereits genehmigt).");
}
}
const angezeigt = nurOffene
? fehlbestaende.filter((f) => f.status !== "erledigt")
: fehlbestaende;
return (
<main className="page">
<h1>Offene Fehlbestände</h1>
<p className="text-muted">
Nachfüllen oder Mindermenge genehmigen, ohne eine komplette Kontrolle des
Objekts zu starten.
</p>
<label className="row" style={{ alignItems: "center", gap: "0.4rem", marginBottom: "1rem" }}>
<input type="checkbox" checked={nurOffene} onChange={(e) => setNurOffene(e.target.checked)} />
Nur offene/in Bearbeitung anzeigen
</label>
{fehler && <div className="alert alert-danger">{fehler}</div>}
{laedt && <p className="text-muted">Lade</p>}
{!laedt && angezeigt.length === 0 && <p className="text-muted">Keine Fehlbestände.</p>}
<ul className="card-list">
{angezeigt.map((fb) => (
<li key={fb.id} className="card" style={{ marginBottom: "0.75rem" }}>
<div className="row-between">
<span>
<strong>{objektName(fb.objekt_id)}</strong> {materialName(fb.material_id)}
</span>
<span className="badge badge-neutral">{fb.status}</span>
</div>
<div className="text-muted" style={{ margin: "0.4rem 0" }}>
Soll {fb.sollmenge} / Ist {fb.istmenge} Fehlmenge {fb.fehlmenge}
</div>
{fb.status !== "erledigt" && (
<div className="row" style={{ flexWrap: "wrap", gap: "0.5rem" }}>
<input
className="input"
style={{ maxWidth: "8rem" }}
placeholder="Menge"
value={nachfuellMengen[fb.id] ?? ""}
onChange={(e) => setNachfuellMengen((m) => ({ ...m, [fb.id]: e.target.value }))}
/>
<button className="btn btn-primary" onClick={() => nachfuellen(fb)}>
Nachfüllen
</button>
{istVerantwortlich && (
<>
<input
className="input"
placeholder="Begründung Mindermenge"
value={begruendungen[fb.id] ?? ""}
onChange={(e) => setBegruendungen((b) => ({ ...b, [fb.id]: e.target.value }))}
/>
<button className="btn btn-secondary" onClick={() => mindermengeGenehmigen(fb)}>
Mindermenge genehmigen
</button>
</>
)}
</div>
)}
</li>
))}
</ul>
</main>
);
}
+13 -3
View File
@@ -1,11 +1,12 @@
import { useState } from "react";
import { apiRequest } from "../../api/client";
import type { Beladungsvorlage, Material, Objekt, Standort } from "../../api/types";
import type { Beladungsvorlage, Material, Objekt, Objekttyp, Standort } from "../../api/types";
import { ObjektPositionenPanel } from "./ObjektPositionenPanel";
interface Props {
objekte: Objekt[];
objekttypen: Objekttyp[];
vorlagen: Beladungsvorlage[];
standorte: Standort[];
materialien: Material[];
@@ -21,6 +22,7 @@ interface Props {
export function ObjektSection({
objekte,
objekttypen,
vorlagen,
standorte,
materialien,
@@ -45,6 +47,14 @@ export function ObjektSection({
const [fahrzeugAuswahl, setFahrzeugAuswahl] = useState<number | "">("");
const [suche, setSuche] = useState("");
// Option A+C (Karte 11 Nachtrag): Dropdown zeigt nur Zugfahrzeuge (Objekttyp-
// Flag ist_zugfahrzeug) ohne eigene Kopplung - serverseitig zusätzlich per
// 422 erzwungen, das ist hier nur die Vorauswahl fürs Formular.
const zugfahrzeuge = objekte.filter((x) => {
const typ = objekttypen.find((t) => t.id === x.objekttyp_id);
return typ?.ist_zugfahrzeug && x.fahrzeug_id === null;
});
function objektName(id: number | null): string {
if (id === null) return "";
const gefunden = objekte.find((x) => x.id === id);
@@ -233,7 +243,7 @@ export function ObjektSection({
onChange={(e) => setFahrzeugId(Number(e.target.value) || "")}
>
<option value="">Kein Fahrzeug</option>
{objekte.map((x) => (
{zugfahrzeuge.map((x) => (
<option key={x.id} value={x.id}>
{x.name} ({x.code})
</option>
@@ -295,7 +305,7 @@ export function ObjektSection({
onChange={(e) => setFahrzeugAuswahl(Number(e.target.value) || "")}
>
<option value="">Kein Fahrzeug</option>
{objekte
{zugfahrzeuge
.filter((x) => x.id !== o.id)
.map((x) => (
<option key={x.id} value={x.id}>
+29 -3
View File
@@ -18,15 +18,21 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [bearbeiteteId, setBearbeiteteId] = useState<number | null>(null);
const [bearbeitungsName, setBearbeitungsName] = useState("");
const [istZugfahrzeug, setIstZugfahrzeug] = useState(false);
const [bearbeitungsZugfahrzeug, setBearbeitungsZugfahrzeug] = useState(false);
function bearbeiten(o: Objekttyp) {
setBearbeiteteId(o.id);
setBearbeitungsName(o.name);
setBearbeitungsZugfahrzeug(o.ist_zugfahrzeug);
}
async function speichern(id: number) {
try {
await apiRequest(`/objekttypen/${id}`, { method: "PATCH", body: { name: bearbeitungsName } });
await apiRequest(`/objekttypen/${id}`, {
method: "PATCH",
body: { name: bearbeitungsName, ist_zugfahrzeug: bearbeitungsZugfahrzeug },
});
setBearbeiteteId(null);
await onGeaendert();
} catch {
@@ -43,9 +49,10 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
try {
await apiRequest("/objekttypen", {
method: "POST",
body: { name, bereich_id: bereichId, kategorie_id: kategorieId || null },
body: { name, bereich_id: bereichId, kategorie_id: kategorieId || null, ist_zugfahrzeug: istZugfahrzeug },
});
setName("");
setIstZugfahrzeug(false);
await onGeaendert();
} catch {
onFehler("Objekttyp konnte nicht angelegt werden.");
@@ -82,6 +89,14 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
</div>
<div className="row">
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" />
<label className="row" style={{ alignItems: "center", gap: "0.4rem" }}>
<input
type="checkbox"
checked={istZugfahrzeug}
onChange={(e) => setIstZugfahrzeug(e.target.checked)}
/>
Zugfahrzeug (koppelbar)
</label>
<button className="btn btn-primary" onClick={anlegen} disabled={!name || wirdAngelegt}>
Anlegen
</button>
@@ -99,6 +114,14 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
value={bearbeitungsName}
onChange={(e) => setBearbeitungsName(e.target.value)}
/>
<label className="row" style={{ alignItems: "center", gap: "0.4rem" }}>
<input
type="checkbox"
checked={bearbeitungsZugfahrzeug}
onChange={(e) => setBearbeitungsZugfahrzeug(e.target.checked)}
/>
Zugfahrzeug
</label>
<button className="btn btn-primary" onClick={() => speichern(o.id)}>
Speichern
</button>
@@ -108,7 +131,10 @@ export function ObjekttypSection({ bereiche, kategorien, objekttypen, onGeaender
</div>
) : (
<>
<span>{o.name}</span>
<span>
{o.name}
{o.ist_zugfahrzeug && <span className="text-muted"> (Zugfahrzeug)</span>}
</span>
<button className="btn btn-secondary" onClick={() => bearbeiten(o)}>
Bearbeiten
</button>