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:
1 parent
30cddd6e85
commit
0b0ebc1da1
13 files changed
+511
No files matched your search
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@nexarch/shl"],
|
||||
};
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,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"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(cleanup);
|
||||
Reference in new issue
Block a user