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
+2
View File
@@ -67,12 +67,14 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
export async function ladeDokumentHoch(felder: {
entitaet_typ: string;
entitaet_id: string;
dokumenttyp: 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);
formData.append("dokumenttyp", felder.dokumenttyp);
if (felder.beschreibung) formData.append("beschreibung", felder.beschreibung);
formData.append("datei", felder.datei);
+9
View File
@@ -125,6 +125,14 @@ export interface Mangel {
erledigt_am: string | null;
}
export type DokumentTyp =
| "pruefprotokoll"
| "wartungsbericht"
| "bedienungsanleitung"
| "rechnung"
| "zulassungsdokument"
| "sonstiges";
export interface Dokument {
id: string;
entitaet_typ: string;
@@ -132,6 +140,7 @@ export interface Dokument {
dateiname: string;
mime_type: string;
groesse_bytes: number;
dokumenttyp: DokumentTyp;
beschreibung: string | null;
hochgeladen_von: number;
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 { useAuth } from "../auth/AuthContext";
import type { Dokument } from "../api/types";
import type { Dokument, DokumentTyp } from "../api/types";
interface Props {
entitaetTyp: string;
@@ -10,6 +10,17 @@ interface Props {
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 {
if (bytes < 1024) return `${bytes} B`;
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 [laedt, setLaedt] = useState(true);
const [beschreibung, setBeschreibung] = useState("");
const [dokumenttyp, setDokumenttyp] = useState<DokumentTyp>("sonstiges");
const [filterTyp, setFilterTyp] = useState<DokumentTyp | "">("");
const [wirdHochgeladen, setWirdHochgeladen] = useState(false);
async function laden() {
setLaedt(true);
try {
const filter = filterTyp ? `&dokumenttyp=${filterTyp}` : "";
const daten = await apiRequest<Dokument[]>(
`/dokumente?entitaet_typ=${entitaetTyp}&entitaet_id=${encodeURIComponent(entitaetId)}`
`/dokumente?entitaet_typ=${entitaetTyp}&entitaet_id=${encodeURIComponent(entitaetId)}${filter}`
);
setDokumente(daten);
} catch {
@@ -45,14 +59,14 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
useEffect(() => {
laden();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entitaetTyp, entitaetId]);
}, [entitaetTyp, entitaetId, filterTyp]);
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 });
await ladeDokumentHoch({ entitaet_typ: entitaetTyp, entitaet_id: entitaetId, dokumenttyp, beschreibung, datei });
setBeschreibung("");
e.target.value = "";
await laden();
@@ -96,6 +110,18 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
return (
<div className="stack">
<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
className="input"
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} />
</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 && dokumente.length === 0 && <p className="text-muted">Keine Dokumente.</p>}
{!laedt && dokumente.length > 0 && (
@@ -112,6 +153,9 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
{dokumente.map((d) => (
<li key={d.id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
<span className="badge badge-neutral" style={{ marginRight: "0.4rem" }}>
{DOKUMENTTYP_LABEL[d.dokumenttyp]}
</span>
<button
onClick={() => ansehen(d)}
title="In neuem Tab ansehen"