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
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
|
||||
|
||||
export const metadata = {
|
||||
title: "NEXARCH Aufbewahrungsfristen",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body
|
||||
style={{
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: 0,
|
||||
background: "var(--shl-color-background, #ffffff)",
|
||||
color: "var(--shl-color-text-primary, #14181f)",
|
||||
}}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<I18nProvider initialLocale="de">
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function IndexPage() {
|
||||
redirect("/classes");
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Table, type TableColumn } from "@nexarch/shl";
|
||||
import { ExpiringObject, ForbiddenError, fetchExpiringPreview } from "../../lib/api";
|
||||
|
||||
// Akzeptanzkriterium 2: Vorschauliste bald ablaufender Objekte (naechste 30
|
||||
// Tage) - ruft dieselbe RET-06-API /retention-classes/preview auf, die
|
||||
// intern dieselbe Funktion wie der periodische Job nutzt (RET-02), keine
|
||||
// eigene Berechnung im Frontend.
|
||||
export default function PreviewPage() {
|
||||
const [objects, setObjects] = useState<ExpiringObject[] | null>(null);
|
||||
const [forbidden, setForbidden] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchExpiringPreview()
|
||||
.then(setObjects)
|
||||
.catch((err) => {
|
||||
if (err instanceof ForbiddenError) {
|
||||
setForbidden(true);
|
||||
} else {
|
||||
setLoadError(err instanceof Error ? err.message : "Vorschauliste konnte nicht geladen werden.");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: TableColumn<ExpiringObject>[] = [
|
||||
{ key: "type", header: "Objekttyp", render: (o) => o.ObjectType },
|
||||
{ key: "ref", header: "Referenz", render: (o) => o.ObjectReference },
|
||||
{ key: "class", header: "Aufbewahrungsklasse", render: (o) => o.RetentionClass },
|
||||
{ key: "due", header: "Fällig am", render: (o) => o.DueDate },
|
||||
];
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
|
||||
<h1>In den nächsten 30 Tagen ablaufende Objekte</h1>
|
||||
{objects && (
|
||||
<Table columns={columns} rows={objects} rowKey={(o) => o.RetentionObjectID} caption="Bald ablaufende Objekte" />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user