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
+42
View File
@@ -64,6 +64,48 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
return (await response.json()) as T;
}
export async function ladeDokumentHoch(felder: {
entitaet_typ: string;
entitaet_id: string;
beschreibung?: string;
datei: File;
}): Promise<unknown> {
const formData = new FormData();
formData.append("entitaet_typ", felder.entitaet_typ);
formData.append("entitaet_id", felder.entitaet_id);
if (felder.beschreibung) formData.append("beschreibung", felder.beschreibung);
formData.append("datei", felder.datei);
const headers: Record<string, string> = {};
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
const response = await fetch(`${BASE_URL}/dokumente`, { method: "POST", headers, body: formData });
if (!response.ok) {
throw new ApiError(response.status, await response.json().catch(() => null));
}
return response.json();
}
// Download braucht den Auth-Header (kein einfacher <a href>-Link möglich, da
// der Server ohne Bearer-Token 401 liefert) - Blob laden und über einen
// temporären Objekt-Link im Browser "herunterladen" lassen.
export async function ladeDokumentHerunter(dokumentId: string, dateiname: string): Promise<void> {
const headers: Record<string, string> = {};
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
const response = await fetch(`${BASE_URL}/dokumente/${dokumentId}/download`, { headers });
if (!response.ok) {
throw new ApiError(response.status, null);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = dateiname;
link.click();
URL.revokeObjectURL(url);
}
export async function login(username: string, password: string): Promise<string> {
const body = new URLSearchParams({ username, password });
const response = await fetch(`${BASE_URL}/auth/login`, {
+12
View File
@@ -73,6 +73,18 @@ export interface Mangel {
erledigt_am: string | null;
}
export interface Dokument {
id: string;
entitaet_typ: string;
entitaet_id: string;
dateiname: string;
mime_type: string;
groesse_bytes: number;
beschreibung: string | null;
hochgeladen_von: number;
hochgeladen_am: string;
}
export interface Lagerbewegung {
id: string;
objekt_id: number;
+129
View File
@@ -0,0 +1,129 @@
import { useEffect, useState } from "react";
import { apiRequest, ladeDokumentHerunter, ladeDokumentHoch } from "../api/client";
import { useAuth } from "../auth/AuthContext";
import type { Dokument } from "../api/types";
interface Props {
entitaetTyp: string;
entitaetId: string;
onFehler: (text: string) => void;
}
function formatGroesse(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// Dokumente-Modul (Roadmap Phase 5): wiederverwendbares Panel für beliebige
// Ressourcen (Objekt, Mangel, Fahrzeugdetails, ...) - gleiche entitaet_typ/
// entitaet_id-Adressierung wie das Backend.
export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
const { istMaterialverantwortlich, istLeitungsverantwortlich, istAdmin } = useAuth();
const darfLoeschen = istMaterialverantwortlich || istLeitungsverantwortlich || istAdmin;
const [dokumente, setDokumente] = useState<Dokument[]>([]);
const [laedt, setLaedt] = useState(true);
const [beschreibung, setBeschreibung] = useState("");
const [wirdHochgeladen, setWirdHochgeladen] = useState(false);
async function laden() {
setLaedt(true);
try {
const daten = await apiRequest<Dokument[]>(
`/dokumente?entitaet_typ=${entitaetTyp}&entitaet_id=${encodeURIComponent(entitaetId)}`
);
setDokumente(daten);
} catch {
onFehler("Dokumente konnten nicht geladen werden.");
} finally {
setLaedt(false);
}
}
useEffect(() => {
laden();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entitaetTyp, entitaetId]);
async function hochladen(e: { target: HTMLInputElement }) {
const datei = e.target.files?.[0];
if (!datei) return;
setWirdHochgeladen(true);
try {
await ladeDokumentHoch({ entitaet_typ: entitaetTyp, entitaet_id: entitaetId, beschreibung, datei });
setBeschreibung("");
e.target.value = "";
await laden();
} catch {
onFehler("Datei konnte nicht hochgeladen werden (Typ erlaubt: PDF/JPEG/PNG/WebP, max. 25 MB).");
} finally {
setWirdHochgeladen(false);
}
}
async function herunterladen(d: Dokument) {
try {
await ladeDokumentHerunter(d.id, d.dateiname);
} catch {
onFehler("Download fehlgeschlagen.");
}
}
async function loeschen(d: Dokument) {
try {
await apiRequest(`/dokumente/${d.id}`, { method: "DELETE" });
await laden();
} catch {
onFehler("Dokument konnte nicht gelöscht werden.");
}
}
return (
<div className="stack">
<div className="row" style={{ flexWrap: "wrap" }}>
<input
className="input"
value={beschreibung}
onChange={(e) => setBeschreibung(e.target.value)}
placeholder="Beschreibung (optional)"
style={{ maxWidth: "16rem" }}
/>
<input type="file" accept=".pdf,.jpg,.jpeg,.png,.webp" onChange={hochladen} disabled={wirdHochgeladen} />
</div>
{laedt && <p className="text-muted">Lade</p>}
{!laedt && dokumente.length === 0 && <p className="text-muted">Keine Dokumente.</p>}
{!laedt && dokumente.length > 0 && (
<ul className="card-list">
{dokumente.map((d) => (
<li key={d.id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
<button
onClick={() => herunterladen(d)}
style={{
background: "none",
border: "none",
padding: 0,
color: "var(--color-primary)",
textDecoration: "underline",
cursor: "pointer",
}}
>
{d.dateiname}
</button>
{d.beschreibung && <span className="text-muted"> {d.beschreibung}</span>}
<span className="text-muted"> ({formatGroesse(d.groesse_bytes)})</span>
</span>
{darfLoeschen && (
<button className="btn btn-secondary" onClick={() => loeschen(d)}>
Löschen
</button>
)}
</li>
))}
</ul>
)}
</div>
);
}
+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