feat(dokumente): DOC-002 feste Dokumenttypen statt Freitext
CI / backend-tests (push) Successful in 2m14s
CI / frontend-build (push) Successful in 36s

Neues Pflichtfeld dokumenttyp (Enum: Prüfprotokoll/Wartungsbericht/
Bedienungsanleitung/Rechnung/Zulassungsdokument/Sonstiges, Migration 0026)
- macht Dokumente kategorisier- und filterbar statt nur per Freitext-
Beschreibung auffindbar zu sein. Backend: Pflichtfeld beim Upload, optionaler
Query-Filter bei GET /dokumente. Frontend: Auswahl-Dropdown beim Upload,
Typ-Badge + Filter-Dropdown in der Liste (DokumentePanel).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-08 10:32:25 +02:00
co-authored by Claude Sonnet 5
parent 0619497a1e
commit 069a1ee7ee
10 changed files with 191 additions and 24 deletions
+1 -1
View File
@@ -215,7 +215,7 @@ vergeben statt geraten.
| READY-004 | Readiness | Readiness-Dashboard | P1 | S | LOW | READY-003 | ✅ (`EinsatzbereitschaftTile.tsx`, `/dashboard/einsatzbereitschaft`) | | READY-004 | Readiness | Readiness-Dashboard | P1 | S | LOW | READY-003 | ✅ (`EinsatzbereitschaftTile.tsx`, `/dashboard/einsatzbereitschaft`) |
| READY-005 | Readiness | Regel-Konfiguration-UI | P2 | M | MEDIUM | READY-003 | ⬜ (keine UI zur Regel-Konfiguration gefunden, Regeln sind Code) | | READY-005 | Readiness | Regel-Konfiguration-UI | P2 | M | MEDIUM | READY-003 | ⬜ (keine UI zur Regel-Konfiguration gefunden, Regeln sind Code) |
| DOC-001 | Documents | Dokument-Grundgerüst (Upload) | P1 | M | MEDIUM | FILE-001 | ✅ (`dokument.py` Model+Endpunkt, inkl. SHA-256-Duplikaterkennung, Migration 0024/0016 referenzieren DOC-001) | | DOC-001 | Documents | Dokument-Grundgerüst (Upload) | P1 | M | MEDIUM | FILE-001 | ✅ (`dokument.py` Model+Endpunkt, inkl. SHA-256-Duplikaterkennung, Migration 0024/0016 referenzieren DOC-001) |
| DOC-002 | Documents | Dokumenttypen | P2 | XS | LOW | DOC-001 | (kein `dokumenttyp`-Feld im Modell gefunden) | | DOC-002 | Documents | Dokumenttypen | P2 | XS | LOW | DOC-001 | (`DokumentTyp`-Enum, Migration 0026, Filter beim Listen, Auswahl beim Upload) |
| DOC-003 | Documents | Versionierung | P2 | M | MEDIUM | DOC-001 | ⬜ (kein Versionsfeld/-verweis im Dokument-Modell) | | DOC-003 | Documents | Versionierung | P2 | M | MEDIUM | DOC-001 | ⬜ (kein Versionsfeld/-verweis im Dokument-Modell) |
| DOC-004 | Documents | Original-vs-Kopie-Kennzeichnung | P2 | S | LOW | DOC-001 | ⬜ (kein entsprechendes Feld gefunden) | | DOC-004 | Documents | Original-vs-Kopie-Kennzeichnung | P2 | S | LOW | DOC-001 | ⬜ (kein entsprechendes Feld gefunden) |
| DOC-005 | Documents | Zugriffsrechte je Dokument | P2 | S | MEDIUM | DOC-001, FOUND-004 | ⬜ (kein dokumentspezifisches Berechtigungsmodell gefunden, nur allgemeine Rollen) | | DOC-005 | Documents | Zugriffsrechte je Dokument | P2 | S | MEDIUM | DOC-001, FOUND-004 | ⬜ (kein dokumentspezifisches Berechtigungsmodell gefunden, nur allgemeine Rollen) |
@@ -0,0 +1,33 @@
"""DOC-002: feste Dokumenttypen statt reinem Freitext (Kategorie beim Upload).
Revision ID: 0026_dokumenttyp
Revises: 0025_material_suchindex
Create Date: 2026-09-08
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0026_dokumenttyp"
down_revision: Union[str, None] = "0025_material_suchindex"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"CREATE TYPE dokument_typ AS ENUM "
"('pruefprotokoll', 'wartungsbericht', 'bedienungsanleitung', 'rechnung', "
"'zulassungsdokument', 'sonstiges')"
)
op.execute(
"ALTER TABLE dokument ADD COLUMN dokumenttyp dokument_typ NOT NULL DEFAULT 'sonstiges'"
)
# Default nur für den Backfill nötig - künftige Inserts sollen den Typ immer
# explizit mitgeben (Anwendungsebene), kein stiller Fallback mehr.
op.execute("ALTER TABLE dokument ALTER COLUMN dokumenttyp DROP DEFAULT")
def downgrade() -> None:
op.execute("ALTER TABLE dokument DROP COLUMN dokumenttyp")
op.execute("DROP TYPE dokument_typ")
+7 -2
View File
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, require_roles from app.api.deps import get_current_user, require_roles
from app.db.session import get_db from app.db.session import get_db
from app.models.auth import RolleTyp from app.models.auth import RolleTyp
from app.models.dokument import Dokument from app.models.dokument import Dokument, DokumentTyp
from app.schemas.dokument import DokumentRead, EntitaetTyp from app.schemas.dokument import DokumentRead, EntitaetTyp
from app.services.dokument import ( from app.services.dokument import (
DateityperlaubtError, DateityperlaubtError,
@@ -36,16 +36,20 @@ _materialverantwortliche = require_roles(
async def liste_dokumente( async def liste_dokumente(
entitaet_typ: EntitaetTyp, entitaet_typ: EntitaetTyp,
entitaet_id: str, entitaet_id: str,
dokumenttyp: DokumentTyp | None = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_=Depends(get_current_user), _=Depends(get_current_user),
) -> list[Dokument]: ) -> list[Dokument]:
return await liste_fuer_entitaet(db, entitaet_typ=entitaet_typ, entitaet_id=entitaet_id) return await liste_fuer_entitaet(
db, entitaet_typ=entitaet_typ, entitaet_id=entitaet_id, dokumenttyp=dokumenttyp
)
@router.post("/dokumente", response_model=DokumentRead, status_code=status.HTTP_201_CREATED) @router.post("/dokumente", response_model=DokumentRead, status_code=status.HTTP_201_CREATED)
async def lade_dokument_hoch( async def lade_dokument_hoch(
entitaet_typ: EntitaetTyp = Form(...), entitaet_typ: EntitaetTyp = Form(...),
entitaet_id: str = Form(...), entitaet_id: str = Form(...),
dokumenttyp: DokumentTyp = Form(...),
beschreibung: str | None = Form(None), beschreibung: str | None = Form(None),
datei: UploadFile = File(...), datei: UploadFile = File(...),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -60,6 +64,7 @@ async def lade_dokument_hoch(
dateiname=datei.filename or "unbenannt", dateiname=datei.filename or "unbenannt",
mime_type=datei.content_type or "application/octet-stream", mime_type=datei.content_type or "application/octet-stream",
inhalt=inhalt, inhalt=inhalt,
dokumenttyp=dokumenttyp,
beschreibung=beschreibung, beschreibung=beschreibung,
hochgeladen_von=current_user.id, hochgeladen_von=current_user.id,
) )
+21 -1
View File
@@ -1,13 +1,30 @@
import enum
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import ForeignKey, Integer, String from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID from sqlalchemy.dialects.postgresql import ENUM as PgEnum, TIMESTAMP, UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base from app.db.base import Base
class DokumentTyp(str, enum.Enum):
"""DOC-002: feste Kategorie statt Freitext - macht Dokumente filter-/
wiederfindbar (Nutzer-Vorgabe Modul 10: Prüfprotokolle/Wartungsberichte/
Bedienungsanleitungen/Rechnungen/Zulassungsdokumente)."""
pruefprotokoll = "pruefprotokoll"
wartungsbericht = "wartungsbericht"
bedienungsanleitung = "bedienungsanleitung"
rechnung = "rechnung"
zulassungsdokument = "zulassungsdokument"
sonstiges = "sonstiges"
dokument_typ_pg = PgEnum(DokumentTyp, name="dokument_typ", create_type=False)
class Dokument(Base): class Dokument(Base):
"""Roadmap Phase 5 (Modul Dokumente): polymorpher Datei-Anhang an beliebige """Roadmap Phase 5 (Modul Dokumente): polymorpher Datei-Anhang an beliebige
Ressource (Objekt, Objektposition, Geräteinstanz, Mangel, Fahrzeugdetails, Ressource (Objekt, Objektposition, Geräteinstanz, Mangel, Fahrzeugdetails,
@@ -30,6 +47,9 @@ class Dokument(Base):
# Nullable wegen Altbestand vor dieser Migration (Hash nachträglich ohne # Nullable wegen Altbestand vor dieser Migration (Hash nachträglich ohne
# Dateizugriff nicht sinnvoll befüllbar); jeder neue Upload setzt ihn. # Dateizugriff nicht sinnvoll befüllbar); jeder neue Upload setzt ihn.
dateihash: Mapped[str | None] = mapped_column(String(64)) dateihash: Mapped[str | None] = mapped_column(String(64))
dokumenttyp: Mapped[DokumentTyp] = mapped_column(
dokument_typ_pg, nullable=False, default=DokumentTyp.sonstiges
)
beschreibung: Mapped[str | None] = mapped_column(String) beschreibung: Mapped[str | None] = mapped_column(String)
hochgeladen_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False) hochgeladen_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
hochgeladen_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) hochgeladen_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
+3
View File
@@ -4,6 +4,8 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from app.models.dokument import DokumentTyp
# Bewusst geschlossene Liste statt Freitext (Konsistenz mit Mangel.entitaet_typ- # Bewusst geschlossene Liste statt Freitext (Konsistenz mit Mangel.entitaet_typ-
# Validierung) - jeder Ressourcentyp, an den Dokumente angehängt werden dürfen, # Validierung) - jeder Ressourcentyp, an den Dokumente angehängt werden dürfen,
# muss hier explizit freigeschaltet werden. # muss hier explizit freigeschaltet werden.
@@ -20,6 +22,7 @@ class DokumentRead(BaseModel):
dateiname: str dateiname: str
mime_type: str mime_type: str
groesse_bytes: int groesse_bytes: int
dokumenttyp: DokumentTyp
beschreibung: str | None beschreibung: str | None
hochgeladen_von: int hochgeladen_von: int
hochgeladen_am: datetime hochgeladen_am: datetime
+10 -7
View File
@@ -8,7 +8,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.app_settings import settings from app.core.app_settings import settings
from app.models.dokument import Dokument from app.models.dokument import Dokument, DokumentTyp
# Erlaubte MIME-Types (Nutzer-Vorgabe Modul 10: PDF/Bilder/Prüfprotokolle/ # Erlaubte MIME-Types (Nutzer-Vorgabe Modul 10: PDF/Bilder/Prüfprotokolle/
# Wartungsberichte/Bedienungsanleitungen/Rechnungen/Zulassungsdokumente) - # Wartungsberichte/Bedienungsanleitungen/Rechnungen/Zulassungsdokumente) -
@@ -52,6 +52,7 @@ async def speichere_dokument(
dateiname: str, dateiname: str,
mime_type: str, mime_type: str,
inhalt: bytes, inhalt: bytes,
dokumenttyp: DokumentTyp,
beschreibung: str | None, beschreibung: str | None,
hochgeladen_von: int, hochgeladen_von: int,
) -> Dokument: ) -> Dokument:
@@ -87,6 +88,7 @@ async def speichere_dokument(
mime_type=mime_type, mime_type=mime_type,
groesse_bytes=len(inhalt), groesse_bytes=len(inhalt),
dateihash=dateihash, dateihash=dateihash,
dokumenttyp=dokumenttyp,
beschreibung=beschreibung, beschreibung=beschreibung,
hochgeladen_von=hochgeladen_von, hochgeladen_von=hochgeladen_von,
hochgeladen_am=datetime.now(timezone.utc), hochgeladen_am=datetime.now(timezone.utc),
@@ -96,12 +98,13 @@ async def speichere_dokument(
return dokument return dokument
async def liste_fuer_entitaet(db: AsyncSession, *, entitaet_typ: str, entitaet_id: str) -> list[Dokument]: async def liste_fuer_entitaet(
result = await db.execute( db: AsyncSession, *, entitaet_typ: str, entitaet_id: str, dokumenttyp: DokumentTyp | None = None
select(Dokument) ) -> list[Dokument]:
.where(Dokument.entitaet_typ == entitaet_typ, Dokument.entitaet_id == entitaet_id) bedingungen = [Dokument.entitaet_typ == entitaet_typ, Dokument.entitaet_id == entitaet_id]
.order_by(Dokument.hochgeladen_am.desc()) if dokumenttyp is not None:
) bedingungen.append(Dokument.dokumenttyp == dokumenttyp)
result = await db.execute(select(Dokument).where(*bedingungen).order_by(Dokument.hochgeladen_am.desc()))
return list(result.scalars().all()) return list(result.scalars().all())
+57 -9
View File
@@ -10,7 +10,7 @@ async def test_dokument_hochladen_und_liste(client, objekt_mit_position, mitarbe
hochgeladen = await client.post( hochgeladen = await client.post(
"/api/v1/dokumente", "/api/v1/dokumente",
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "beschreibung": "Zulassung"}, data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "zulassungsdokument", "beschreibung": "Zulassung"},
files={"datei": ("zulassung.pdf", b"%PDF-1.4 fake", "application/pdf")}, files={"datei": ("zulassung.pdf", b"%PDF-1.4 fake", "application/pdf")},
headers=auth_header(token), headers=auth_header(token),
) )
@@ -31,7 +31,7 @@ async def test_download_liefert_inhalt(client, objekt_mit_position, mitarbeiter_
token = await login(client, "mitarbeiter1") token = await login(client, "mitarbeiter1")
hochgeladen = await client.post( hochgeladen = await client.post(
"/api/v1/dokumente", "/api/v1/dokumente",
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)}, data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "sonstiges"},
files={"datei": ("foto.png", b"\x89PNG fake", "image/png")}, files={"datei": ("foto.png", b"\x89PNG fake", "image/png")},
headers=auth_header(token), headers=auth_header(token),
) )
@@ -49,7 +49,7 @@ async def test_unerlaubter_dateityp_wird_abgelehnt(client, objekt_mit_position,
response = await client.post( response = await client.post(
"/api/v1/dokumente", "/api/v1/dokumente",
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)}, data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "sonstiges"},
files={"datei": ("boese.html", b"<script>alert(1)</script>", "text/html")}, files={"datei": ("boese.html", b"<script>alert(1)</script>", "text/html")},
headers=auth_header(token), headers=auth_header(token),
) )
@@ -64,7 +64,7 @@ async def test_materialverantwortlicher_kann_dokument_loeschen(
mitarbeiter_token = await login(client, "mitarbeiter1") mitarbeiter_token = await login(client, "mitarbeiter1")
hochgeladen = await client.post( hochgeladen = await client.post(
"/api/v1/dokumente", "/api/v1/dokumente",
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)}, data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "sonstiges"},
files={"datei": ("bericht.pdf", b"%PDF-1.4 x", "application/pdf")}, files={"datei": ("bericht.pdf", b"%PDF-1.4 x", "application/pdf")},
headers=auth_header(mitarbeiter_token), headers=auth_header(mitarbeiter_token),
) )
@@ -86,7 +86,7 @@ async def test_mitarbeiter_darf_dokument_nicht_loeschen(client, objekt_mit_posit
token = await login(client, "mitarbeiter1") token = await login(client, "mitarbeiter1")
hochgeladen = await client.post( hochgeladen = await client.post(
"/api/v1/dokumente", "/api/v1/dokumente",
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)}, data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "sonstiges"},
files={"datei": ("x.pdf", b"%PDF-1.4 x", "application/pdf")}, files={"datei": ("x.pdf", b"%PDF-1.4 x", "application/pdf")},
headers=auth_header(token), headers=auth_header(token),
) )
@@ -105,7 +105,7 @@ async def test_identische_datei_am_gleichen_objekt_wird_abgelehnt(client, objekt
erster = await client.post( erster = await client.post(
"/api/v1/dokumente", "/api/v1/dokumente",
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)}, data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "sonstiges"},
files={"datei": ("original.pdf", inhalt, "application/pdf")}, files={"datei": ("original.pdf", inhalt, "application/pdf")},
headers=auth_header(token), headers=auth_header(token),
) )
@@ -113,7 +113,7 @@ async def test_identische_datei_am_gleichen_objekt_wird_abgelehnt(client, objekt
zweiter = await client.post( zweiter = await client.post(
"/api/v1/dokumente", "/api/v1/dokumente",
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)}, data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "sonstiges"},
files={"datei": ("kopie.pdf", inhalt, "application/pdf")}, files={"datei": ("kopie.pdf", inhalt, "application/pdf")},
headers=auth_header(token), headers=auth_header(token),
) )
@@ -136,14 +136,62 @@ async def test_identische_datei_an_anderer_entitaet_ist_kein_duplikat(client, ob
await client.post( await client.post(
"/api/v1/dokumente", "/api/v1/dokumente",
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)}, data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "sonstiges"},
files={"datei": ("anleitung.pdf", inhalt, "application/pdf")}, files={"datei": ("anleitung.pdf", inhalt, "application/pdf")},
headers=auth_header(token), headers=auth_header(token),
) )
andere_entitaet = await client.post( andere_entitaet = await client.post(
"/api/v1/dokumente", "/api/v1/dokumente",
data={"entitaet_typ": "mangel", "entitaet_id": "irgendein-mangel-id"}, data={"entitaet_typ": "mangel", "entitaet_id": "irgendein-mangel-id", "dokumenttyp": "sonstiges"},
files={"datei": ("anleitung.pdf", inhalt, "application/pdf")}, files={"datei": ("anleitung.pdf", inhalt, "application/pdf")},
headers=auth_header(token), headers=auth_header(token),
) )
assert andere_entitaet.status_code == 201 assert andere_entitaet.status_code == 201
@pytest.mark.asyncio
async def test_liste_kann_nach_dokumenttyp_gefiltert_werden(client, objekt_mit_position, mitarbeiter_user):
"""DOC-002: feste Kategorie statt Freitext - Dokumente pro Typ auffindbar."""
objekt, _material = objekt_mit_position
token = await login(client, "mitarbeiter1")
await client.post(
"/api/v1/dokumente",
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "rechnung"},
files={"datei": ("rechnung.pdf", b"%PDF-1.4 r", "application/pdf")},
headers=auth_header(token),
)
await client.post(
"/api/v1/dokumente",
data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "pruefprotokoll"},
files={"datei": ("pruefung.pdf", b"%PDF-1.4 p", "application/pdf")},
headers=auth_header(token),
)
alle = await client.get(
f"/api/v1/dokumente?entitaet_typ=objekt&entitaet_id={objekt.id}", headers=auth_header(token)
)
assert len(alle.json()) == 2
nur_rechnungen = await client.get(
f"/api/v1/dokumente?entitaet_typ=objekt&entitaet_id={objekt.id}&dokumenttyp=rechnung",
headers=auth_header(token),
)
body = nur_rechnungen.json()
assert len(body) == 1
assert body[0]["dateiname"] == "rechnung.pdf"
assert body[0]["dokumenttyp"] == "rechnung"
@pytest.mark.asyncio
async def test_unbekannter_dokumenttyp_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), "dokumenttyp": "erfunden"},
files={"datei": ("x.pdf", b"%PDF-1.4 x", "application/pdf")},
headers=auth_header(token),
)
assert response.status_code == 422
+2
View File
@@ -67,12 +67,14 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
export async function ladeDokumentHoch(felder: { export async function ladeDokumentHoch(felder: {
entitaet_typ: string; entitaet_typ: string;
entitaet_id: string; entitaet_id: string;
dokumenttyp: string;
beschreibung?: string; beschreibung?: string;
datei: File; datei: File;
}): Promise<unknown> { }): Promise<unknown> {
const formData = new FormData(); const formData = new FormData();
formData.append("entitaet_typ", felder.entitaet_typ); formData.append("entitaet_typ", felder.entitaet_typ);
formData.append("entitaet_id", felder.entitaet_id); formData.append("entitaet_id", felder.entitaet_id);
formData.append("dokumenttyp", felder.dokumenttyp);
if (felder.beschreibung) formData.append("beschreibung", felder.beschreibung); if (felder.beschreibung) formData.append("beschreibung", felder.beschreibung);
formData.append("datei", felder.datei); formData.append("datei", felder.datei);
+9
View File
@@ -125,6 +125,14 @@ export interface Mangel {
erledigt_am: string | null; erledigt_am: string | null;
} }
export type DokumentTyp =
| "pruefprotokoll"
| "wartungsbericht"
| "bedienungsanleitung"
| "rechnung"
| "zulassungsdokument"
| "sonstiges";
export interface Dokument { export interface Dokument {
id: string; id: string;
entitaet_typ: string; entitaet_typ: string;
@@ -132,6 +140,7 @@ export interface Dokument {
dateiname: string; dateiname: string;
mime_type: string; mime_type: string;
groesse_bytes: number; groesse_bytes: number;
dokumenttyp: DokumentTyp;
beschreibung: string | null; beschreibung: string | null;
hochgeladen_von: number; hochgeladen_von: number;
hochgeladen_am: string; hochgeladen_am: string;
+48 -4
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { ApiError, apiRequest, ladeDokumentAnsehen, ladeDokumentHerunter, ladeDokumentHoch } from "../api/client"; import { ApiError, apiRequest, ladeDokumentAnsehen, ladeDokumentHerunter, ladeDokumentHoch } from "../api/client";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import type { Dokument } from "../api/types"; import type { Dokument, DokumentTyp } from "../api/types";
interface Props { interface Props {
entitaetTyp: string; entitaetTyp: string;
@@ -10,6 +10,17 @@ interface Props {
onFehler: (text: string) => void; onFehler: (text: string) => void;
} }
const DOKUMENTTYP_LABEL: Record<DokumentTyp, string> = {
pruefprotokoll: "Prüfprotokoll",
wartungsbericht: "Wartungsbericht",
bedienungsanleitung: "Bedienungsanleitung",
rechnung: "Rechnung",
zulassungsdokument: "Zulassungsdokument",
sonstiges: "Sonstiges",
};
const DOKUMENTTYPEN = Object.keys(DOKUMENTTYP_LABEL) as DokumentTyp[];
function formatGroesse(bytes: number): string { function formatGroesse(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
@@ -26,13 +37,16 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
const [dokumente, setDokumente] = useState<Dokument[]>([]); const [dokumente, setDokumente] = useState<Dokument[]>([]);
const [laedt, setLaedt] = useState(true); const [laedt, setLaedt] = useState(true);
const [beschreibung, setBeschreibung] = useState(""); const [beschreibung, setBeschreibung] = useState("");
const [dokumenttyp, setDokumenttyp] = useState<DokumentTyp>("sonstiges");
const [filterTyp, setFilterTyp] = useState<DokumentTyp | "">("");
const [wirdHochgeladen, setWirdHochgeladen] = useState(false); const [wirdHochgeladen, setWirdHochgeladen] = useState(false);
async function laden() { async function laden() {
setLaedt(true); setLaedt(true);
try { try {
const filter = filterTyp ? `&dokumenttyp=${filterTyp}` : "";
const daten = await apiRequest<Dokument[]>( const daten = await apiRequest<Dokument[]>(
`/dokumente?entitaet_typ=${entitaetTyp}&entitaet_id=${encodeURIComponent(entitaetId)}` `/dokumente?entitaet_typ=${entitaetTyp}&entitaet_id=${encodeURIComponent(entitaetId)}${filter}`
); );
setDokumente(daten); setDokumente(daten);
} catch { } catch {
@@ -45,14 +59,14 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
useEffect(() => { useEffect(() => {
laden(); laden();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [entitaetTyp, entitaetId]); }, [entitaetTyp, entitaetId, filterTyp]);
async function hochladen(e: { target: HTMLInputElement }) { async function hochladen(e: { target: HTMLInputElement }) {
const datei = e.target.files?.[0]; const datei = e.target.files?.[0];
if (!datei) return; if (!datei) return;
setWirdHochgeladen(true); setWirdHochgeladen(true);
try { try {
await ladeDokumentHoch({ entitaet_typ: entitaetTyp, entitaet_id: entitaetId, beschreibung, datei }); await ladeDokumentHoch({ entitaet_typ: entitaetTyp, entitaet_id: entitaetId, dokumenttyp, beschreibung, datei });
setBeschreibung(""); setBeschreibung("");
e.target.value = ""; e.target.value = "";
await laden(); await laden();
@@ -96,6 +110,18 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
return ( return (
<div className="stack"> <div className="stack">
<div className="row" style={{ flexWrap: "wrap" }}> <div className="row" style={{ flexWrap: "wrap" }}>
<select
className="input"
value={dokumenttyp}
onChange={(e) => setDokumenttyp(e.target.value as DokumentTyp)}
style={{ maxWidth: "12rem" }}
>
{DOKUMENTTYPEN.map((typ) => (
<option key={typ} value={typ}>
{DOKUMENTTYP_LABEL[typ]}
</option>
))}
</select>
<input <input
className="input" className="input"
value={beschreibung} value={beschreibung}
@@ -105,6 +131,21 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
/> />
<input type="file" accept=".pdf,.jpg,.jpeg,.png,.webp" onChange={hochladen} disabled={wirdHochgeladen} /> <input type="file" accept=".pdf,.jpg,.jpeg,.png,.webp" onChange={hochladen} disabled={wirdHochgeladen} />
</div> </div>
{dokumente.length > 0 || filterTyp ? (
<select
className="input"
value={filterTyp}
onChange={(e) => setFilterTyp(e.target.value as DokumentTyp | "")}
style={{ maxWidth: "14rem" }}
>
<option value="">Alle Typen</option>
{DOKUMENTTYPEN.map((typ) => (
<option key={typ} value={typ}>
{DOKUMENTTYP_LABEL[typ]}
</option>
))}
</select>
) : null}
{laedt && <p className="text-muted">Lade</p>} {laedt && <p className="text-muted">Lade</p>}
{!laedt && dokumente.length === 0 && <p className="text-muted">Keine Dokumente.</p>} {!laedt && dokumente.length === 0 && <p className="text-muted">Keine Dokumente.</p>}
{!laedt && dokumente.length > 0 && ( {!laedt && dokumente.length > 0 && (
@@ -112,6 +153,9 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
{dokumente.map((d) => ( {dokumente.map((d) => (
<li key={d.id} className="row-between" style={{ padding: "0.3rem 0" }}> <li key={d.id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span> <span>
<span className="badge badge-neutral" style={{ marginRight: "0.4rem" }}>
{DOKUMENTTYP_LABEL[d.dokumenttyp]}
</span>
<button <button
onClick={() => ansehen(d)} onClick={() => ansehen(d)}
title="In neuem Tab ansehen" title="In neuem Tab ansehen"