From e521b8e33865f3ccc7d9157aad73bdfcca25297f Mon Sep 17 00:00:00 2001 From: Frontend Engineer Date: Mon, 13 Apr 2026 19:52:43 +0000 Subject: [PATCH] feat: replace hardcoded Analysemodus with dynamic skill selection (AIIA-98) - Add GET /api/skills read-only endpoint for fetching active tenant skills - Update analyse-form.tsx to fetch skills dynamically, show description as helper text, and render structured data results as table - Extract SkillCards client component for the skill info cards - Send skillId alongside mode slug for forward compatibility Depends on: AIIA-97 (skills schema/API) and backend analysis integration. Co-Authored-By: Paperclip --- src/app/(dashboard)/analyse/analyse-form.tsx | 140 +++++++++++++++---- src/app/(dashboard)/analyse/page.tsx | 47 +------ src/app/(dashboard)/analyse/skill-cards.tsx | 51 +++++++ src/app/api/skills/route.ts | 36 +++++ 4 files changed, 205 insertions(+), 69 deletions(-) create mode 100644 src/app/(dashboard)/analyse/skill-cards.tsx create mode 100644 src/app/api/skills/route.ts diff --git a/src/app/(dashboard)/analyse/analyse-form.tsx b/src/app/(dashboard)/analyse/analyse-form.tsx index 00fc332..55194b9 100644 --- a/src/app/(dashboard)/analyse/analyse-form.tsx +++ b/src/app/(dashboard)/analyse/analyse-form.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import SourceSelection from '@/components/documents/source-selection'; interface CaseOption { @@ -9,25 +9,89 @@ interface CaseOption { caseNumber: string; } -const MODES = [ - { key: 'gutachten', label: 'Gutachten' }, - { key: 'entscheidung', label: 'Entscheidungsprognose' }, - { key: 'vergleich', label: 'Vergleichsanalyse' }, - { key: 'risiko', label: 'Risikoanalyse' }, -] as const; +interface SkillOption { + id: string; + slug: string; + name: string; + description: string | null; + outputType: 'analysis' | 'structured_data'; + outputSchema: Record | null; +} + +/** Render a structured data result as a key-value table. */ +function StructuredDataResult({ data }: { data: Record }) { + const entries = Object.entries(data); + if (entries.length === 0) return

Keine Daten.

; + + return ( + + + + + + + + + {entries.map(([key, value]) => ( + + + + + ))} + +
FeldWert
{key} + {typeof value === 'object' && value !== null + ? JSON.stringify(value, null, 2) + : String(value ?? '—')} +
+ ); +} export default function AnalyseForm({ cases }: { cases: CaseOption[] }) { - const [mode, setMode] = useState('gutachten'); + const [skills, setSkills] = useState([]); + const [skillSlug, setSkillSlug] = useState(''); const [caseId, setCaseId] = useState(''); const [question, setQuestion] = useState(''); const [selectedDocumentIds, setSelectedDocumentIds] = useState([]); const [result, setResult] = useState(''); const [loading, setLoading] = useState(false); + const [skillsLoading, setSkillsLoading] = useState(true); const [error, setError] = useState(''); + const selectedSkill = useMemo( + () => skills.find((s) => s.slug === skillSlug), + [skills, skillSlug], + ); + + useEffect(() => { + fetch('/api/skills') + .then((res) => (res.ok ? res.json() : [])) + .then((data: SkillOption[]) => { + setSkills(data); + if (data.length > 0 && !skillSlug) { + setSkillSlug(data[0].slug); + } + }) + .catch(() => setSkills([])) + .finally(() => setSkillsLoading(false)); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + /** Try to parse a JSON structured-data response from streamed text. */ + function tryParseStructured(text: string): Record | null { + try { + const parsed = JSON.parse(text.trim()); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Not valid JSON (yet) — show as text + } + return null; + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - if (!question.trim()) return; + if (!question.trim() || !skillSlug) return; setError(''); setResult(''); @@ -38,8 +102,9 @@ export default function AnalyseForm({ cases }: { cases: CaseOption[] }) { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - mode, - title: `${MODES.find((m) => m.key === mode)?.label ?? mode} — ${question.trim().slice(0, 80)}`, + mode: skillSlug, + skillId: selectedSkill?.id, + title: `${selectedSkill?.name ?? skillSlug} — ${question.trim().slice(0, 80)}`, query: question.trim(), caseId: caseId || undefined, documentIds: selectedDocumentIds.length > 0 ? selectedDocumentIds : undefined, @@ -69,21 +134,37 @@ export default function AnalyseForm({ cases }: { cases: CaseOption[] }) { } } + const isStructuredSkill = selectedSkill?.outputType === 'structured_data'; + const structuredData = isStructuredSkill && result ? tryParseStructured(result) : null; + return (
- + {skillsLoading ? ( +
+ Lade Skills… +
+ ) : skills.length === 0 ? ( +
+ Keine Skills verfügbar +
+ ) : ( + + )} + {selectedSkill?.description && ( +

{selectedSkill.description}

+ )}
@@ -125,7 +206,7 @@ export default function AnalyseForm({ cases }: { cases: CaseOption[] }) {
)}
diff --git a/src/app/(dashboard)/analyse/page.tsx b/src/app/(dashboard)/analyse/page.tsx index b1b5823..31812bd 100644 --- a/src/app/(dashboard)/analyse/page.tsx +++ b/src/app/(dashboard)/analyse/page.tsx @@ -5,33 +5,7 @@ import { analyses, cases } from '@/lib/db/schema'; import { eq, desc } from 'drizzle-orm'; import Link from 'next/link'; import AnalyseForm from './analyse-form'; - -const MODE_INFO = [ - { - key: 'gutachten', - label: 'Gutachten', - description: 'Systematische Rechtsprüfung nach dem juristischen Gutachtenstil (Obersatz, Definition, Subsumtion, Ergebnis).', - icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', - }, - { - key: 'entscheidung', - label: 'Entscheidungsprognose', - description: 'Prognose der wahrscheinlichen Gerichts- oder Schiedsentscheidung mit Präzedenzfällen.', - icon: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3', - }, - { - key: 'vergleich', - label: 'Vergleichsanalyse', - description: 'Bewertung von Vergleichsoptionen: Erfolgsaussichten, Wirtschaftlichkeit, Risiko.', - icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z', - }, - { - key: 'risiko', - label: 'Risikoanalyse', - description: 'Risikomatrix mit Fristrisiken, Compliance-Risiken und priorisierter Handlungsempfehlung.', - icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z', - }, -]; +import SkillCards from './skill-cards'; export default async function AnalysePage() { const session = await getServerSession(authOptions); @@ -67,20 +41,7 @@ export default async function AnalysePage() {

-
- {MODE_INFO.map((mode) => ( -
- - - -

{mode.label}

-

{mode.description}

-
- ))} -
+ @@ -92,9 +53,7 @@ export default async function AnalysePage() {

{a.title || 'Ohne Titel'}

-

- {MODE_INFO.find((m) => m.key === a.mode)?.label ?? a.mode} -

+

{a.mode}

{new Date(a.createdAt).toLocaleDateString('de-DE')} diff --git a/src/app/(dashboard)/analyse/skill-cards.tsx b/src/app/(dashboard)/analyse/skill-cards.tsx new file mode 100644 index 0000000..7395a2f --- /dev/null +++ b/src/app/(dashboard)/analyse/skill-cards.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +interface SkillSummary { + slug: string; + name: string; + description: string | null; +} + +/** Map well-known system skill slugs to distinct SVG paths */ +const SKILL_ICONS: Record = { + gutachten: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', + entscheidung: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3', + vergleich: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z', + risiko: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z', +}; + +const DEFAULT_ICON = 'M13 10V3L4 14h7v7l9-11h-7z'; + +export default function SkillCards() { + const [skills, setSkills] = useState([]); + + useEffect(() => { + fetch('/api/skills') + .then((res) => (res.ok ? res.json() : [])) + .then((data: SkillSummary[]) => setSkills(data)) + .catch(() => setSkills([])); + }, []); + + if (skills.length === 0) return null; + + return ( +
+ {skills.map((skill) => ( +
+ + + +

{skill.name}

+ {skill.description && ( +

{skill.description}

+ )} +
+ ))} +
+ ); +} diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts new file mode 100644 index 0000000..bbb3ade --- /dev/null +++ b/src/app/api/skills/route.ts @@ -0,0 +1,36 @@ +// GET /api/skills — List active skills for the current tenant (read-only) +// Used by the analyse form to populate the skill selector. + +import { db } from '@/lib/db'; +import { skills } from '@/lib/db/schema'; +import { eq, and, asc } from 'drizzle-orm'; +import { requirePermission } from '@/lib/auth/rbac'; + +export async function GET() { + const auth = await requirePermission('analyses:create'); + if ('response' in auth) return auth.response; + + const rows = await db + .select({ + id: skills.id, + slug: skills.slug, + name: skills.name, + description: skills.description, + outputType: skills.outputType, + outputSchema: skills.outputSchema, + requiresNorms: skills.requiresNorms, + requiresDecisions: skills.requiresDecisions, + isSystem: skills.isSystem, + sortOrder: skills.sortOrder, + }) + .from(skills) + .where( + and( + eq(skills.tenantId, auth.ctx.tenantId), + eq(skills.isActive, true), + ), + ) + .orderBy(asc(skills.sortOrder), asc(skills.createdAt)); + + return Response.json(rows); +}