feat(dokumente,identity): DOC-003 Versionierung + QR-Code-Etiketten
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:
@@ -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`, {
|
||||
|
||||
@@ -144,6 +144,7 @@ export interface Dokument {
|
||||
beschreibung: string | null;
|
||||
hochgeladen_von: number;
|
||||
hochgeladen_am: string;
|
||||
vorgaenger_id: string | null;
|
||||
}
|
||||
|
||||
export interface Lagerbewegung {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user