Files
nexarch/web/retention-admin/app/classes/page.tsx
T
sysops 36d1cf5e7f RET-06: aufbewahrungsfristen-konfigurationsoberfläche
- web/retention-admin: eigenstaendige Next.js/React/TypeScript-App
  (kein Backend-Annex), auf web/shl (SHL-01) aufbauend
- classes: Aufbewahrungsklasse anlegen/aendern/deaktivieren (AC1)
- preview: Vorschauliste 30 Tage, nutzt denselben Endpunkt wie der
  periodische Job (AC2)
- lib/api.ts: ForbiddenError bei 403, getrennt behandelt, keine
  eigene Autorisierungslogik im Frontend (RBAC-Entscheidung liegt
  bei RET-08/RBAC-06)
- expliziter 403-Nachweis (nicht nur der 200-Fall): lib/api.test.ts,
  ClassesPage/PreviewPage zeigen 'Zugriff verweigert' statt leerer Seite
- lokal getestet: tsc clean, next build clean, vitest 3/3 gruen

Pruefungen siehe archive/docs/RET-06-PRUEFPROTOKOLL.md
2026-08-30 08:49:24 +02:00

137 lines
4.1 KiB
TypeScript

"use client";
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
import { Table, TextField, useToast, type TableColumn } from "@nexarch/shl";
import {
ApiError,
ClassRule,
ForbiddenError,
configureClassRule,
deactivateClassRule,
fetchClassRules,
} from "../../lib/api";
export default function ClassesPage() {
const { push } = useToast();
const [rules, setRules] = useState<ClassRule[] | null>(null);
const [forbidden, setForbidden] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [newClass, setNewClass] = useState("");
const [newDuration, setNewDuration] = useState("");
const [saving, setSaving] = useState(false);
function load() {
fetchClassRules()
.then((r) => {
setRules(r);
setForbidden(false);
})
.catch((err) => {
if (err instanceof ForbiddenError) {
setForbidden(true);
} else {
setLoadError(err instanceof Error ? err.message : "Aufbewahrungsklassen konnten nicht geladen werden.");
}
});
}
useEffect(load, []);
// Akzeptanzkriterium 3: nicht berechtigte Rolle sieht explizit den
// Zugriff-verweigert-Zustand, keine leere Tabelle ohne Erklaerung.
if (forbidden) {
return (
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
<p role="alert">Zugriff verweigert: Ihre Rolle ist nicht zur Fristenverwaltung berechtigt.</p>
</main>
);
}
if (loadError) {
return (
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
<p role="alert">{loadError}</p>
</main>
);
}
async function onCreateOrChange(e: FormEvent) {
e.preventDefault();
setSaving(true);
try {
// Akzeptanzkriterium 1: gleicher Endpunkt legt an ODER aendert
// (UPSERT im Backend, retentionengine.ConfigureClassRule).
await configureClassRule(newClass, newDuration);
push(`Aufbewahrungsklasse "${newClass}" gespeichert.`, "success");
setNewClass("");
setNewDuration("");
load();
} catch (err) {
if (err instanceof ForbiddenError) {
setForbidden(true);
} else {
push(err instanceof ApiError ? err.message : "Speichern fehlgeschlagen.", "danger");
}
} finally {
setSaving(false);
}
}
async function onDeactivate(retentionClass: string) {
try {
await deactivateClassRule(retentionClass);
push(`Aufbewahrungsklasse "${retentionClass}" deaktiviert.`, "success");
load();
} catch (err) {
if (err instanceof ForbiddenError) {
setForbidden(true);
} else {
push(err instanceof ApiError ? err.message : "Deaktivieren fehlgeschlagen.", "danger");
}
}
}
const columns: TableColumn<ClassRule>[] = [
{ key: "class", header: "Aufbewahrungsklasse", render: (r) => r.RetentionClass },
{ key: "duration", header: "Frist", render: (r) => r.Duration },
{ key: "active", header: "Status", render: (r) => (r.Active ? "aktiv" : "deaktiviert") },
{
key: "actions",
header: "Aktion",
render: (r) =>
r.Active ? (
<button type="button" onClick={() => onDeactivate(r.RetentionClass)}>
Deaktivieren
</button>
) : null,
},
];
return (
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
<h1>Aufbewahrungsfristen-Konfiguration</h1>
<form onSubmit={onCreateOrChange} style={{ marginBottom: 24 }}>
<TextField
label="Aufbewahrungsklasse"
value={newClass}
onChange={(e: ChangeEvent<HTMLInputElement>) => setNewClass(e.target.value)}
required
/>
<TextField
label="Frist (z. B. 5 years)"
value={newDuration}
onChange={(e: ChangeEvent<HTMLInputElement>) => setNewDuration(e.target.value)}
required
/>
<button type="submit" disabled={saving}>
Speichern
</button>
</form>
{rules && (
<Table columns={columns} rows={rules} rowKey={(r) => r.RetentionClass} caption="Konfigurierte Aufbewahrungsklassen" />
)}
</main>
);
}