feat(maintenance): MAINT-Epic (Wartungspläne/-aufträge, Werkstatt) + Akte-Redesign
CI / backend-tests (push) Failing after 2m4s
CI / frontend-build (push) Successful in 18s

Neues Wartungskonzept: Wartungsplan+Position+Intervall (zeit/km/
Betriebsstunden, kombinierbar - fällig sobald ein Kriterium erreicht
ist), Wartungsauftrag mit intern/externer Werkstatt, Status offen/
in_arbeit/erledigt, Kosten, Historie, Dokument-Anhang. Ad-hoc-Aufträge
ohne Plan möglich (z.B. kleinere Reparatur). Admin-Tab "Wartungspläne".

Objektakte-Redesign (Epic 21, Nutzer-Feedback "wirkt nicht modern"):
CSS-Grid statt Karten-Stapel, einspaltig mobil, zweispaltig ab 900px;
Fahrzeug-Objekte zeigen Wartung/Fahrzeugdaten prominent, Geräte/
Rucksäcke die Beladung. Dabei gefundene Lücke: Beladung (Soll/Ist je
Position) fehlte in der Akte komplett, nur über eine laufende
Kontrolle sichtbar - jetzt eigene Karte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-06 23:17:39 +02:00
co-authored by Claude Sonnet 5
parent d607a1101d
commit 2a4d391711
23 changed files with 1505 additions and 44 deletions
+10
View File
@@ -22,6 +22,7 @@ import { ObjekttypSection } from "./admin/ObjekttypSection";
import { QualifikationSection } from "./admin/QualifikationSection";
import { StandortSection } from "./admin/StandortSection";
import { VorlageSection } from "./admin/VorlageSection";
import { WartungsplanSection } from "./admin/WartungsplanSection";
import { ZustaendigkeitSection } from "./admin/ZustaendigkeitSection";
type Tab =
@@ -39,6 +40,7 @@ type Tab =
| "lagerbewegungen"
| "lager"
| "ausgabe"
| "wartungsplaene"
| "rollen";
// Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte
@@ -76,6 +78,11 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera
label: "Ausgabe/Rückgabe",
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
},
{
key: "wartungsplaene",
label: "Wartungspläne",
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
},
{ key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] },
];
@@ -247,6 +254,9 @@ export function AdminPage() {
{aktiverTab === "ausgabe" && (
<AusgabeSection materialien={materialien} benutzer={benutzer} onFehler={setFehler} />
)}
{aktiverTab === "wartungsplaene" && (
<WartungsplanSection objekttypen={objekttypen} onFehler={setFehler} />
)}
{aktiverTab === "rollen" && <RolleSection benutzer={benutzer} onFehler={setFehler} />}
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
{aktiverTab === "personal" && (
+49 -14
View File
@@ -5,6 +5,7 @@ import { apiRequest } from "../api/client";
import { useAuth } from "../auth/AuthContext";
import type { Akte } from "../api/types";
import { DokumentePanel } from "../components/DokumentePanel";
import { WartungSection } from "../components/WartungSection";
import { ReadinessBadge } from "../components/status/ReadinessBadge";
import { GRUND_TEXT } from "../components/status/gruendeText";
import { EREIGNIS_LABEL } from "../components/status/ereignisText";
@@ -15,8 +16,6 @@ const MANGEL_PRIORITAET_LABEL: Record<string, string> = {
hoch: "Hoch",
};
// FILE-006: EIN Screen für alle Teilbereiche einer Ressource statt mehrerer
// Einzelseiten - konsumiert den FILE-001-Aggregations-Endpunkt.
export function AktePage() {
const { objektId } = useParams<{ objektId: string }>();
const { istVerantwortlich } = useAuth();
@@ -52,10 +51,11 @@ export function AktePage() {
if (!akte) return null;
const { objekt } = akte;
const istFahrzeug = akte.fahrzeugdetails !== null;
return (
<main className="page">
<div className="row-between" style={{ alignItems: "flex-start" }}>
<main className={`page-wide akte-grid${istFahrzeug ? " ist-fahrzeug" : ""}`}>
<div className="akte-kopf row-between" style={{ alignItems: "flex-start" }}>
<div>
<h1 style={{ marginBottom: "0.15rem" }}>{objekt.name}</h1>
<div className="text-muted">
@@ -65,7 +65,7 @@ export function AktePage() {
<ReadinessBadge status={akte.einsatzbereitschaft_status} />
</div>
{akte.einsatzbereitschaft_gruende.length > 0 && (
<ul className="card-list" style={{ marginTop: "0.5rem" }}>
<ul className="akte-kopf card-list" style={{ marginTop: "0.5rem" }}>
{akte.einsatzbereitschaft_gruende.map((g) => (
<li key={g} className="text-muted" style={{ fontSize: "0.9rem" }}>
{GRUND_TEXT[g] ?? g}
@@ -74,9 +74,7 @@ export function AktePage() {
</ul>
)}
{/* nach dem QR-Scan (MOBILE-002) landet man hier - die
Kernaktionen sollen direkt sichtbar sein, nicht erst am Seitenende. */}
<div className="row" style={{ flexWrap: "wrap", marginTop: "1rem" }}>
<div className="akte-aktionen row" style={{ flexWrap: "wrap" }}>
<Link to={`/objekte/${objekt.id}/kontrolle`} className="btn btn-primary">
Kontrolle starten
</Link>
@@ -85,7 +83,7 @@ export function AktePage() {
</Link>
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<div className="akte-stamm card">
<h3 style={{ marginTop: 0 }}>Stammdaten</h3>
<div className="stack">
<div>Standort: {akte.standort_name}</div>
@@ -108,7 +106,40 @@ export function AktePage() {
</div>
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<div className="akte-beladung card">
<h3 style={{ marginTop: 0 }}>Beladung</h3>
{akte.beladung.length === 0 && <p className="text-muted">Keine Positionen zugeordnet.</p>}
{akte.beladung.length > 0 && (
<div style={{ overflowX: "auto" }}>
<table className="table">
<thead>
<tr>
<th>Material</th>
<th>Fach</th>
<th>Soll</th>
<th>Ist</th>
</tr>
</thead>
<tbody>
{akte.beladung.map((b) => (
<tr key={b.objektposition_id}>
<td>{b.material_name}</td>
<td>{b.fach ?? ""}</td>
<td>
{b.sollmenge} {b.einheit}
</td>
<td className={Number(b.istmenge) < Number(b.sollmenge) ? "text-danger" : undefined}>
{b.istmenge} {b.einheit}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="akte-verantwortlich card">
<h3 style={{ marginTop: 0 }}>Verantwortlichkeit</h3>
{akte.zustaendige_benutzer.length === 0 && akte.kontrollverantwortung.length === 0 && (
<p className="text-muted">Keine Zuordnung hinterlegt.</p>
@@ -124,7 +155,7 @@ export function AktePage() {
)}
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<div className="akte-pruefungen card">
<h3 style={{ marginTop: 0 }}>Prüfungen (Geräte)</h3>
{akte.geraete.length === 0 && <p className="text-muted">Keine Geräte-Instanzen erfasst.</p>}
{akte.geraete.length > 0 && (
@@ -149,7 +180,11 @@ export function AktePage() {
)}
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<div className="akte-wartung">
<WartungSection objektId={objekt.id} onFehler={setFehler} />
</div>
<div className="akte-maengel card">
<h3 style={{ marginTop: 0 }}>Mängel</h3>
{akte.maengel.length === 0 && <p className="text-muted">Keine Mängel gemeldet.</p>}
{akte.maengel.length > 0 && (
@@ -163,12 +198,12 @@ export function AktePage() {
)}
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<div className="akte-dokumente card">
<h3 style={{ marginTop: 0 }}>Dokumente</h3>
<DokumentePanel entitaetTyp="objekt" entitaetId={String(objekt.id)} onFehler={setFehler} />
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<div className="akte-historie card">
<h3 style={{ marginTop: 0 }}>Historie</h3>
{akte.historie.length === 0 && <p className="text-muted">Noch keine Änderungen protokolliert.</p>}
{akte.historie.length > 0 && (
@@ -0,0 +1,153 @@
import { useEffect, useState } from "react";
import { apiRequest } from "../../api/client";
import type { Objekttyp, Wartungsplan } from "../../api/types";
interface Props {
objekttypen: Objekttyp[];
onFehler: (text: string) => void;
}
interface NeueIntervallZeile {
typ: "zeit" | "km" | "betriebsstunden";
wert: string;
}
export function WartungsplanSection({ objekttypen, onFehler }: Props) {
const [plaene, setPlaene] = useState<Wartungsplan[]>([]);
const [objekttypId, setObjekttypId] = useState<number | "">("");
const [name, setName] = useState("");
const [wartungsart, setWartungsart] = useState("");
const [intervalle, setIntervalle] = useState<NeueIntervallZeile[]>([{ typ: "zeit", wert: "" }]);
async function laden() {
try {
setPlaene(await apiRequest<Wartungsplan[]>("/wartungsplaene"));
} catch {
onFehler("Wartungspläne konnten nicht geladen werden.");
}
}
useEffect(() => {
laden();
}, []);
function objekttypName(id: number): string {
return objekttypen.find((o) => o.id === id)?.name ?? `#${id}`;
}
function intervallZeileAendern(index: number, feld: keyof NeueIntervallZeile, wert: string) {
setIntervalle((zeilen) => zeilen.map((z, i) => (i === index ? { ...z, [feld]: wert } : z)));
}
async function planAnlegen() {
if (objekttypId === "" || !name || !wartungsart) {
onFehler("Objekttyp, Name und mindestens eine Wartungsart angeben.");
return;
}
try {
await apiRequest("/wartungsplaene", {
method: "POST",
body: {
objekttyp_id: objekttypId,
name,
positionen: [
{
wartungsart,
intervalle: intervalle
.filter((z) => z.wert)
.map((z) => ({ typ: z.typ, wert: Number(z.wert) })),
},
],
},
});
setName("");
setWartungsart("");
setIntervalle([{ typ: "zeit", wert: "" }]);
await laden();
} catch {
onFehler("Wartungsplan konnte nicht angelegt werden.");
}
}
return (
<section className="section">
<h2>Wartungspläne</h2>
<div className="card">
<h3 style={{ marginTop: 0 }}>Neuer Plan (eine Wartungsart)</h3>
<div className="stack">
<select className="input" value={objekttypId} onChange={(e) => setObjekttypId(Number(e.target.value) || "")}>
<option value="">Objekttyp wählen</option>
{objekttypen.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
<input className="input" placeholder="Plan-Name (z.B. RTW Standard)" value={name} onChange={(e) => setName(e.target.value)} />
<input
className="input"
placeholder="Wartungsart (z.B. Ölwechsel)"
value={wartungsart}
onChange={(e) => setWartungsart(e.target.value)}
/>
{intervalle.map((zeile, i) => (
<div className="row" key={i}>
<select
className="input"
value={zeile.typ}
onChange={(e) => intervallZeileAendern(i, "typ", e.target.value)}
>
<option value="zeit">Zeit (Monate)</option>
<option value="km">Kilometer</option>
<option value="betriebsstunden">Betriebsstunden</option>
</select>
<input
className="input"
type="number"
placeholder="Wert"
value={zeile.wert}
onChange={(e) => intervallZeileAendern(i, "wert", e.target.value)}
/>
</div>
))}
<button
className="btn btn-secondary"
onClick={() => setIntervalle((z) => [...z, { typ: "zeit", wert: "" }])}
>
+ weiteres Intervall
</button>
<button className="btn btn-primary" onClick={planAnlegen}>
Plan anlegen
</button>
</div>
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<h3 style={{ marginTop: 0 }}>Bestehende Pläne</h3>
{plaene.length === 0 && <p className="text-muted">Keine Wartungspläne angelegt.</p>}
<ul className="card-list">
{plaene.map((p) => (
<li key={p.id}>
<strong>{p.name}</strong> <span className="text-muted">({objekttypName(p.objekttyp_id)})</span>
<ul>
{p.positionen.map((pos) => (
<li key={pos.id}>
{pos.wartungsart}
{pos.intervalle.length > 0 && (
<span className="text-muted">
{" "}
{pos.intervalle.map((i) => `${i.wert} ${i.typ}`).join(", ")}
</span>
)}
</li>
))}
</ul>
</li>
))}
</ul>
</div>
</section>
);
}