feat(dokument): Dokumente-Modul (Roadmap Phase 5)
CI / backend-tests (push) Failing after 1m51s
CI / frontend-build (push) Successful in 17s

Polymorphe Datei-Anhänge (PDF/JPEG/PNG/WebP, Whitelist statt Blacklist -
owasp-Grundsatz) an beliebige Ressource, gleiches entitaet_typ/entitaet_id-
Muster wie Mangel/Historie. Lokale Ablage (settings.upload_dir, kein Cloud-
Zwang, Self-Hosting-Anforderung), server-generierter Dateiname verhindert
Path-Traversal/Namenskollisionen.

POST /dokumente (multipart), GET /dokumente (Filter Pflicht: entitaet_typ +
entitaet_id), GET /dokumente/{id}/download, DELETE /dokumente/{id}. Hochladen:
alle Mitarbeiter+, Löschen: Materialverantwortliche+Leitung+Admin.

Frontend: wiederverwendbares DokumentePanel (Upload/Liste/Download/Löschen),
eingebunden in ObjektSection (je Objekt) und MangelListePage (Fotos zu
Mängeln) - weitere Ressourcen (Geräteinstanz, Fahrzeugdetails, Benutzer)
können denselben Baustein später einfach wiederverwenden.

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:56:27 +02:00
co-authored by Claude Sonnet 5
parent 11ad747136
commit 73bf6c1605
16 changed files with 631 additions and 0 deletions
+14
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { apiRequest } from "../api/client";
import { useAuth } from "../auth/AuthContext";
import { DokumentePanel } from "../components/DokumentePanel";
import type { Mangel, MangelPrioritaet, MangelStatus, Objekt } from "../api/types";
const STATUS_LABEL: Record<MangelStatus, string> = {
@@ -37,6 +38,7 @@ export function MangelListePage() {
const [beschreibung, setBeschreibung] = useState("");
const [prioritaet, setPrioritaet] = useState<MangelPrioritaet>("normal");
const [wirdGemeldet, setWirdGemeldet] = useState(false);
const [fotosOffenId, setFotosOffenId] = useState<string | null>(null);
async function laden() {
setLaedt(true);
@@ -160,6 +162,18 @@ export function MangelListePage() {
))}
</select>
)}
<button
className="btn btn-secondary"
style={{ marginTop: "0.5rem" }}
onClick={() => setFotosOffenId(fotosOffenId === m.id ? null : m.id)}
>
{fotosOffenId === m.id ? "Dokumente schließen" : "Dokumente/Fotos"}
</button>
{fotosOffenId === m.id && (
<div style={{ marginTop: "0.5rem" }}>
<DokumentePanel entitaetTyp="mangel" entitaetId={m.id} onFehler={setFehler} />
</div>
)}
</li>
))}
</ul>
@@ -2,6 +2,7 @@ import { useState } from "react";
import { apiRequest } from "../../api/client";
import type { Beladungsvorlage, Fahrzeugdetails, Material, Objekt, Objekttyp, Standort } from "../../api/types";
import { DokumentePanel } from "../../components/DokumentePanel";
import { ObjektPositionenPanel } from "./ObjektPositionenPanel";
const STATUS_OPTIONEN = [
@@ -44,6 +45,7 @@ export function ObjektSection({
const [fahrzeugId, setFahrzeugId] = useState<number | "">("");
const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [ausgeklapptId, setAusgeklapptId] = useState<number | null>(null);
const [dokumenteOffenId, setDokumenteOffenId] = useState<number | null>(null);
const [duplizierId, setDuplizierId] = useState<number | null>(null);
const [neuerPraefix, setNeuerPraefix] = useState("");
const [neuerCode, setNeuerCode] = useState("");
@@ -397,6 +399,12 @@ export function ObjektSection({
>
{ausgeklapptId === o.id ? "Positionen ausblenden" : "Ablauf/Charge/SN pflegen"}
</button>
<button
className="btn btn-secondary"
onClick={() => setDokumenteOffenId(dokumenteOffenId === o.id ? null : o.id)}
>
{dokumenteOffenId === o.id ? "Dokumente schließen" : "Dokumente"}
</button>
{!nurPflegen && (
<button className="btn btn-secondary" onClick={() => fahrzeugBearbeiten(o)}>
{fahrzeugBearbeitenId === o.id ? "Schließen" : "Fahrzeug zuordnen"}
@@ -524,6 +532,11 @@ export function ObjektSection({
/>
</div>
)}
{dokumenteOffenId === o.id && (
<div style={{ marginTop: "0.5rem" }}>
<DokumentePanel entitaetTyp="objekt" entitaetId={String(o.id)} onFehler={onFehler} />
</div>
)}
{duplizierId === o.id && (
<div className="row" style={{ marginTop: "0.5rem", flexWrap: "wrap" }}>
<input