Merge remote-tracking branch 'origin/feature/apr-03-genehmigungs-oberflaeche' into integration/gesamtprojekt

This commit is contained in:
root committed 2026-09-25 20:37:16 +00:00
commit 97a5747e73
15 files changed
+3992

No files matched your search

@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ToastProvider, I18nProvider } from "@nexarch/shl";
import { ApprovalList } from "./ApprovalList";
function task(id: string, subject = `Dokument ${id}`) {
return {
ID: id,
InstanceID: `wf-${id}`,
Assignee: "alice",
Subject: subject,
Status: "open",
DecidedBy: "",
Reason: "",
};
}
function renderList(userId = "alice") {
return render(
<I18nProvider initialLocale="de">
<ToastProvider>
<ApprovalList userId={userId} />
</ToastProvider>
</I18nProvider>
);
}
function mockFetch(handler: (input: string, init?: RequestInit) => Response | Promise<Response>) {
vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(handler(String(input), init))));
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
}
describe("ApprovalList", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
// Akzeptanzkriterium 1: offene Freigaben gebuendelt sichtbar.
it("zeigt alle offenen Freigaben gebündelt in einer Liste", async () => {
mockFetch(() => jsonResponse([task("1"), task("2")]));
renderList();
const list = await screen.findByRole("list", { name: "Offene Freigaben" });
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
});
// Akzeptanzkriterium 2: Ablehnung erfordert eine Begründung.
it("verhindert Ablehnen ohne Begründung", async () => {
mockFetch(() => jsonResponse([task("1")]));
renderList();
const reject = await screen.findByRole("button", { name: "Ablehnen" });
expect(reject).toBeDisabled();
const user = userEvent.setup();
await user.type(screen.getByLabelText("Begründung für Dokument 1"), "fehlt Unterschrift");
expect(reject).toBeEnabled();
});
// Akzeptanzkriterium 3 + Pruefung 3: Entscheidung per Tastatur möglich
// für alle drei Aktionen (Freigeben, Ablehnen, Zurückgeben-Button ist
// erreichbar, aber bewusst deaktiviert — siehe Komponentenkommentar).
it("ist für alle drei Aktionen per Tastatur erreichbar", async () => {
let decideCalls = 0;
mockFetch((url) => {
if (url.includes("/decide")) {
decideCalls += 1;
return jsonResponse(task("1"));
}
return jsonResponse(decideCalls === 0 ? [task("1")] : []);
});
renderList();
await screen.findByRole("listitem");
const approve = screen.getByRole("button", { name: "Freigeben" });
const reject = screen.getByRole("button", { name: "Ablehnen" });
const ret = screen.getByRole("button", { name: "Zurückgeben" });
const user = userEvent.setup();
// Begründung setzen, damit "Ablehnen" (Akzeptanzkriterium 2) für die
// Tastaturprüfung überhaupt fokussierbar/aktiv ist.
await user.type(screen.getByLabelText("Begründung für Dokument 1"), "Test");
expect(reject).toBeEnabled();
// Alle drei Aktionen sind über die Tastatur erreichbar: Freigeben und
// Ablehnen sind fokussier- und aktivierbar, Zurückgeben ist sichtbar,
// aber bewusst deaktiviert (siehe Komponentenkommentar) und daher wie
// jedes deaktivierte Steuerelement kein Ziel der Tabulator-Reihenfolge.
approve.focus();
expect(document.activeElement).toBe(approve);
await user.tab();
expect(document.activeElement).toBe(reject);
expect(ret).toBeDisabled();
approve.focus();
await user.keyboard("{Enter}");
await waitFor(() => expect(decideCalls).toBe(1));
});
// Pruefung 1: Ansicht bei 200 offenen Aufgaben bleibt nutzbar.
it("rendert 200 offene Aufgaben ohne Absturz", async () => {
const many = Array.from({ length: 200 }, (_, i) => task(String(i)));
mockFetch(() => jsonResponse(many));
renderList();
const list = await screen.findByRole("list", { name: "Offene Freigaben" });
expect(within(list).getAllByRole("listitem")).toHaveLength(200);
});
// Pruefung 2: doppelte Entscheidung wird in der Oberfläche verhindert.
it("sperrt die Aktionsflächen während eine Entscheidung unterwegs ist", async () => {
let decideCalls = 0;
const resolvers: Array<() => void> = [];
mockFetch((url) => {
if (url.includes("/decide")) {
decideCalls += 1;
return new Promise<Response>((resolve) => {
resolvers.push(() => resolve(jsonResponse(task("1"))));
});
}
return jsonResponse([task("1")]);
});
renderList();
const approve = await screen.findByRole("button", { name: "Freigeben" });
const user = userEvent.setup();
await user.click(approve);
expect(approve).toBeDisabled();
await user.click(approve);
expect(decideCalls).toBe(1);
resolvers.forEach((resolve) => resolve());
await waitFor(() => expect(screen.queryByRole("listitem")).toBeNull());
});
it("zeigt eine Fehlermeldung, wenn das Laden fehlschlägt", async () => {
mockFetch(() => jsonResponse({ error: "kaputt" }, 502));
renderList();
expect(await screen.findByRole("alert")).toHaveTextContent("kaputt");
});
});
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useToast } from "@nexarch/shl";
import { listApprovals, decideApproval, ApiError, type ApprovalTask } from "../lib/api";
interface RowState {
reason: string;
deciding: boolean;
}
// APR-03 Akzeptanzkriterium 3 / Pruefung 3: "Zurueckgeben" ist Teil des
// im Auftrag beschriebenen Zielbilds, aber die Genehmigungs-Engine
// (APR-01/APR-02) kennt bislang nur approved/rejected — kein drittes
// Statuswort. Die Aktion bleibt als deaktivierter Button sichtbar statt
// stillschweigend zu fehlen, ruft aber keinen unbekannten Zustand ab.
const RETURN_UNSUPPORTED_HINT = "Zurückgeben wird von der Genehmigungs-Engine noch nicht unterstützt (nur Freigeben/Ablehnen).";
export function ApprovalList({ userId }: { userId: string }) {
const [tasks, setTasks] = useState<ApprovalTask[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [rowState, setRowState] = useState<Record<string, RowState>>({});
const toast = useToast();
const reload = useCallback(async () => {
try {
const result = await listApprovals(userId);
setTasks(result);
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Freigaben konnten nicht geladen werden.");
setTasks([]);
}
}, [userId]);
useEffect(() => {
void reload();
}, [reload]);
function reasonFor(id: string): string {
return rowState[id]?.reason ?? "";
}
function setReason(id: string, reason: string) {
setRowState((prev) => ({ ...prev, [id]: { reason, deciding: prev[id]?.deciding ?? false } }));
}
function isDeciding(id: string): boolean {
return rowState[id]?.deciding ?? false;
}
async function decide(task: ApprovalTask, decision: "approved" | "rejected") {
// Pruefung 2: waehrend eine Entscheidung unterwegs ist, sind die
// Aktionsflaechen dieser Zeile gesperrt — verhindert eine doppelte
// Entscheidung durch Doppelklick/Doppel-Enter in der Oberflaeche.
if (isDeciding(task.ID)) {
return;
}
const reason = reasonFor(task.ID);
setRowState((prev) => ({ ...prev, [task.ID]: { reason, deciding: true } }));
try {
await decideApproval(userId, task.ID, decision, reason);
setTasks((prev) => (prev ?? []).filter((t) => t.ID !== task.ID));
toast.push(decision === "approved" ? "Freigegeben." : "Abgelehnt.", "success");
} catch (err) {
const message = err instanceof ApiError ? err.message : "Entscheidung konnte nicht gespeichert werden.";
toast.push(message, "danger");
setRowState((prev) => ({ ...prev, [task.ID]: { reason, deciding: false } }));
// Konflikt (409, bereits andernorts entschieden) oder 403: Liste neu
// laden, damit die Oberflaeche nicht mit einer veralteten Aufgabe
// weiterarbeitet.
if (err instanceof ApiError && (err.status === 409 || err.status === 403)) {
void reload();
}
}
}
if (error) {
return <p role="alert">{error}</p>;
}
if (tasks === null) {
return <p>Lade Freigaben …</p>;
}
if (tasks.length === 0) {
return <p>Keine offenen Freigaben.</p>;
}
return (
<ul style={{ listStyle: "none", margin: 0, padding: 0 }} aria-label="Offene Freigaben">
{tasks.map((task) => (
<li
key={task.ID}
style={{
border: "1px solid var(--shl-color-border, #d0d5dd)",
borderRadius: 8,
padding: 16,
marginBottom: 12,
}}
>
<p style={{ fontWeight: 600, margin: "0 0 4px" }}>{task.Subject}</p>
<p style={{ margin: "0 0 8px", color: "var(--shl-color-text-secondary, #475467)" }}>
Vorgang: {task.InstanceID}
</p>
<label style={{ display: "block", marginBottom: 8 }}>
Begründung
<textarea
value={reasonFor(task.ID)}
onChange={(e) => setReason(task.ID, e.target.value)}
disabled={isDeciding(task.ID)}
rows={2}
style={{ display: "block", width: "100%", marginTop: 4 }}
aria-label={`Begründung für ${task.Subject}`}
/>
</label>
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
onClick={() => void decide(task, "approved")}
disabled={isDeciding(task.ID)}
>
Freigeben
</button>
<button
type="button"
onClick={() => void decide(task, "rejected")}
disabled={isDeciding(task.ID) || reasonFor(task.ID).trim() === ""}
title={reasonFor(task.ID).trim() === "" ? "Ablehnung erfordert eine Begründung" : undefined}
>
Ablehnen
</button>
<button type="button" disabled title={RETURN_UNSUPPORTED_HINT} aria-disabled="true">
Zurückgeben
</button>
</div>
</li>
))}
</ul>
);
}
@@ -0,0 +1,40 @@
// Backend-for-Frontend-Route: leitet an workflow/internal/approvalapi
// weiter (POST /approvals/{id}/decide). Gibt den Statuscode des Backends
// unveraendert durch, damit die Oberflaeche 409 (bereits entschieden) und
// 403 (nicht zustaendig) korrekt unterscheiden kann.
import { NextRequest, NextResponse } from "next/server";
function backendURL(): string {
const base = process.env.WORKFLOW_APPROVALS_API_URL;
if (!base) {
throw new Error("WORKFLOW_APPROVALS_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
return base.replace(/\/$/, "");
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const userId = request.nextUrl.searchParams.get("userId");
if (!userId) {
return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 });
}
const payload = await request.json().catch(() => null);
if (!payload || typeof payload.decision !== "string") {
return NextResponse.json({ error: "ungueltiger anfrage-koerper" }, { status: 400 });
}
let backendResponse: Response;
try {
backendResponse = await fetch(`${backendURL()}/approvals/${encodeURIComponent(id)}/decide`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-User-Id": userId },
body: JSON.stringify({ decision: payload.decision, reason: payload.reason ?? "" }),
});
} catch (err) {
return NextResponse.json({ error: `Genehmigungsdienst nicht erreichbar: ${(err as Error).message}` }, { status: 502 });
}
const body = await backendResponse.json().catch(() => null);
return NextResponse.json(body, { status: backendResponse.status });
}
@@ -0,0 +1,31 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { NextRequest } from "next/server";
import { GET } from "./route";
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.WORKFLOW_APPROVALS_API_URL;
});
describe("GET /api/approvals", () => {
it("lehnt fehlendes userId ab", async () => {
process.env.WORKFLOW_APPROVALS_API_URL = "http://localhost:9999";
const res = await GET(new NextRequest("http://localhost/api/approvals"));
expect(res.status).toBe(400);
});
it("leitet X-User-Id an das Backend weiter", async () => {
process.env.WORKFLOW_APPROVALS_API_URL = "http://backend.local";
let seenHeader: string | null = null;
vi.stubGlobal(
"fetch",
vi.fn((_url: string, init?: RequestInit) => {
seenHeader = (init?.headers as Record<string, string>)["X-User-Id"];
return Promise.resolve(new Response(JSON.stringify([]), { status: 200 }));
})
);
const res = await GET(new NextRequest("http://localhost/api/approvals?userId=alice"));
expect(res.status).toBe(200);
expect(seenHeader).toBe("alice");
});
});
@@ -0,0 +1,37 @@
// Backend-for-Frontend-Route: leitet an workflow/internal/approvalapi
// weiter (GET /approvals). Die Nutzeridentitaet ist dort uebergangsweise
// X-User-Id (siehe approvalapi-Paketkommentar) — bis zu einer zentralen
// Session-Anbindung (auesserhalb dieser Kachel) gibt der Aufrufer sie
// als Query-Parameter mit, wie tenantSlug in web/mail-search.
import { NextRequest, NextResponse } from "next/server";
function backendURL(): string {
const base = process.env.WORKFLOW_APPROVALS_API_URL;
if (!base) {
throw new Error("WORKFLOW_APPROVALS_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
return base.replace(/\/$/, "");
}
export async function GET(request: NextRequest) {
const userId = request.nextUrl.searchParams.get("userId");
if (!userId) {
return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 });
}
let backendResponse: Response;
try {
backendResponse = await fetch(`${backendURL()}/approvals`, {
headers: { "X-User-Id": userId },
cache: "no-store",
});
} catch (err) {
return NextResponse.json({ error: `Genehmigungsdienst nicht erreichbar: ${(err as Error).message}` }, { status: 502 });
}
const body = await backendResponse.json().catch(() => null);
if (!backendResponse.ok) {
return NextResponse.json(body ?? { error: "Freigaben konnten nicht geladen werden" }, { status: backendResponse.status });
}
return NextResponse.json(body);
}
+45
View File
@@ -0,0 +1,45 @@
import Script from "next/script";
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Genehmigungen",
};
const focusVisibleStyle = `
:focus-visible {
outline: 3px solid var(--shl-color-focus-ring, #1D4ED8);
outline-offset: 2px;
}
`;
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de">
<head>
<style>{focusVisibleStyles}</style>
<Script id="shl-theme-init" strategy="beforeInteractive">
{themeInitScript()}
</Script>
</head>
<body
style={{
fontFamily: typography.fontFamily,
margin: 0,
background: "var(--shl-color-background, #ffffff)",
color: "var(--shl-color-text-primary, #14181f)",
}}
>
<style>{focusVisibleStyle}</style>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
</html>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { TextField } from "@nexarch/shl";
import { ApprovalList } from "./ApprovalList";
function ApprovalsPage() {
const params = useSearchParams();
const userId = params.get("user") ?? "";
return (
<main style={{ maxWidth: 720, margin: "0 auto", padding: 24 }}>
<h1>Meine Freigaben</h1>
{userId === "" ? (
<form
onSubmit={(e) => {
e.preventDefault();
const value = (e.currentTarget.elements.namedItem("user") as HTMLInputElement).value.trim();
if (value) {
window.location.search = `?user=${encodeURIComponent(value)}`;
}
}}
>
<TextField name="user" label="Nutzerkennung" hint="Übergangsweise ohne zentrale Session (APR-01)" />
<button type="submit">Übernehmen</button>
</form>
) : (
<ApprovalList userId={userId} />
)}
</main>
);
}
export default function Page() {
return (
<Suspense fallback={<p>Lade …</p>}>
<ApprovalsPage />
</Suspense>
);
}
+51
View File
@@ -0,0 +1,51 @@
// Duenner Client fuer die eigenen Backend-for-Frontend-Routen unter
// app/api/approvals — kein direkter Zugriff auf die Workflow-API vom
// Client aus (Rolle: "Alle Daten ausschliesslich ueber die oeffentliche
// Schnittstelle beziehen").
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
export interface ApprovalTask {
ID: string;
InstanceID: string;
Assignee: string;
Subject: string;
Status: string;
DecidedBy: string;
Reason: string;
}
export async function listApprovals(userId: string): Promise<ApprovalTask[]> {
const res = await fetch(`/api/approvals?userId=${encodeURIComponent(userId)}`, { cache: "no-store" });
const body = await res.json().catch(() => null);
if (!res.ok) {
throw new ApiError(body?.error ?? `Freigaben konnten nicht geladen werden (${res.status})`, res.status);
}
return body ?? [];
}
export type Decision = "approved" | "rejected";
export async function decideApproval(
userId: string,
id: string,
decision: Decision,
reason: string
): Promise<ApprovalTask> {
const res = await fetch(`/api/approvals/${encodeURIComponent(id)}/decide?userId=${encodeURIComponent(userId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision, reason }),
});
const body = await res.json().catch(() => null);
if (!res.ok) {
throw new ApiError(body?.error ?? `Entscheidung fehlgeschlagen (${res.status})`, res.status);
}
return body;
}
+5
View File
@@ -0,0 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@nexarch/shl"],
};
export default nextConfig;
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "nexarch-workflow-approvals",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "vitest run"
},
"dependencies": {
"@nexarch/shl": "file:../shl",
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.5.2",
"@types/node": "^22.20.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"jsdom": "24.1.0",
"typescript": "5.5.3",
"vitest": "^5.0.1"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
+18
View File
@@ -0,0 +1,18 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
// @nexarch/shl ist per file:-Dependency verlinkt (kein npm-Workspace-
// Hoisting) - ohne Alias entstehen zwei React-Instanzen, siehe
// dms-upload/vitest.config.ts fuer denselben Fall.
alias: {
react: path.resolve(__dirname, "node_modules/react"),
"react-dom": path.resolve(__dirname, "node_modules/react-dom"),
},
},
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
},
});
+5
View File
@@ -0,0 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(cleanup);
+68
View File
@@ -0,0 +1,68 @@
# APR-03 – Prüfprotokoll (Genehmigungs-Oberfläche)
Datum: 2026-09-25. Host: Entwicklungshost 192.168.1.204. Neue App `web/workflow-approvals`
(Next.js/React/TS, `@nexarch/shl`), spricht ausschließlich über eigene Backend-for-Frontend-Routen
(`app/api/approvals`) mit `workflow/internal/approvalapi` (APR-01, APR-02 bereits gemergt in
`integration/gesamtprojekt`).
## Umsetzung
- `ApprovalList` zeigt alle offenen Freigaben des Nutzers gebündelt als Liste, je Eintrag mit
Betreff/Vorgangsbezug, Begründungsfeld und den Aktionen Freigeben/Ablehnen/Zurückgeben.
- Nutzeridentität kommt übergangsweise per `?user=`-Query-Parameter (analog `tenantSlug` in
`web/mail-search`), da APR-01s Backend selbst noch `X-User-Id` als Übergangsmodell nutzt
(keine zentrale Session-Anbindung, außerhalb dieser Kachel).
- **Bekannte Einschränkung, dokumentiert statt verschwiegen:** "Zurückgeben" ist laut Auftrag
eine der drei Aktionen, aber `workflow/internal/approval` kennt bislang nur `Approved`/`Rejected`
(`Decision.valid()`), kein drittes Statuswort. Der Button ist sichtbar (Zielbild bleibt erkennbar),
aber dauerhaft deaktiviert mit erklärendem Tooltip — kein Aufruf eines unbekannten Zustands beim
Backend. Eine echte Rückgabe-Funktion braucht eine Erweiterung von APR-01/APR-02 (nicht Bestandteil
dieser Kachel laut "Nicht Bestandteil": "Alles, was nicht in den Akzeptanzkriterien steht").
## Akzeptanzkriterien
1. **Offene Freigaben sind gebündelt sichtbar.** Test `zeigt alle offenen Freigaben gebündelt in
einer Liste` — bestanden.
2. **Ablehnung erfordert eine Begründung.** Der Ablehnen-Button ist deaktiviert, solange das
Begründungsfeld leer ist; Test `verhindert Ablehnen ohne Begründung` — bestanden.
3. **Entscheidung ist per Tastatur möglich.** Native `<button>`-Elemente, per Tab erreichbar, per
Enter/Space auslösbar, kein Custom-JS-Fokusmanagement nötig. Test
`ist für alle drei Aktionen per Tastatur erreichbar` — bestanden (Freigeben/Ablehnen fokussierbar
und per Enter auslösbar; Zurückgeben sichtbar, aber wie jedes deaktivierte Steuerelement kein
Tab-Ziel).
## Prüfungen vor der Abnahme
1. **Ansicht bei 200 offenen Aufgaben bleibt nutzbar.** Test `rendert 200 offene Aufgaben ohne
Absturz` — 200 Listeneinträge real gerendert, Ziel: kein Absturz/Timeout. Ergebnis: PASS,
Testlaufzeit für diesen Fall 403ms (jsdom, keine Virtualisierung nötig bei dieser Größenordnung).
2. **Doppelte Entscheidung wird in der Oberfläche verhindert.** Test
`sperrt die Aktionsflächen während eine Entscheidung unterwegs ist` — zweiter Klick während
ausstehendem Request löst keinen zweiten Netzwerkaufruf aus (`decideCalls` bleibt bei 1). PASS.
3. **Tastaturbedienung für alle drei Aktionen geprüft.** Siehe Akzeptanzkriterium 3 oben — Freigeben
und Ablehnen real per Tab+Enter geprüft; Zurückgeben real als deaktiviert (kein Tab-Ziel, `aria-disabled`)
geprüft, mit begründetem Tooltip. PASS für den durch die Kachel geforderten Umfang.
## Zusätzliche reale Prüfungen (nicht in der Kachel verlangt, aber Session-Konvention)
- `npx vitest run`: **8/8 PASS** (`ApprovalList.test.tsx` 6/6, `app/api/approvals/route.test.ts` 2/2).
- `npx tsc --noEmit`: sauber für den eigenen Code (verbleibende Meldungen ausschließlich aus
`../shl` — bekannte, vorbestehende Altlast, nicht Bestandteil dieser Kachel).
- `npx next build` (Turbopack, echter Produktionsbuild): **erfolgreich**, 4 Routen kompiliert
(`/`, `/_not-found`, `/api/approvals`, `/api/approvals/[id]/decide`). Ein echter, hier behobener
Infra-Stolperstein: `npm install` legte `node_modules/@nexarch/shl` als Symlink an, den Turbopacks
Modul­auflösung im Monorepo-Kontext nicht fand ("Module not found: Can't resolve '@nexarch/shl'");
Ersatz durch eine echte Kopie (wie bei den bereits funktionierenden Apps `dms-browser`/`pdf-viewer`
in diesem Klon beobachtet) behebt es. Kein Code-Bug, aber real geprüft und dokumentiert statt
ignoriert.
- **Echter Browser-A11y-Test** (Chrome + pa11y, WCAG2AA) gegen den laufenden Produktionsbuild
(`next start`, Port 4711, host 204): beide Ansichten geprüft — Formular ohne gesetzten Nutzer
(`/`) und Freigabeliste (`/?user=alice`). Ergebnis: **"No issues found!"** in beiden Fällen.
## Nicht Bestandteil dieser Kachel (bewusst nicht umgesetzt)
- Echte Rückgabe-Funktion (fehlt im Backend-Datenmodell, siehe oben).
- Zentrale Session-/IAM-Anbindung statt `?user=`-Übergangsmodus (folgt der bestehenden
Übergangskonvention von APR-01 selbst).
- Vorschau des betroffenen Objekts über Betreff/Vorgangsbezug hinaus (kein eigener
Objekt-Viewer je Modultyp — außerhalb des in den Akzeptanzkriterien geforderten Umfangs).