feat(fahrzeug): Fahrzeug-Spezialfelder + 5-stufiger Status (Roadmap Phase 3)
CI / backend-tests (push) Failing after 1m40s
CI / frontend-build (push) Successful in 17s

Neue Tabelle fahrzeugdetails (1:1 zu Objekt, additiv/optional): Kennzeichen,
Funkrufname, Baujahr, Kilometerstand, Betriebsstunden, nächste HU/UVV.
objekt_status um 'in_wartung' erweitert (Nutzer-Vorgabe: einsatzbereit/
eingeschränkt/nicht einsatzbereit/in Wartung/außer Dienst - die ersten drei
bleiben weiterhin rein rechnerisch über dashboard.einsatzbereitschaft(),
in_wartung/ausser_dienst sind die beiden manuell gesetzten Zustände).

dashboard.einsatzbereitschaft(): ausser_dienst-Objekte zählen nicht mehr mit,
in_wartung und überfällige HU/UVV sind neue Gründe für "nicht einsatzbereit".

dashboard.bevorstehende_prueftermine() (Kachel 2) zeigt jetzt Geräte- UND
Fahrzeugprüfungen (HU/UVV) in einer Liste (Nutzer-Vorgabe: ein Konzept für
"welche Prüfungen bald fällig sind") - Schema dafür verallgemeinert
(typ/bezeichnung/faelligkeitsdatum statt geräte-spezifischer Felder).

Frontend: Status-Dropdown + Fahrzeugdetails-Formular in ObjektSection
(nur für Objekttypen mit ist_zugfahrzeug=True), Dashboard-Kachel 2 zeigt
gemischte Liste.

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 13:36:01 +02:00
co-authored by Claude Sonnet 5
parent eb20affd5d
commit 15fb8dbb16
12 changed files with 545 additions and 31 deletions
+11
View File
@@ -1,3 +1,14 @@
export interface Fahrzeugdetails {
objekt_id: number;
kennzeichen: string | null;
funkrufname: string | null;
baujahr: number | null;
kilometerstand: number | null;
betriebsstunden: string | null;
naechste_hu: string | null;
naechste_uvv: string | null;
}
export interface Objekt {
id: number;
code: string;
+10 -11
View File
@@ -19,11 +19,10 @@ interface Einsatzbereitschaft {
}
interface Prueftermineintrag {
geraet_instanz_id: string;
typ: string;
objekt_id: number;
material_name: string;
seriennummer: string;
naechste_pruefung: string;
bezeichnung: string;
faelligkeitsdatum: string;
verbleibende_tage: number;
status: string;
}
@@ -42,6 +41,8 @@ const GRUND_TEXT: Record<string, string> = {
pruefung_ueberfaellig: "Geräteprüfung überfällig",
kontrolle_laeuft: "Kontrolle läuft (gesperrt)",
mangel_kritisch_offen: "Kritischer Mangel offen",
in_wartung: "In Wartung",
hu_uvv_ueberfaellig: "HU/UVV überfällig",
mindermenge_genehmigt: "Mindermenge genehmigt",
};
@@ -124,13 +125,11 @@ export function DashboardPage() {
<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)
{prueftermine.map((p, i) => (
<li key={i} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>{p.bezeichnung}</span>
<span style={{ color: p.status === "abgelaufen" ? "#b00020" : undefined }}>
{p.faelligkeitsdatum} ({p.verbleibende_tage} Tage)
</span>
</li>
))}
+128 -1
View File
@@ -1,9 +1,15 @@
import { useState } from "react";
import { apiRequest } from "../../api/client";
import type { Beladungsvorlage, Material, Objekt, Objekttyp, Standort } from "../../api/types";
import type { Beladungsvorlage, Fahrzeugdetails, Material, Objekt, Objekttyp, Standort } from "../../api/types";
import { ObjektPositionenPanel } from "./ObjektPositionenPanel";
const STATUS_OPTIONEN = [
{ wert: "aktiv", label: "Aktiv" },
{ wert: "in_wartung", label: "In Wartung" },
{ wert: "ausser_dienst", label: "Außer Dienst" },
];
interface Props {
objekte: Objekt[];
objekttypen: Objekttyp[];
@@ -46,6 +52,52 @@ export function ObjektSection({
const [fahrzeugBearbeitenId, setFahrzeugBearbeitenId] = useState<number | null>(null);
const [fahrzeugAuswahl, setFahrzeugAuswahl] = useState<number | "">("");
const [suche, setSuche] = useState("");
const [detailsOffenId, setDetailsOffenId] = useState<number | null>(null);
const [details, setDetails] = useState<Partial<Fahrzeugdetails>>({});
async function statusAendern(o: Objekt, status: string) {
try {
await apiRequest(`/objekte/${o.id}`, { method: "PATCH", body: { status } });
await onGeaendert();
} catch {
onFehler("Status konnte nicht geändert werden.");
}
}
async function fahrzeugdetailsOeffnen(o: Objekt) {
if (detailsOffenId === o.id) {
setDetailsOffenId(null);
return;
}
setDetailsOffenId(o.id);
try {
const daten = await apiRequest<Fahrzeugdetails>(`/objekte/${o.id}/fahrzeugdetails`);
setDetails(daten);
} catch {
setDetails({});
}
}
async function fahrzeugdetailsSpeichern(objektId: number) {
try {
await apiRequest(`/objekte/${objektId}/fahrzeugdetails`, {
method: "PUT",
body: {
kennzeichen: details.kennzeichen || null,
funkrufname: details.funkrufname || null,
baujahr: details.baujahr || null,
kilometerstand: details.kilometerstand || null,
betriebsstunden: details.betriebsstunden || null,
naechste_hu: details.naechste_hu || null,
naechste_uvv: details.naechste_uvv || null,
},
});
setDetailsOffenId(null);
onFehler("");
} catch {
onFehler("Fahrzeugdetails konnten nicht gespeichert werden.");
}
}
// Option A+C (Karte 11 Nachtrag): Dropdown zeigt nur Zugfahrzeuge (Objekttyp-
// Flag ist_zugfahrzeug) ohne eigene Kopplung - serverseitig zusätzlich per
@@ -286,6 +338,24 @@ export function ObjektSection({
</span>
</span>
<div className="row">
{!nurPflegen && (
<select
className="input"
value={o.status}
onChange={(e) => statusAendern(o, e.target.value)}
>
{STATUS_OPTIONEN.map((s) => (
<option key={s.wert} value={s.wert}>
{s.label}
</option>
))}
</select>
)}
{objekttypen.find((t) => t.id === o.objekttyp_id)?.ist_zugfahrzeug && (
<button className="btn btn-secondary" onClick={() => fahrzeugdetailsOeffnen(o)}>
{detailsOffenId === o.id ? "Schließen" : "Fahrzeugdetails"}
</button>
)}
<button
className="btn btn-secondary"
onClick={() => setAusgeklapptId(ausgeklapptId === o.id ? null : o.id)}
@@ -304,6 +374,63 @@ export function ObjektSection({
)}
</div>
</div>
{detailsOffenId === o.id && (
<div className="stack" style={{ marginTop: "0.5rem", maxWidth: "24rem" }}>
<input
className="input"
placeholder="Kennzeichen"
value={details.kennzeichen ?? ""}
onChange={(e) => setDetails((d) => ({ ...d, kennzeichen: e.target.value }))}
/>
<input
className="input"
placeholder="Funkrufname"
value={details.funkrufname ?? ""}
onChange={(e) => setDetails((d) => ({ ...d, funkrufname: e.target.value }))}
/>
<div className="row">
<input
className="input"
type="number"
placeholder="Baujahr"
value={details.baujahr ?? ""}
onChange={(e) => setDetails((d) => ({ ...d, baujahr: Number(e.target.value) || undefined }))}
/>
<input
className="input"
type="number"
placeholder="Kilometerstand"
value={details.kilometerstand ?? ""}
onChange={(e) =>
setDetails((d) => ({ ...d, kilometerstand: Number(e.target.value) || undefined }))
}
/>
</div>
<div className="row">
<div className="field">
<label>Nächste HU</label>
<input
className="input"
type="date"
value={details.naechste_hu ?? ""}
onChange={(e) => setDetails((d) => ({ ...d, naechste_hu: e.target.value }))}
/>
</div>
<div className="field">
<label>Nächste UVV</label>
<input
className="input"
type="date"
value={details.naechste_uvv ?? ""}
onChange={(e) => setDetails((d) => ({ ...d, naechste_uvv: e.target.value }))}
/>
</div>
</div>
<button className="btn btn-primary" onClick={() => fahrzeugdetailsSpeichern(o.id)}>
Speichern
</button>
</div>
)}
{fahrzeugBearbeitenId === o.id && (
<div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}>
<select