diff --git a/DEVLOG.md b/DEVLOG.md index 30f9951..8a2107b 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -4087,3 +4087,22 @@ Keine Commits in dieser Session. - frontend/src/pages/admin/ObjektSection.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++ --- +## 2026-09-05 13:47 – 13:51 (3m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +- 11ad747 fix(historie): Audit-Lücke bei Mangel/Personal/Objekt-Änderungen geschlossen + +### Geänderte Dateien +- DEVLOG.md | 22 ++++++++++++++++++++++ +- backend/app/api/v1/endpoints/mangel.py | 33 +++++++++++++++++++++++++-------- +- backend/app/api/v1/endpoints/objekte.py | 23 ++++++++++++++++++++++- +- backend/app/api/v1/endpoints/personal.py | 27 +++++++++++++++++++++++++-- +- backend/app/services/mangel.py | 28 ++++++++++++++++++++++++---- +- backend/tests/test_fahrzeugdetails.py | 15 +++++++++++++++ +- backend/tests/test_mangel.py | 26 ++++++++++++++++++++++++++ +- backend/tests/test_personal.py | 32 ++++++++++++++++++++++++++++++++ +- frontend/src/pages/admin/HistorieSection.tsx | 11 ++++++++++- + +--- diff --git a/backend/.gitignore b/backend/.gitignore index 6e96b1a..a8ff7c9 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -7,3 +7,4 @@ venv/ .coverage htmlcov/ *.egg-info/ +/uploads/ diff --git a/backend/alembic/versions/0016_dokument.py b/backend/alembic/versions/0016_dokument.py new file mode 100644 index 0000000..820b702 --- /dev/null +++ b/backend/alembic/versions/0016_dokument.py @@ -0,0 +1,43 @@ +"""Dokumente-Modul (Roadmap Phase 5): polymorphe Datei-Anhänge (PDF/Bilder/ +Prüfprotokolle/Wartungsberichte/Bedienungsanleitungen/Rechnungen/Zulassungs- +dokumente) an beliebige Ressource. entitaet_typ/entitaet_id als String- +Polymorphie, gleiches Muster wie historie.entitaet_typ/mangel.entitaet_typ +(bewusst kein FK - Dokument bleibt lesbar, auch wenn die konkrete Ressource +später gelöscht wird). + +Revision ID: 0016_dokument +Revises: 0015_lagerbewegung +Create Date: 2026-09-05 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0016_dokument" +down_revision: Union[str, None] = "0015_lagerbewegung" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE dokument ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entitaet_typ TEXT NOT NULL, + entitaet_id TEXT NOT NULL, + dateiname TEXT NOT NULL, + speicherpfad TEXT NOT NULL, + mime_type TEXT NOT NULL, + groesse_bytes INTEGER NOT NULL, + beschreibung TEXT, + hochgeladen_von INTEGER NOT NULL REFERENCES benutzer(id), + hochgeladen_am TIMESTAMPTZ NOT NULL + ) + """ + ) + op.execute("CREATE INDEX idx_dokument_entitaet ON dokument (entitaet_typ, entitaet_id)") + + +def downgrade() -> None: + op.execute("DROP TABLE dokument") diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 578fdaf..2157c1c 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -4,6 +4,7 @@ from app.api.v1.endpoints import ( auth, benutzer, dashboard, + dokument, eskalation, fehlbestaende, geraet_instanz, @@ -36,3 +37,4 @@ api_router.include_router(dashboard.router, tags=["dashboard"]) api_router.include_router(personal.router, tags=["personal"]) api_router.include_router(mangel.router, tags=["mangel"]) api_router.include_router(lagerbewegung.router, tags=["lagerbewegung"]) +api_router.include_router(dokument.router, tags=["dokument"]) diff --git a/backend/app/api/v1/endpoints/dokument.py b/backend/app/api/v1/endpoints/dokument.py new file mode 100644 index 0000000..d0c459f --- /dev/null +++ b/backend/app/api/v1/endpoints/dokument.py @@ -0,0 +1,95 @@ +import uuid + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user, require_roles +from app.db.session import get_db +from app.models.auth import RolleTyp +from app.models.dokument import Dokument +from app.schemas.dokument import DokumentRead, EntitaetTyp +from app.services.dokument import ( + DateityperlaubtError, + DateizugrossError, + dateipfad, + liste_fuer_entitaet, + loesche_dokument, + speichere_dokument, +) + +router = APIRouter() + +_mitarbeiter_plus = require_roles( + RolleTyp.mitarbeiter, + RolleTyp.materialverantwortlicher, + RolleTyp.leitungsverantwortlicher, + RolleTyp.administration, +) +_materialverantwortliche = require_roles( + RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher +) + + +@router.get("/dokumente", response_model=list[DokumentRead]) +async def liste_dokumente( + entitaet_typ: EntitaetTyp, + entitaet_id: str, + db: AsyncSession = Depends(get_db), + _=Depends(get_current_user), +) -> list[Dokument]: + return await liste_fuer_entitaet(db, entitaet_typ=entitaet_typ, entitaet_id=entitaet_id) + + +@router.post("/dokumente", response_model=DokumentRead, status_code=status.HTTP_201_CREATED) +async def lade_dokument_hoch( + entitaet_typ: EntitaetTyp = Form(...), + entitaet_id: str = Form(...), + beschreibung: str | None = Form(None), + datei: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + current_user=Depends(_mitarbeiter_plus), +) -> Dokument: + inhalt = await datei.read() + try: + return await speichere_dokument( + db, + entitaet_typ=entitaet_typ, + entitaet_id=entitaet_id, + dateiname=datei.filename or "unbenannt", + mime_type=datei.content_type or "application/octet-stream", + inhalt=inhalt, + beschreibung=beschreibung, + hochgeladen_von=current_user.id, + ) + except DateityperlaubtError as exc: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail=f"Dateityp nicht erlaubt: {exc}" + ) from exc + except DateizugrossError as exc: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="Datei zu groß" + ) from exc + + +@router.get("/dokumente/{dokument_id}/download") +async def lade_dokument_herunter( + dokument_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) +) -> FileResponse: + dokument = await db.get(Dokument, dokument_id) + if dokument is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") + pfad = dateipfad(dokument) + if not pfad.exists(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht mehr vorhanden") + return FileResponse(pfad, media_type=dokument.mime_type, filename=dokument.dateiname) + + +@router.delete("/dokumente/{dokument_id}", status_code=status.HTTP_204_NO_CONTENT) +async def entferne_dokument( + dokument_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(_materialverantwortliche) +) -> None: + dokument = await db.get(Dokument, dokument_id) + if dokument is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") + await loesche_dokument(db, dokument=dokument) diff --git a/backend/app/core/app_settings.py b/backend/app/core/app_settings.py index b9d6303..7c3ee70 100644 --- a/backend/app/core/app_settings.py +++ b/backend/app/core/app_settings.py @@ -24,5 +24,12 @@ class Settings(BaseSettings): smtp_from: str = "mabea@example.org" smtp_use_tls: bool = True + # Dokumente-Modul (Roadmap Phase 5): lokale Ablage, kein Cloud-Storage- + # Zwang (Self-Hosting-Anforderung). Relativer Default reicht für + # Entwicklung, Produktion setzt einen absoluten Pfad außerhalb des + # Anwendungsverzeichnisses (z.B. /opt/mabea/uploads, siehe deploy/README.md). + upload_dir: str = "./uploads" + max_upload_size_mb: int = 25 + settings = Settings() diff --git a/backend/app/models/dokument.py b/backend/app/models/dokument.py new file mode 100644 index 0000000..d320db9 --- /dev/null +++ b/backend/app/models/dokument.py @@ -0,0 +1,29 @@ +import uuid +from datetime import datetime + +from sqlalchemy import ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Dokument(Base): + """Roadmap Phase 5 (Modul Dokumente): polymorpher Datei-Anhang an beliebige + Ressource (Objekt, Objektposition, Geräteinstanz, Mangel, Fahrzeugdetails, + Benutzer, ...) - gleiches entitaet_typ/entitaet_id-Muster wie Historie/ + Mangel. Datei liegt lokal auf Platte (self-hosted, kein Cloud-Zwang), + speicherpfad ist relativ zu settings.upload_dir.""" + + __tablename__ = "dokument" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + entitaet_typ: Mapped[str] = mapped_column(String, nullable=False) + entitaet_id: Mapped[str] = mapped_column(String, nullable=False) + dateiname: Mapped[str] = mapped_column(String, nullable=False) + speicherpfad: Mapped[str] = mapped_column(String, nullable=False) + mime_type: Mapped[str] = mapped_column(String, nullable=False) + groesse_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + beschreibung: Mapped[str | None] = mapped_column(String) + hochgeladen_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False) + hochgeladen_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) diff --git a/backend/app/schemas/dokument.py b/backend/app/schemas/dokument.py new file mode 100644 index 0000000..cb215ae --- /dev/null +++ b/backend/app/schemas/dokument.py @@ -0,0 +1,25 @@ +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +# Bewusst geschlossene Liste statt Freitext (Konsistenz mit Mangel.entitaet_typ- +# Validierung) - jeder Ressourcentyp, an den Dokumente angehängt werden dürfen, +# muss hier explizit freigeschaltet werden. +EntitaetTyp = Literal[ + "objekt", "objektposition", "geraet_instanz", "mangel", "fahrzeugdetails", "benutzer" +] + + +class DokumentRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + entitaet_typ: str + entitaet_id: str + dateiname: str + mime_type: str + groesse_bytes: int + beschreibung: str | None + hochgeladen_von: int + hochgeladen_am: datetime diff --git a/backend/app/services/dokument.py b/backend/app/services/dokument.py new file mode 100644 index 0000000..0f2203a --- /dev/null +++ b/backend/app/services/dokument.py @@ -0,0 +1,99 @@ +import os +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.app_settings import settings +from app.models.dokument import Dokument + +# Erlaubte MIME-Types (Nutzer-Vorgabe Modul 10: PDF/Bilder/Prüfprotokolle/ +# Wartungsberichte/Bedienungsanleitungen/Rechnungen/Zulassungsdokumente) - +# Whitelist statt Blacklist (owasp-Grundsatz: Datei-Upload ist klassischer +# Angriffsvektor, z.B. .html/.svg mit eingebettetem Skript). +ERLAUBTE_MIME_TYPES = { + "application/pdf", + "image/jpeg", + "image/png", + "image/webp", +} + + +class DateityperlaubtError(Exception): + pass + + +class DateizugrossError(Exception): + pass + + +def _upload_pfad() -> Path: + pfad = Path(settings.upload_dir) + pfad.mkdir(parents=True, exist_ok=True) + return pfad + + +async def speichere_dokument( + db: AsyncSession, + *, + entitaet_typ: str, + entitaet_id: str, + dateiname: str, + mime_type: str, + inhalt: bytes, + beschreibung: str | None, + hochgeladen_von: int, +) -> Dokument: + if mime_type not in ERLAUBTE_MIME_TYPES: + raise DateityperlaubtError(mime_type) + if len(inhalt) > settings.max_upload_size_mb * 1024 * 1024: + raise DateizugrossError(len(inhalt)) + + # Speichername ist server-generiert (UUID), NIEMALS der Original-Dateiname - + # verhindert Path-Traversal (../../etc/passwd) und Namenskollisionen. + endung = Path(dateiname).suffix[:10] + speichername = f"{uuid.uuid4()}{endung}" + ziel = _upload_pfad() / speichername + ziel.write_bytes(inhalt) + + dokument = Dokument( + entitaet_typ=entitaet_typ, + entitaet_id=entitaet_id, + dateiname=dateiname, + speicherpfad=speichername, + mime_type=mime_type, + groesse_bytes=len(inhalt), + beschreibung=beschreibung, + hochgeladen_von=hochgeladen_von, + hochgeladen_am=datetime.now(timezone.utc), + ) + db.add(dokument) + await db.flush() + return dokument + + +async def liste_fuer_entitaet(db: AsyncSession, *, entitaet_typ: str, entitaet_id: str) -> list[Dokument]: + result = await db.execute( + select(Dokument) + .where(Dokument.entitaet_typ == entitaet_typ, Dokument.entitaet_id == entitaet_id) + .order_by(Dokument.hochgeladen_am.desc()) + ) + return list(result.scalars().all()) + + +def dateipfad(dokument: Dokument) -> Path: + return _upload_pfad() / dokument.speicherpfad + + +async def loesche_dokument(db: AsyncSession, *, dokument: Dokument) -> None: + pfad = dateipfad(dokument) + await db.delete(dokument) + await db.flush() + # Datei erst nach erfolgreichem DB-Commit-Vorbereiten löschen (flush wirft + # bei FK-Problemen, bevor die Datei weg ist) - hier gibt es keine + # eingehenden FKs auf dokument, daher unkritisch, aber Reihenfolge bewusst + # gewählt für den Fall künftiger Referenzen. + if pfad.exists(): + os.remove(pfad) diff --git a/backend/example.env b/backend/example.env index e3ba378..3fec028 100644 --- a/backend/example.env +++ b/backend/example.env @@ -7,3 +7,8 @@ ACCESS_TOKEN_EXPIRE_MINUTES=480 # ID des systemknoten-Datensatzes mit typ='haupt' (Sprintplan E6, siehe alembic/versions/0002_seed_hauptserver.py) SYSTEMKNOTEN_ID=1 + +# Dokumente-Modul: Ablageverzeichnis außerhalb des Anwendungsverzeichnisses +# (z.B. /opt/mabea/uploads), damit ein Redeploy hochgeladene Dateien nicht löscht. +UPLOAD_DIR=/opt/mabea/uploads +MAX_UPLOAD_SIZE_MB=25 diff --git a/backend/tests/test_dokument.py b/backend/tests/test_dokument.py new file mode 100644 index 0000000..51a1888 --- /dev/null +++ b/backend/tests/test_dokument.py @@ -0,0 +1,96 @@ +import pytest + +from tests.conftest import auth_header, login + + +@pytest.mark.asyncio +async def test_dokument_hochladen_und_liste(client, objekt_mit_position, mitarbeiter_user): + objekt, _material = objekt_mit_position + token = await login(client, "mitarbeiter1") + + hochgeladen = await client.post( + "/api/v1/dokumente", + data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "beschreibung": "Zulassung"}, + files={"datei": ("zulassung.pdf", b"%PDF-1.4 fake", "application/pdf")}, + headers=auth_header(token), + ) + assert hochgeladen.status_code == 201 + body = hochgeladen.json() + assert body["dateiname"] == "zulassung.pdf" + assert body["groesse_bytes"] > 0 + + liste = await client.get( + f"/api/v1/dokumente?entitaet_typ=objekt&entitaet_id={objekt.id}", headers=auth_header(token) + ) + assert len(liste.json()) == 1 + + +@pytest.mark.asyncio +async def test_download_liefert_inhalt(client, objekt_mit_position, mitarbeiter_user): + objekt, _material = objekt_mit_position + token = await login(client, "mitarbeiter1") + hochgeladen = await client.post( + "/api/v1/dokumente", + data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)}, + files={"datei": ("foto.png", b"\x89PNG fake", "image/png")}, + headers=auth_header(token), + ) + dokument_id = hochgeladen.json()["id"] + + download = await client.get(f"/api/v1/dokumente/{dokument_id}/download", headers=auth_header(token)) + assert download.status_code == 200 + assert download.content == b"\x89PNG fake" + + +@pytest.mark.asyncio +async def test_unerlaubter_dateityp_wird_abgelehnt(client, objekt_mit_position, mitarbeiter_user): + objekt, _material = objekt_mit_position + token = await login(client, "mitarbeiter1") + + response = await client.post( + "/api/v1/dokumente", + data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)}, + files={"datei": ("boese.html", b"", "text/html")}, + headers=auth_header(token), + ) + assert response.status_code == 415 + + +@pytest.mark.asyncio +async def test_materialverantwortlicher_kann_dokument_loeschen( + client, objekt_mit_position, mitarbeiter_user, materialverantwortlicher_user +): + objekt, _material = objekt_mit_position + mitarbeiter_token = await login(client, "mitarbeiter1") + hochgeladen = await client.post( + "/api/v1/dokumente", + data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)}, + files={"datei": ("bericht.pdf", b"%PDF-1.4 x", "application/pdf")}, + headers=auth_header(mitarbeiter_token), + ) + dokument_id = hochgeladen.json()["id"] + + verantwortlicher_token = await login(client, "materialverantwortlicher1") + geloescht = await client.delete(f"/api/v1/dokumente/{dokument_id}", headers=auth_header(verantwortlicher_token)) + assert geloescht.status_code == 204 + + liste = await client.get( + f"/api/v1/dokumente?entitaet_typ=objekt&entitaet_id={objekt.id}", headers=auth_header(verantwortlicher_token) + ) + assert liste.json() == [] + + +@pytest.mark.asyncio +async def test_mitarbeiter_darf_dokument_nicht_loeschen(client, objekt_mit_position, mitarbeiter_user): + objekt, _material = objekt_mit_position + token = await login(client, "mitarbeiter1") + hochgeladen = await client.post( + "/api/v1/dokumente", + data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)}, + files={"datei": ("x.pdf", b"%PDF-1.4 x", "application/pdf")}, + headers=auth_header(token), + ) + dokument_id = hochgeladen.json()["id"] + + response = await client.delete(f"/api/v1/dokumente/{dokument_id}", headers=auth_header(token)) + assert response.status_code == 403 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index dba5b97..6a1b1d7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -64,6 +64,48 @@ export async function apiRequest(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 { + 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 = {}; + 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 -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 { + const headers: Record = {}; + 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 { const body = new URLSearchParams({ username, password }); const response = await fetch(`${BASE_URL}/auth/login`, { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 3a001d6..0f33458 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; diff --git a/frontend/src/components/DokumentePanel.tsx b/frontend/src/components/DokumentePanel.tsx new file mode 100644 index 0000000..1d0d2fc --- /dev/null +++ b/frontend/src/components/DokumentePanel.tsx @@ -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([]); + const [laedt, setLaedt] = useState(true); + const [beschreibung, setBeschreibung] = useState(""); + const [wirdHochgeladen, setWirdHochgeladen] = useState(false); + + async function laden() { + setLaedt(true); + try { + const daten = await apiRequest( + `/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 ( +
+
+ setBeschreibung(e.target.value)} + placeholder="Beschreibung (optional)" + style={{ maxWidth: "16rem" }} + /> + +
+ {laedt &&

Lade…

} + {!laedt && dokumente.length === 0 &&

Keine Dokumente.

} + {!laedt && dokumente.length > 0 && ( +
    + {dokumente.map((d) => ( +
  • + + + {d.beschreibung && – {d.beschreibung}} + ({formatGroesse(d.groesse_bytes)}) + + {darfLoeschen && ( + + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/MangelListePage.tsx b/frontend/src/pages/MangelListePage.tsx index eed1005..542c940 100644 --- a/frontend/src/pages/MangelListePage.tsx +++ b/frontend/src/pages/MangelListePage.tsx @@ -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 = { @@ -37,6 +38,7 @@ export function MangelListePage() { const [beschreibung, setBeschreibung] = useState(""); const [prioritaet, setPrioritaet] = useState("normal"); const [wirdGemeldet, setWirdGemeldet] = useState(false); + const [fotosOffenId, setFotosOffenId] = useState(null); async function laden() { setLaedt(true); @@ -160,6 +162,18 @@ export function MangelListePage() { ))} )} + + {fotosOffenId === m.id && ( +
+ +
+ )} ))} diff --git a/frontend/src/pages/admin/ObjektSection.tsx b/frontend/src/pages/admin/ObjektSection.tsx index b8ec5ca..eea1232 100644 --- a/frontend/src/pages/admin/ObjektSection.tsx +++ b/frontend/src/pages/admin/ObjektSection.tsx @@ -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(""); const [wirdAngelegt, setWirdAngelegt] = useState(false); const [ausgeklapptId, setAusgeklapptId] = useState(null); + const [dokumenteOffenId, setDokumenteOffenId] = useState(null); const [duplizierId, setDuplizierId] = useState(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"} + {!nurPflegen && (