feat(dokumente,identity): DOC-003 Versionierung + QR-Code-Etiketten
CI / backend-tests (push) Failing after 2m15s
CI / frontend-build (push) Successful in 28s

Dokument-Versionierung: vorgaenger_id verkettet Versionen (Migration 0027),
POST /dokumente/{id}/ersetzen legt neue Version an statt zu überschreiben,
alte Fassung bleibt als Historie erhalten. GET /dokumente/{id}/versionen
liefert die Kette (neueste zuerst), respektiert DOC-005-Zugriffsrechte.
Liste zeigt nur die jeweils aktuelle Version. Löschen der aktuellen Version
gibt automatisch den Vorgänger als neue "aktuelle" frei. Frontend:
"Ersetzen"-Button + aufklappbare Versionshistorie im DokumentePanel.

QR-Code-Etiketten: bisher nur Code128-Barcode möglich. generiere_qr_label_pdf
(reportlab-eigenes QR-Widget, keine neue Abhängigkeit) als Alternative,
?format=qr|code128 an /objekte/{id}/label.pdf und /objektpositionen/{id}/
label.pdf. AktePage bekommt "Etikett (Barcode)"/"Etikett (QR)"-Buttons
(vorher gab es dafür noch gar keinen UI-Zugang im Web-Frontend).

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 11:03:46 +02:00
co-authored by Claude Sonnet 5
parent d5d8c72d9a
commit 977d81ddcd
14 changed files with 543 additions and 54 deletions
+35 -2
View File
@@ -88,6 +88,31 @@ export async function ladeDokumentHoch(felder: {
return response.json();
}
// DOC-003: neue Version eines bestehenden Dokuments - entitaet_typ/entitaet_id/
// dokumenttyp übernimmt das Backend vom Vorgänger, hier nicht nötig.
export async function ladeDokumentErsetzen(felder: {
dokumentId: string;
beschreibung?: string;
datei: File;
}): Promise<unknown> {
const formData = new FormData();
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/${felder.dokumentId}/ersetzen`, {
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.
@@ -120,13 +145,13 @@ export async function ladeDokumentHerunter(dokumentId: string, dateiname: string
// erst danach nachgeladen. Kein "noopener", weil sonst keine Referenz zum
// nachträglichen Setzen von location.href zurückkäme; unkritisch, da das
// Ziel eine selbst erzeugte same-origin blob:-URL ist, keine fremde Seite.
export async function ladeDokumentAnsehen(dokumentId: string): Promise<void> {
async function ladeUndOeffneInNeuemTab(pfad: string): Promise<void> {
const tab = window.open("", "_blank");
try {
const headers: Record<string, string> = {};
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
const response = await fetch(`${BASE_URL}/dokumente/${dokumentId}/download`, { headers });
const response = await fetch(`${BASE_URL}${pfad}`, { headers });
if (!response.ok) {
throw new ApiError(response.status, null);
}
@@ -144,6 +169,14 @@ export async function ladeDokumentAnsehen(dokumentId: string): Promise<void> {
}
}
export function ladeDokumentAnsehen(dokumentId: string): Promise<void> {
return ladeUndOeffneInNeuemTab(`/dokumente/${dokumentId}/download`);
}
export function ladeObjektEtikett(objektId: number, format: "code128" | "qr"): Promise<void> {
return ladeUndOeffneInNeuemTab(`/objekte/${objektId}/label.pdf?format=${format}`);
}
export async function login(username: string, password: string): Promise<string> {
const body = new URLSearchParams({ username, password });
const response = await fetch(`${BASE_URL}/auth/login`, {
+1
View File
@@ -144,6 +144,7 @@ export interface Dokument {
beschreibung: string | null;
hochgeladen_von: number;
hochgeladen_am: string;
vorgaenger_id: string | null;
}
export interface Lagerbewegung {
+136 -37
View File
@@ -1,6 +1,13 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ApiError, apiRequest, ladeDokumentAnsehen, ladeDokumentHerunter, ladeDokumentHoch } from "../api/client";
import {
ApiError,
apiRequest,
ladeDokumentAnsehen,
ladeDokumentErsetzen,
ladeDokumentHerunter,
ladeDokumentHoch,
} from "../api/client";
import { useAuth } from "../auth/AuthContext";
import type { Dokument, DokumentTyp } from "../api/types";
@@ -27,6 +34,10 @@ function formatGroesse(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatZeitpunkt(iso: string): string {
return new Date(iso).toLocaleString("de-DE");
}
// Dokumente-Modul (Roadmap Phase 5): wiederverwendbares Panel für beliebige
// Ressourcen (Objekt, Mangel, Fahrzeugdetails, ...) - gleiche entitaet_typ/
// entitaet_id-Adressierung wie das Backend.
@@ -40,6 +51,8 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
const [dokumenttyp, setDokumenttyp] = useState<DokumentTyp>("sonstiges");
const [filterTyp, setFilterTyp] = useState<DokumentTyp | "">("");
const [wirdHochgeladen, setWirdHochgeladen] = useState(false);
const [versionenOffen, setVersionenOffen] = useState<Record<string, Dokument[]>>({});
const ersetzenInputs = useRef(new Map<string, HTMLInputElement>());
async function laden() {
setLaedt(true);
@@ -82,17 +95,52 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
}
}
async function ansehen(d: Dokument) {
async function ersetzen(dokumentId: string, e: { target: HTMLInputElement }) {
const datei = e.target.files?.[0];
if (!datei) return;
try {
await ladeDokumentAnsehen(d.id);
await ladeDokumentErsetzen({ dokumentId, datei });
e.target.value = "";
delete versionenOffen[dokumentId];
await laden();
} catch (fehler) {
if (fehler instanceof ApiError && fehler.status === 409) {
const detail = (fehler.detail as { detail?: string } | null)?.detail;
onFehler(detail ?? "Diese Datei wurde hier bereits hochgeladen.");
} else {
onFehler("Neue Version konnte nicht angelegt werden.");
}
}
}
async function versionenUmschalten(d: Dokument) {
if (versionenOffen[d.id]) {
setVersionenOffen((bisherig) => {
const neu = { ...bisherig };
delete neu[d.id];
return neu;
});
return;
}
try {
const historie = await apiRequest<Dokument[]>(`/dokumente/${d.id}/versionen`);
setVersionenOffen((bisherig) => ({ ...bisherig, [d.id]: historie.slice(1) }));
} catch {
onFehler("Versionshistorie konnte nicht geladen werden.");
}
}
async function ansehen(dokumentId: string) {
try {
await ladeDokumentAnsehen(dokumentId);
} catch {
onFehler("Datei konnte nicht geöffnet werden.");
}
}
async function herunterladen(d: Dokument) {
async function herunterladen(dokumentId: string, dateiname: string) {
try {
await ladeDokumentHerunter(d.id, d.dateiname);
await ladeDokumentHerunter(dokumentId, dateiname);
} catch {
onFehler("Download fehlgeschlagen.");
}
@@ -151,38 +199,89 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
{!laedt && dokumente.length > 0 && (
<ul className="card-list">
{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"
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>
<span className="row" style={{ gap: "0.4rem" }}>
<button className="btn btn-secondary" onClick={() => herunterladen(d)}>
Herunterladen
</button>
{darfLoeschen && (
<button className="btn btn-secondary" onClick={() => loeschen(d)}>
Löschen
<li key={d.id} style={{ padding: "0.3rem 0" }}>
<div className="row-between">
<span>
<span className="badge badge-neutral" style={{ marginRight: "0.4rem" }}>
{DOKUMENTTYP_LABEL[d.dokumenttyp]}
</span>
<button
onClick={() => ansehen(d.id)}
title="In neuem Tab ansehen"
style={{
background: "none",
border: "none",
padding: 0,
color: "var(--color-primary)",
textDecoration: "underline",
cursor: "pointer",
}}
>
{d.dateiname}
</button>
)}
</span>
{d.beschreibung && <span className="text-muted"> {d.beschreibung}</span>}
<span className="text-muted"> ({formatGroesse(d.groesse_bytes)})</span>
</span>
<span className="row" style={{ gap: "0.4rem" }}>
<button className="btn btn-secondary" onClick={() => herunterladen(d.id, d.dateiname)}>
Herunterladen
</button>
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png,.webp"
style={{ display: "none" }}
ref={(el) => {
if (el) ersetzenInputs.current.set(d.id, el);
else ersetzenInputs.current.delete(d.id);
}}
onChange={(e) => ersetzen(d.id, e)}
/>
<button
className="btn btn-secondary"
onClick={() => ersetzenInputs.current.get(d.id)?.click()}
title="Neue Version dieses Dokuments hochladen - alte Fassung bleibt in der Historie"
>
Ersetzen
</button>
{d.vorgaenger_id !== null || versionenOffen[d.id] ? (
<button className="btn btn-secondary" onClick={() => versionenUmschalten(d)}>
{versionenOffen[d.id] ? "Versionen ausblenden" : "Versionen"}
</button>
) : null}
{darfLoeschen && (
<button className="btn btn-secondary" onClick={() => loeschen(d)}>
Löschen
</button>
)}
</span>
</div>
{versionenOffen[d.id] && versionenOffen[d.id].length > 0 && (
<ul className="card-list" style={{ marginLeft: "1.5rem", marginTop: "0.3rem" }}>
{versionenOffen[d.id].map((alt) => (
<li key={alt.id} className="row-between text-muted" style={{ fontSize: "0.85rem" }}>
<span>
<button
onClick={() => ansehen(alt.id)}
style={{
background: "none",
border: "none",
padding: 0,
color: "inherit",
textDecoration: "underline",
cursor: "pointer",
}}
>
{alt.dateiname}
</button>{" "}
· {formatZeitpunkt(alt.hochgeladen_am)}
</span>
<button className="btn btn-secondary" onClick={() => herunterladen(alt.id, alt.dateiname)}>
Herunterladen
</button>
</li>
))}
</ul>
)}
</li>
))}
</ul>
+16 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { apiRequest } from "../api/client";
import { apiRequest, ladeObjektEtikett } from "../api/client";
import { useAuth } from "../auth/AuthContext";
import type { Akte } from "../api/types";
import { DokumentePanel } from "../components/DokumentePanel";
@@ -41,6 +41,15 @@ export function AktePage() {
useEffect(laden, [objektId]);
async function etikettDrucken(format: "code128" | "qr") {
if (!objektId) return;
try {
await ladeObjektEtikett(Number(objektId), format);
} catch {
setFehler("Etikett konnte nicht erstellt werden.");
}
}
async function pruefungDurchgefuehrt(geraetId: string) {
try {
await apiRequest(`/geraete/${geraetId}`, {
@@ -90,6 +99,12 @@ export function AktePage() {
<Link to={`/maengel?objekt_id=${objekt.id}`} className="btn btn-secondary">
Mangel melden
</Link>
<button className="btn btn-secondary" onClick={() => etikettDrucken("code128")}>
Etikett (Barcode)
</button>
<button className="btn btn-secondary" onClick={() => etikettDrucken("qr")}>
Etikett (QR)
</button>
</div>
<div className="akte-stamm card">