feat(akte): Digital-File-Epic - Aggregations-Endpunkt + Übersichtsseite (FILE-001..006)
CI / backend-tests (push) Successful in 1m58s
CI / frontend-build (push) Successful in 18s

Größte Lücke aus arbeitskacheln/03_digital_file.md: alle Teilbereiche
(Stammdaten, Standort, Verantwortlichkeit, Historie, Dokumente, Geräte-
Prüfungen, Mängel) existierten einzeln, aber keine gebündelte Akte-Ansicht
je Objekt.

- GET /api/v1/akte/objekt/{id}: aggregiert alle Teilbereiche in einem Aufruf
  (app/services/akte.py), 404 bei unbekanntem Objekt
- Zuständigkeits-Auflösung invertiert (wer ist für DIESES Objekt zuständig,
  Standort-Vererbung + Objekt-Zeile als Vereinigung, Karte 04)
- Frontend: /akte/objekt/:objektId (AktePage.tsx), Link aus der Objektliste
- 2 neue Tests (test_akte.py), 137 Tests weiterhin grün

FILE-007 (Audit-Konsistenz-Check über alle Mutations-Endpunkte) bewusst
nicht Teil dieses Commits - eigene, spätere Qualitätssicherungs-Kachel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-05 21:13:29 +02:00
co-authored by Claude Sonnet 5
parent fca9ee8fd8
commit 84c11af3bb
9 changed files with 444 additions and 0 deletions
+9
View File
@@ -4,6 +4,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider, useAuth } from "./auth/AuthContext";
import { AppShell } from "./components/AppShell";
import { AdminPage } from "./pages/AdminPage";
import { AktePage } from "./pages/AktePage";
import { DashboardPage } from "./pages/DashboardPage";
import { FehlbestandListePage } from "./pages/FehlbestandListePage";
import { KontrollPage } from "./pages/KontrollPage";
@@ -46,6 +47,14 @@ export default function App() {
</GeschuetzteRoute>
}
/>
<Route
path="/akte/objekt/:objektId"
element={
<GeschuetzteRoute>
<AktePage />
</GeschuetzteRoute>
}
/>
<Route
path="/objekte/:objektId/kontrolle"
element={
+20
View File
@@ -207,6 +207,26 @@ export interface EskalationKonfiguration {
leitung_tage: number;
}
export interface ZustaendigerBenutzer {
id: number;
name: string;
quelle: "standort" | "objekt";
}
export interface Akte {
objekt: Objekt;
objekttyp_name: string;
standort_name: string;
fahrzeugdetails: Fahrzeugdetails | null;
zustaendige_benutzer: ZustaendigerBenutzer[];
kontrollverantwortung: Kontrollverantwortung[];
letzte_kontrolle_am: string | null;
historie: HistorieEintrag[];
dokumente: Dokument[];
geraete: GeraetInstanz[];
maengel: Mangel[];
}
export interface HistorieEintrag {
id: string;
zeitpunkt: string;
+155
View File
@@ -0,0 +1,155 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { apiRequest } from "../api/client";
import type { Akte } from "../api/types";
const MANGEL_PRIORITAET_LABEL: Record<string, string> = {
niedrig: "Niedrig",
normal: "Normal",
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 [akte, setAkte] = useState<Akte | null>(null);
const [fehler, setFehler] = useState<string | null>(null);
const [laedt, setLaedt] = useState(true);
useEffect(() => {
if (!objektId) return;
setLaedt(true);
apiRequest<Akte>(`/akte/objekt/${objektId}`)
.then(setAkte)
.catch(() => setFehler("Akte konnte nicht geladen werden."))
.finally(() => setLaedt(false));
}, [objektId]);
if (laedt) return <main className="page"><p className="text-muted">Lade</p></main>;
if (fehler) return <main className="page"><div className="alert alert-danger">{fehler}</div></main>;
if (!akte) return null;
const { objekt } = akte;
return (
<main className="page">
<div className="row-between" style={{ alignItems: "flex-start" }}>
<div>
<h1 style={{ marginBottom: "0.15rem" }}>{objekt.name}</h1>
<div className="text-muted">
{objekt.code} · {akte.objekttyp_name}
</div>
</div>
<span className="badge badge-neutral">{objekt.status}</span>
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<h3 style={{ marginTop: 0 }}>Stammdaten</h3>
<div className="stack">
<div>Standort: {akte.standort_name}</div>
<div>
Letzte Kontrolle:{" "}
{akte.letzte_kontrolle_am
? new Date(akte.letzte_kontrolle_am).toLocaleString("de-DE")
: "noch nie kontrolliert"}
</div>
{akte.fahrzeugdetails && (
<>
{akte.fahrzeugdetails.kennzeichen && <div>Kennzeichen: {akte.fahrzeugdetails.kennzeichen}</div>}
{akte.fahrzeugdetails.funkrufname && <div>Funkrufname: {akte.fahrzeugdetails.funkrufname}</div>}
{akte.fahrzeugdetails.funkkenner && <div>Funkkenner: {akte.fahrzeugdetails.funkkenner}</div>}
{akte.fahrzeugdetails.kilometerstand !== null && (
<div>Kilometerstand: {akte.fahrzeugdetails.kilometerstand} km</div>
)}
</>
)}
</div>
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<h3 style={{ marginTop: 0 }}>Verantwortlichkeit</h3>
{akte.zustaendige_benutzer.length === 0 && akte.kontrollverantwortung.length === 0 && (
<p className="text-muted">Keine Zuordnung hinterlegt.</p>
)}
{akte.zustaendige_benutzer.length > 0 && (
<ul>
{akte.zustaendige_benutzer.map((z) => (
<li key={z.id}>
{z.name} <span className="text-muted">({z.quelle === "standort" ? "über Standort" : "direkt"})</span>
</li>
))}
</ul>
)}
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<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 && (
<ul>
{akte.geraete.map((g) => (
<li key={g.id}>
SN {g.seriennummer} {" "}
<span className={g.status === "einsatzbereit" ? "text-success" : "text-danger"}>{g.status}</span>
{g.naechste_pruefung && (
<span className="text-muted"> · nächste Prüfung {new Date(g.naechste_pruefung).toLocaleDateString("de-DE")}</span>
)}
</li>
))}
</ul>
)}
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<h3 style={{ marginTop: 0 }}>Mängel</h3>
{akte.maengel.length === 0 && <p className="text-muted">Keine Mängel gemeldet.</p>}
{akte.maengel.length > 0 && (
<ul>
{akte.maengel.map((m) => (
<li key={m.id}>
{m.beschreibung} {MANGEL_PRIORITAET_LABEL[m.prioritaet]}, {m.status}
</li>
))}
</ul>
)}
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<h3 style={{ marginTop: 0 }}>Dokumente</h3>
{akte.dokumente.length === 0 && <p className="text-muted">Keine Dokumente hinterlegt.</p>}
{akte.dokumente.length > 0 && (
<ul>
{akte.dokumente.map((d) => (
<li key={d.id}>
{d.dateiname} <span className="text-muted">({Math.round(d.groesse_bytes / 1024)} KB)</span>
</li>
))}
</ul>
)}
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<h3 style={{ marginTop: 0 }}>Historie</h3>
{akte.historie.length === 0 && <p className="text-muted">Noch keine Änderungen protokolliert.</p>}
{akte.historie.length > 0 && (
<ul>
{akte.historie.map((h) => (
<li key={h.id}>
{new Date(h.zeitpunkt).toLocaleString("de-DE")} {h.ereignistyp}
{h.benutzer_name && <span className="text-muted"> ({h.benutzer_name})</span>}
</li>
))}
</ul>
)}
</div>
<div style={{ marginTop: "1rem" }}>
<Link to={`/objekte/${objekt.id}/kontrolle`} className="btn btn-primary">
Kontrolle starten
</Link>
</div>
</main>
);
}
+8
View File
@@ -82,6 +82,14 @@ export function ObjektListPage() {
: "Noch nie kontrolliert"}
</div>
</Link>
<Link
to={`/akte/objekt/${objekt.id}`}
className="text-muted"
style={{ fontSize: "0.85rem" }}
onClick={(e) => e.stopPropagation()}
>
Akte ansehen
</Link>
</li>
))}
</ul>