feat(dokument): Dokumente-Modul (Roadmap Phase 5)
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:
@@ -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`, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user