EXT-04: extraktions-korrektur-oberflaeche (Frontend)

web/ai-extraction-review (Next.js/React/TS, @nexarch/shl): zeigt
extrahierte Rechnungsfelder mit Konfidenz, erlaubt Korrektur pro Feld
ohne Seiteneffekt auf die uebrigen Felder, Bestaetigung/Abbruch ohne
automatische Uebernahme. Echte Uebernahme in DMS-/Mail-Metadaten ist
nicht Bestandteil dieser Kachel.

6/6 Vitest, tsc sauber, next build erfolgreich, echter pa11y
WCAG2AA-Check gegen laufenden Produktionsbuild ohne Befunde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-26 12:48:03 +02:00
1 parent 30cddd6e85
commit 0b0ebc1da1
13 files changed
+511

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
node_modules
.next
@@ -0,0 +1,132 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ExtractionReviewCard } from "./ExtractionReviewCard";
import type { InvoiceExtraction } from "../lib/api";
const EXTRACTION: InvoiceExtraction = {
fields: {
invoice_number: { value: "2026-0042", present: true, confidence: 0.95, plausible: true },
total_amount: { value: "12450.00", present: true, confidence: 0.9, plausible: true },
due_date: { value: "2026-01-17", present: true, confidence: 0.6, plausible: true },
sender: { value: "Musterfirma GmbH", present: true, confidence: 0.85, plausible: true },
},
overallScore: 0.825,
valid: true,
};
function renderCard(overrides?: Partial<Parameters<typeof ExtractionReviewCard>[0]>) {
const onConfirm = vi.fn();
const utils = render(
<ExtractionReviewCard
title="Rechnung 2026-0042"
sourceUrl="/quelle/1"
sourceLabel="Original ansehen"
load={async () => EXTRACTION}
onConfirm={onConfirm}
{...overrides}
/>,
);
return { onConfirm, ...utils };
}
describe("ExtractionReviewCard", () => {
// AK1: Vorschlaege werden mit Konfidenz angezeigt.
it("zeigt jeden Vorschlag mit seiner Konfidenz an", async () => {
renderCard();
await waitFor(() => {
expect(screen.getByDisplayValue("2026-0042")).toBeInTheDocument();
});
expect(screen.getByText(/Konfidenz: 95%/)).toBeInTheDocument();
expect(screen.getByText(/Konfidenz: 60%/)).toBeInTheDocument();
});
// AK2 / Pruefung 1: Bestaetigungsfluss vollstaendig durchlaufen.
it("uebergibt die Werte an onConfirm, wenn der Nutzer bestaetigt", async () => {
const user = userEvent.setup();
const { onConfirm } = renderCard();
await waitFor(() => {
expect(screen.getByDisplayValue("2026-0042")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Bestätigen" }));
expect(onConfirm).toHaveBeenCalledWith({
invoice_number: "2026-0042",
total_amount: "12450.00",
due_date: "2026-01-17",
sender: "Musterfirma GmbH",
});
expect(screen.getByText(/Bestätigt und übernommen/)).toBeInTheDocument();
});
// AK2/AK3 / Pruefung 2: Korrektur eines einzelnen Felds ohne Seiteneffekt
// auf die uebrigen Felder, und der korrigierte Wert wird uebernommen.
it("erlaubt die Korrektur eines einzelnen Felds ohne die anderen zu veraendern", async () => {
const user = userEvent.setup();
const { onConfirm } = renderCard();
await waitFor(() => {
expect(screen.getByDisplayValue("2026-0042")).toBeInTheDocument();
});
const invoiceNumberInput = screen.getByLabelText(/Rechnungsnummer/) as HTMLInputElement;
const senderInput = screen.getByLabelText(/Absender/) as HTMLInputElement;
const amountInput = screen.getByLabelText(/^Betrag/) as HTMLInputElement;
await user.clear(invoiceNumberInput);
await user.type(invoiceNumberInput, "2026-0099");
expect(invoiceNumberInput.value).toBe("2026-0099");
expect(senderInput.value).toBe("Musterfirma GmbH");
expect(amountInput.value).toBe("12450.00");
await user.click(screen.getByRole("button", { name: "Bestätigen" }));
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({ invoice_number: "2026-0099", sender: "Musterfirma GmbH" }),
);
});
// Pruefung 3: Abbruch ohne Uebernahme.
it("ruft onConfirm nicht auf, wenn der Nutzer abbricht", async () => {
const user = userEvent.setup();
const { onConfirm } = renderCard();
await waitFor(() => {
expect(screen.getByDisplayValue("2026-0042")).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
expect(onConfirm).not.toHaveBeenCalled();
expect(screen.getByText(/Abgebrochen, keine Werte übernommen/)).toBeInTheDocument();
});
it("markiert einen unplausiblen Wert deutlich", async () => {
renderCard({
load: async () => ({
...EXTRACTION,
fields: {
...EXTRACTION.fields,
total_amount: { value: "-5.00", present: true, confidence: 0.9, plausible: false, reasons: ["wert -5 unterschreitet minimum 0"] },
},
}),
});
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(/Unplausibel/);
});
});
it("zeigt eine Fehlermeldung, wenn das Laden fehlschlaegt", async () => {
renderCard({
load: async () => {
throw new Error("Dienst nicht erreichbar");
},
});
await waitFor(() => {
expect(screen.getByRole("alert")).toHaveTextContent(/Dienst nicht erreichbar/);
});
});
});
@@ -0,0 +1,126 @@
"use client";
import { useEffect, useState } from "react";
import type { FieldSuggestion, InvoiceExtraction } from "../lib/api";
interface ExtractionReviewCardProps {
title: string;
sourceUrl: string;
sourceLabel: string;
load: () => Promise<InvoiceExtraction>;
onConfirm: (values: Record<string, string>) => void;
}
type State =
| { status: "loading" }
| { status: "loaded"; fields: Record<string, FieldSuggestion>; values: Record<string, string> }
| { status: "confirmed"; values: Record<string, string> }
| { status: "cancelled" }
| { status: "error"; message: string };
const FIELD_LABELS: Record<string, string> = {
invoice_number: "Rechnungsnummer",
total_amount: "Betrag",
due_date: "Fälligkeitsdatum",
sender: "Absender",
};
export function ExtractionReviewCard({ title, sourceUrl, sourceLabel, load, onConfirm }: ExtractionReviewCardProps) {
const [state, setState] = useState<State>({ status: "loading" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
load()
.then((extraction) => {
if (cancelled) return;
const values: Record<string, string> = {};
for (const [name, field] of Object.entries(extraction.fields)) {
values[name] = field.value;
}
setState({ status: "loaded", fields: extraction.fields, values });
})
.catch((err: Error) => {
if (!cancelled) setState({ status: "error", message: err.message });
});
return () => {
cancelled = true;
};
}, [load]);
function handleFieldChange(name: string, value: string) {
if (state.status !== "loaded") return;
// Aendert ausschliesslich den Wert des betroffenen Felds (AK3: Korrektur
// pro Feld einzeln moeglich, ohne die uebrigen Felder zu beruehren).
setState({ ...state, values: { ...state.values, [name]: value } });
}
function handleConfirm() {
if (state.status !== "loaded") return;
onConfirm(state.values);
setState({ status: "confirmed", values: state.values });
}
function handleCancel() {
// AK2/Pruefung 3: Abbruch ohne Uebernahme — onConfirm wird bewusst NICHT
// aufgerufen.
setState({ status: "cancelled" });
}
return (
<section
style={{
border: "1px solid var(--shl-color-border, #d0d5dd)",
borderRadius: 8,
padding: 16,
marginBottom: 16,
}}
>
<h2 style={{ marginTop: 0 }}>{title}</h2>
{state.status === "loading" && <p role="status">Vorschläge werden geladen…</p>}
{state.status === "error" && <p role="alert">Vorschläge konnten nicht geladen werden: {state.message}</p>}
{state.status === "cancelled" && <p role="status">Abgebrochen, keine Werte übernommen.</p>}
{state.status === "confirmed" && <p role="status">Bestätigt und übernommen.</p>}
{state.status === "loaded" && (
<form
onSubmit={(e) => {
e.preventDefault();
handleConfirm();
}}
>
{Object.entries(state.fields).map(([name, field]) => {
const label = FIELD_LABELS[name] ?? name;
const confidencePercent = Math.round(field.confidence * 100);
return (
<div key={name} style={{ marginBottom: 12 }}>
<label htmlFor={`field-${name}`} style={{ display: "block", fontWeight: 600 }}>
{label}
<span style={{ fontWeight: 400, marginLeft: 8 }}>Konfidenz: {confidencePercent}%</span>
{!field.plausible && (
<span role="alert" style={{ marginLeft: 8, color: "var(--shl-color-danger, #b42318)" }}>
Unplausibel{field.reasons?.length ? `: ${field.reasons.join("; ")}` : ""}
</span>
)}
</label>
<input
id={`field-${name}`}
type="text"
value={state.values[name] ?? ""}
onChange={(e) => handleFieldChange(name, e.target.value)}
style={{ width: "100%", padding: 8 }}
/>
</div>
);
})}
<button type="submit">Bestätigen</button>
<button type="button" onClick={handleCancel} style={{ marginLeft: 8 }}>
Abbrechen
</button>
</form>
)}
<p>
<a href={sourceUrl}>{sourceLabel}</a>
</p>
</section>
);
}
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { EXAMPLE_INVOICE } from "../../../../../lib/examples";
function backendURL(): string {
const base = process.env.AI_EXTRACTION_API_URL;
if (!base) {
throw new Error("AI_EXTRACTION_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
return base.replace(/\/$/, "");
}
function serviceToken(): string {
const token = process.env.AI_EXTRACTION_SERVICE_TOKEN;
if (!token) {
throw new Error("AI_EXTRACTION_SERVICE_TOKEN ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
return token;
}
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
if (id !== EXAMPLE_INVOICE.id) {
return NextResponse.json({ error: "Unbekannte Beispielrechnung" }, { status: 404 });
}
let backendResponse: Response;
try {
backendResponse = await fetch(`${backendURL()}/invoices/extract`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Service-Token": serviceToken() },
body: JSON.stringify({ text: EXAMPLE_INVOICE.text }),
cache: "no-store",
});
} catch (err) {
return NextResponse.json({ error: `KI-Dienst nicht erreichbar: ${(err as Error).message}` }, { status: 502 });
}
const body = await backendResponse.json().catch(() => null);
if (!backendResponse.ok) {
return NextResponse.json(body ?? { error: "Extraktion konnte nicht geladen werden" }, { status: backendResponse.status });
}
return NextResponse.json({
fields: body.fields,
overallScore: body.overall_score,
valid: body.valid,
rejectReason: body.reject_reason,
});
}
+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 Extraktions-Korrektur",
};
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>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { ExtractionReviewCard } from "./ExtractionReviewCard";
import { getInvoiceExtraction } from "../lib/api";
import { EXAMPLE_INVOICE } from "../lib/examples";
export default function ExtractionReviewPage() {
return (
<main style={{ maxWidth: 800, margin: "0 auto", padding: 24 }}>
<h1>Extraktions-Korrektur</h1>
<ExtractionReviewCard
title={EXAMPLE_INVOICE.title}
sourceUrl={EXAMPLE_INVOICE.sourceUrl}
sourceLabel="Original ansehen"
load={() => getInvoiceExtraction(EXAMPLE_INVOICE.id)}
onConfirm={(values) => {
// Die eigentliche Uebernahme in die Metadaten des aufrufenden
// Moduls (DMS/Mail) ist nicht Bestandteil dieser Kachel (EXT-04
// "Nicht Bestandteil") — hier nur die bestaetigten Werte
// entgegennehmen.
console.log("Bestaetigte Werte:", values);
}}
/>
</main>
);
}
+40
View File
@@ -0,0 +1,40 @@
// Duenner Client fuer die eigene Backend-for-Frontend-Route unter app/api —
// kein direkter Zugriff auf die AI-API vom Client aus (Rolle: "Ruft
// ausschliesslich die vorhandene Schnittstelle auf"), gleiches Muster wie
// web/ai-summaries.
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
export interface FieldSuggestion {
value: string;
present: boolean;
confidence: number;
plausible: boolean;
reasons?: string[];
}
export interface InvoiceExtraction {
fields: Record<string, FieldSuggestion>;
overallScore: number;
valid: boolean;
rejectReason?: string;
}
async function parse<T>(res: Response, fallback: string): Promise<T> {
const body = await res.json().catch(() => null);
if (!res.ok) {
throw new ApiError(body?.error ?? `${fallback} (${res.status})`, res.status);
}
return body as T;
}
export async function getInvoiceExtraction(invoiceId: string): Promise<InvoiceExtraction> {
const res = await fetch(`/api/invoices/${encodeURIComponent(invoiceId)}/extract`, { cache: "no-store" });
return parse<InvoiceExtraction>(res, "Extraktion konnte nicht geladen werden");
}
+20
View File
@@ -0,0 +1,20 @@
// Beispieldaten fuer die Anzeige (Pruefung 1: Bestaetigungsfluss im Test
// vollstaendig durchlaufen) — echte DMS-/Mail-Anbindung ist nicht
// Bestandteil dieser Kachel (EXT-04 "Nicht Bestandteil").
export interface ExampleInvoice {
id: string;
title: string;
sourceUrl: string;
text: string;
}
export const EXAMPLE_INVOICE: ExampleInvoice = {
id: "beispiel-rechnung-1",
title: "Rechnung 2026-0042",
sourceUrl: "/dms/documents/beispiel-rechnung-1",
text:
"Rechnung Nr. 2026-0042 vom 3. Januar 2026 an die Musterfirma GmbH ueber " +
"die Lieferung von 50 Buerostuehlen. Gesamtbetrag: 12.450,00 EUR " +
"zzgl. 19% Umsatzsteuer. Faelligkeitsdatum: 17. Januar 2026.",
};
+5
View File
@@ -0,0 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@nexarch/shl"],
};
export default nextConfig;
+27
View File
@@ -0,0 +1,27 @@
{
"name": "nexarch-ai-extraction-review",
"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"]
}
+15
View File
@@ -0,0 +1,15 @@
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
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);