feat(akte): Digital-File-Epic - Aggregations-Endpunkt + Übersichtsseite (FILE-001..006)
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:
@@ -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={
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user