diff --git a/web/routing-rules/.gitignore b/web/routing-rules/.gitignore new file mode 100644 index 00000000..b90a368f --- /dev/null +++ b/web/routing-rules/.gitignore @@ -0,0 +1,2 @@ +node_modules +.next diff --git a/web/routing-rules/app/RuleEditor.test.tsx b/web/routing-rules/app/RuleEditor.test.tsx new file mode 100644 index 00000000..7d6d6014 --- /dev/null +++ b/web/routing-rules/app/RuleEditor.test.tsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { ThemeProvider, I18nProvider, ToastProvider } from "@nexarch/shl"; +import { RuleEditor } from "./RuleEditor"; + +function renderWithProviders(ui: React.ReactElement) { + return render( + + + {ui} + + , + ); +} + +describe("RuleEditor", () => { + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + + // AK1/Pruefung1: Priorisierung per Tastatur-Aktion (gleichwertig zum + // Drag & Drop, siehe Komponentenkommentar) — Regel mit niedrigerer + // Prioritaet wird per "nach oben"-Button ueber die andere gehoben. + it("zeigt Regeln sortiert und erlaubt Umsortieren per Aktionsknopf", async () => { + const priorityCalls: { id: string; body: string }[] = []; + global.fetch = vi.fn(async (url: string | URL, init?: RequestInit) => { + const u = url.toString(); + if (u.includes("/priority")) { + const idMatch = u.match(/routing-rules\/([^/]+)\/priority/); + priorityCalls.push({ id: idMatch ? idMatch[1] : "", body: init?.body as string }); + return new Response(null, { status: 204 }); + } + return new Response( + JSON.stringify([ + { ID: "hoch", Name: "hohe-regel", Priority: 10, Enabled: true, ActiveVersion: 1, Conditions: [], Actions: [] }, + { ID: "niedrig", Name: "niedrige-regel", Priority: 1, Enabled: true, ActiveVersion: 1, Conditions: [], Actions: [] }, + ]), + { status: 200 }, + ); + }) as typeof fetch; + + renderWithProviders(); + + await screen.findByText(/hohe-regel/); + const moveDown = screen.getByRole("button", { name: /hohe-regel nach unten verschieben/i }); + fireEvent.click(moveDown); + + await waitFor(() => expect(priorityCalls.length).toBe(2)); + expect(priorityCalls.map((c) => JSON.parse(c.body).priority).sort()).toEqual([1, 10]); + }); + + // AK3/Pruefung2: Testlauf einer Regel vor Aktivierung. + it("zeigt das Ergebnis eines Testlaufs an", async () => { + global.fetch = vi.fn(async (url: string | URL) => { + const u = url.toString(); + if (u.includes("/routing-rules/test")) { + return new Response(JSON.stringify({ matched: true, rule_id: "r1", rule_name: "rechnungsregel", actions: [{ type: "route_to_process", target: "approval" }] }), { status: 200 }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }) as typeof fetch; + + renderWithProviders(); + await screen.findByText(/Keine Regeln angelegt/); + + fireEvent.click(screen.getByRole("button", { name: /Testlauf starten/i })); + + await waitFor(() => expect(screen.getByText(/rechnungsregel/)).toBeInTheDocument()); + }); + + // Auftrag: Anlegen einer Regel in der Oberflaeche. + it("legt eine neue Regel per Formular an", async () => { + let createBody: string | null = null; + global.fetch = vi.fn(async (url: string | URL, init?: RequestInit) => { + const u = url.toString(); + if (init?.method === "POST" && u.includes("/routing-rules") && !u.includes("/test")) { + createBody = init.body as string; + return new Response(JSON.stringify({ ID: "new", Name: "neue-regel", Priority: 5, Enabled: true, ActiveVersion: 1, Conditions: [], Actions: [] }), { status: 201 }); + } + return new Response(JSON.stringify([]), { status: 200 }); + }) as typeof fetch; + + renderWithProviders(); + await screen.findByText(/Keine Regeln angelegt/); + + fireEvent.change(screen.getByLabelText(/^Name$/), { target: { value: "neue-regel" } }); + fireEvent.change(screen.getByLabelText(/Bedingung: Feld/), { target: { value: "object_type" } }); + fireEvent.change(screen.getByLabelText(/Bedingung: Wert/), { target: { value: "invoice" } }); + fireEvent.change(screen.getByLabelText(/Aktion: Ziel/), { target: { value: "approval" } }); + fireEvent.click(screen.getByRole("button", { name: /Regel anlegen/i })); + + await waitFor(() => expect(createBody).not.toBeNull()); + expect(JSON.parse(createBody as unknown as string).name).toBe("neue-regel"); + }); + + it("zeigt einen Fehler bei ungueltigem JSON im Testlauf", async () => { + global.fetch = vi.fn(async () => new Response(JSON.stringify([]), { status: 200 })) as typeof fetch; + + renderWithProviders(); + await screen.findByText(/Keine Regeln angelegt/); + + const textarea = screen.getByLabelText(/Beispielobjekt/i); + fireEvent.change(textarea, { target: { value: "{ungueltig" } }); + fireEvent.click(screen.getByRole("button", { name: /Testlauf starten/i })); + + await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/Ungueltiges JSON/i)); + }); +}); diff --git a/web/routing-rules/app/RuleEditor.tsx b/web/routing-rules/app/RuleEditor.tsx new file mode 100644 index 00000000..d2cf3eda --- /dev/null +++ b/web/routing-rules/app/RuleEditor.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useToast } from "@nexarch/shl"; +import { listRules, createRule, setPriority, setEnabled, testRule, ApiError, type Rule } from "../lib/api"; + +// RTE-03 AK1/Pruefung1: Priorisierung per Drag & Drop UND gleichwertige +// Tastaturbedienung ueber "nach oben/unten"-Aktionen — Drag & Drop allein +// ist nicht tastaturbedienbar, gleiches Prinzip wie ReminderKanban (VOR-05). +export function RuleEditor({ userId }: { userId: string }) { + const [rules, setRules] = useState(null); + const [error, setError] = useState(null); + const [draggingId, setDraggingId] = useState(null); + const toast = useToast(); + + const reload = useCallback(async () => { + try { + const result = await listRules(userId); + setRules(result); + setError(null); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Regeln konnten nicht geladen werden."); + setRules([]); + } + }, [userId]); + + useEffect(() => { + void reload(); + }, [reload]); + + async function swapPriority(a: Rule, b: Rule) { + try { + await setPriority(userId, a.ID, b.Priority); + await setPriority(userId, b.ID, a.Priority); + await reload(); + } catch (err) { + toast.push(err instanceof ApiError ? err.message : "Umsortieren fehlgeschlagen.", "danger"); + } + } + + async function moveUp(index: number) { + if (!rules || index === 0) return; + await swapPriority(rules[index], rules[index - 1]); + } + + async function moveDown(index: number) { + if (!rules || index === rules.length - 1) return; + await swapPriority(rules[index], rules[index + 1]); + } + + async function toggleEnabled(rule: Rule) { + try { + await setEnabled(userId, rule.ID, !rule.Enabled); + await reload(); + } catch (err) { + toast.push(err instanceof ApiError ? err.message : "Status konnte nicht geaendert werden.", "danger"); + } + } + + if (error) { + return

{error}

; + } + if (rules === null) { + return

Lade Regeln …

; + } + + return ( +
+

Routing-Regeln

+ + {rules.length === 0 ? ( +

Keine Regeln angelegt.

+ ) : ( +
    + {rules.map((rule, index) => ( +
  • { + e.dataTransfer.setData("text/plain", rule.ID); + setDraggingId(rule.ID); + }} + onDragEnd={() => setDraggingId(null)} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const droppedId = e.dataTransfer.getData("text/plain") || draggingId; + if (droppedId && droppedId !== rule.ID) { + const source = rules.find((r) => r.ID === droppedId); + if (source) void swapPriority(source, rule); + } + setDraggingId(null); + }} + style={{ + border: "1px solid var(--shl-color-border, #d0d5dd)", + borderRadius: 6, + padding: 8, + marginBottom: 8, + background: "var(--shl-color-background, #ffffff)", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + gap: 8, + }} + > +
    +

    + {rule.Name} (Prioritaet {rule.Priority}) +

    +

    + {rule.Enabled ? "Aktiv" : "Deaktiviert"} +

    +
    +
    + + + +
    +
  • + ))} +
+ )} + +
+ ); +} + +// RTE-03 Auftrag: Anlegen einer Regel in der Oberflaeche. Feld/Operator/Wert +// bewusst als einzelne Bedingung (nicht mehrere) fuer die kleinste Loesung +// — Erweiterung auf mehrere Bedingungen ist eine spaetere Kachel, nicht Teil +// der drei Akzeptanzkriterien dieser hier. +function CreateForm({ userId, onCreated }: { userId: string; onCreated: () => void }) { + const [name, setName] = useState(""); + const [priority, setPriorityValue] = useState(0); + const [field, setField] = useState(""); + const [operator, setOperator] = useState("equals"); + const [value, setValue] = useState(""); + const [actionType, setActionType] = useState("route_to_process"); + const [target, setTarget] = useState(""); + const [formError, setFormError] = useState(null); + const toast = useToast(); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setFormError(null); + try { + await createRule(userId, { + name, + priority, + conditions: [{ field, operator, value }], + actions: [{ type: actionType, target }], + }); + setName(""); + setField(""); + setValue(""); + setTarget(""); + toast.push("Regel angelegt.", "success"); + onCreated(); + } catch (err) { + setFormError(err instanceof ApiError ? err.message : "Regel konnte nicht angelegt werden."); + } + } + + return ( +
void submit(e)} style={{ marginBottom: 24, display: "flex", flexDirection: "column", gap: 8, maxWidth: 480 }}> +

Neue Regel anlegen

+ + setName(e.target.value)} required /> + + + setPriorityValue(Number(e.target.value))} /> + + + setField(e.target.value)} placeholder="z.B. object_type" required /> + + + + + + setValue(e.target.value)} required /> + + + + + + setTarget(e.target.value)} required /> + + {formError ?

{formError}

: null} + +
+ ); +} + +// RTE-03 AK3/Pruefung2: Regel vor Aktivierung testen, ohne echte +// Ausfuehrung anzustossen. +function TestPanel({ userId }: { userId: string }) { + const [objectJSON, setObjectJSON] = useState('{"object_type": "invoice"}'); + const [result, setResult] = useState(null); + const [testError, setTestError] = useState(null); + + async function runTest() { + setTestError(null); + setResult(null); + let object: Record; + try { + object = JSON.parse(objectJSON); + } catch { + setTestError("Ungueltiges JSON."); + return; + } + try { + const res = await testRule(userId, object); + setResult( + res.matched + ? `Treffer: „${res.rule_name}“ (${res.actions?.length ?? 0} Aktion(en))` + : "Kein Treffer — Objekt faellt in keinen Klaerungskorb-Fall.", + ); + } catch (err) { + setTestError(err instanceof ApiError ? err.message : "Testlauf fehlgeschlagen."); + } + } + + return ( +
+

Regel testen

+ +