Merge remote-tracking branch 'origin/feature/apr-03-genehmigungs-oberflaeche' into integration/gesamtprojekt
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@nexarch/shl"],
|
||||
};
|
||||
export default nextConfig;
|
||||
Generated
+3319
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -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
|
||||
Modulauflö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).
|
||||
Reference in new issue
Block a user