From d15476f5e99ada5100eb472250efe27f6d58eefa Mon Sep 17 00:00:00 2001 From: Frontend Engineer Date: Mon, 13 Apr 2026 19:46:38 +0000 Subject: [PATCH] feat: add Skills management settings UI and API routes (AIIA-97) - Skill types (src/types/skill.ts) with form data, slugify helper - Skills settings component with list view (drag-and-drop reorder), editor form (name, slug, prompt, output type, JSON schema, context requirements, active toggle), system skill protection - API routes: GET/POST /api/settings/skills, GET/PATCH/DELETE /api/settings/skills/[id], PATCH /api/settings/skills/reorder - Integrated into /einstellungen page (admin only) - API routes depend on `skills` table from AIIA-94 schema migration Co-Authored-By: Paperclip --- src/app/(dashboard)/einstellungen/page.tsx | 3 + .../einstellungen/skills-settings.tsx | 496 ++++++++++++++++++ src/app/api/settings/skills/[id]/route.ts | 142 +++++ src/app/api/settings/skills/reorder/route.ts | 32 ++ src/app/api/settings/skills/route.ts | 101 ++++ src/types/skill.ts | 57 ++ 6 files changed, 831 insertions(+) create mode 100644 src/app/(dashboard)/einstellungen/skills-settings.tsx create mode 100644 src/app/api/settings/skills/[id]/route.ts create mode 100644 src/app/api/settings/skills/reorder/route.ts create mode 100644 src/app/api/settings/skills/route.ts create mode 100644 src/types/skill.ts diff --git a/src/app/(dashboard)/einstellungen/page.tsx b/src/app/(dashboard)/einstellungen/page.tsx index 0e1bb48..ffc7b8d 100644 --- a/src/app/(dashboard)/einstellungen/page.tsx +++ b/src/app/(dashboard)/einstellungen/page.tsx @@ -6,6 +6,7 @@ import { tenants, users } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; import AISettingsForm from './ai-settings'; import ApiKeySettings from './api-key-settings'; +import SkillsSettings from './skills-settings'; const ROLE_LABELS: Record = { admin: 'Administrator', @@ -76,6 +77,8 @@ export default async function EinstellungenPage() { {isAdmin && } + {isAdmin && } + {isAdmin && tenantUsers.length > 0 && (

Benutzer

diff --git a/src/app/(dashboard)/einstellungen/skills-settings.tsx b/src/app/(dashboard)/einstellungen/skills-settings.tsx new file mode 100644 index 0000000..cd2c916 --- /dev/null +++ b/src/app/(dashboard)/einstellungen/skills-settings.tsx @@ -0,0 +1,496 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import type { Skill, SkillFormData } from '@/types/skill'; +import { emptySkillForm, slugify } from '@/types/skill'; + +const OUTPUT_TYPE_LABELS: Record = { + analysis: 'Analyse', + structured_data: 'Strukturierte Daten', +}; + +export default function SkillsSettings() { + const [skills, setSkills] = useState([]); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + // Editor state + const [editing, setEditing] = useState(null); // null = creating new + const [showEditor, setShowEditor] = useState(false); + const [form, setForm] = useState(emptySkillForm()); + const [saving, setSaving] = useState(false); + const [slugManual, setSlugManual] = useState(false); + + // Drag state + const [dragIdx, setDragIdx] = useState(null); + + const loadSkills = useCallback(async () => { + try { + const res = await fetch('/api/settings/skills'); + if (res.ok) { + const data = await res.json(); + setSkills(data); + } + } catch { + // ignore + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadSkills(); + }, [loadSkills]); + + function openCreate() { + setEditing(null); + setForm(emptySkillForm()); + setSlugManual(false); + setShowEditor(true); + setMessage(null); + } + + function openEdit(skill: Skill) { + setEditing(skill); + setForm({ + name: skill.name, + slug: skill.slug, + description: skill.description ?? '', + systemPrompt: skill.systemPrompt, + outputType: skill.outputType, + outputSchema: skill.outputSchema ? JSON.stringify(skill.outputSchema, null, 2) : '', + requiresNorms: skill.requiresNorms, + requiresDecisions: skill.requiresDecisions, + isActive: skill.isActive, + }); + setSlugManual(true); + setShowEditor(true); + setMessage(null); + } + + function closeEditor() { + setShowEditor(false); + setEditing(null); + setForm(emptySkillForm()); + setMessage(null); + } + + function handleNameChange(name: string) { + setForm((f) => ({ + ...f, + name, + ...(!slugManual && { slug: slugify(name) }), + })); + } + + function handleSlugChange(slug: string) { + setSlugManual(true); + setForm((f) => ({ ...f, slug })); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!form.name.trim() || !form.slug.trim() || !form.systemPrompt.trim()) { + setMessage({ type: 'error', text: 'Name, Slug und System-Prompt sind Pflichtfelder.' }); + return; + } + + // Validate JSON schema if structured data + let outputSchema: Record | null = null; + if (form.outputType === 'structured_data') { + if (!form.outputSchema.trim()) { + setMessage({ type: 'error', text: 'JSON Schema ist bei strukturierten Daten erforderlich.' }); + return; + } + try { + outputSchema = JSON.parse(form.outputSchema); + } catch { + setMessage({ type: 'error', text: 'Ungültiges JSON im Schema-Feld.' }); + return; + } + } + + setSaving(true); + setMessage(null); + + const payload = { + name: form.name.trim(), + slug: form.slug.trim(), + description: form.description.trim() || null, + systemPrompt: form.systemPrompt, + outputType: form.outputType, + outputSchema, + requiresNorms: form.requiresNorms, + requiresDecisions: form.requiresDecisions, + isActive: form.isActive, + }; + + try { + const url = editing + ? `/api/settings/skills/${editing.id}` + : '/api/settings/skills'; + const method = editing ? 'PATCH' : 'POST'; + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (res.ok) { + setMessage({ type: 'success', text: editing ? 'Skill aktualisiert.' : 'Skill erstellt.' }); + closeEditor(); + await loadSkills(); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error ?? 'Fehler beim Speichern.' }); + } + } catch { + setMessage({ type: 'error', text: 'Netzwerkfehler.' }); + } finally { + setSaving(false); + } + } + + async function handleDelete(skill: Skill) { + if (skill.isSystem) return; + if (!confirm(`Skill "${skill.name}" wirklich löschen?`)) return; + + try { + const res = await fetch(`/api/settings/skills/${skill.id}`, { method: 'DELETE' }); + if (res.ok) { + setMessage({ type: 'success', text: 'Skill deaktiviert.' }); + await loadSkills(); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error ?? 'Fehler beim Löschen.' }); + } + } catch { + setMessage({ type: 'error', text: 'Netzwerkfehler.' }); + } + } + + async function handleToggleActive(skill: Skill) { + try { + const res = await fetch(`/api/settings/skills/${skill.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive: !skill.isActive }), + }); + if (res.ok) await loadSkills(); + } catch { + // ignore + } + } + + function handleResetToDefault(skill: Skill) { + if (!skill.isSystem) return; + // Prefill with current values — the backend handles "reset" by restoring original system prompt + // For now, we just re-fetch. A future enhancement could add a dedicated reset endpoint. + setMessage({ type: 'error', text: 'Zurücksetzen auf Standard wird noch nicht unterstützt.' }); + } + + // Drag-and-drop reorder + function handleDragStart(idx: number) { + setDragIdx(idx); + } + + function handleDragOver(e: React.DragEvent, idx: number) { + e.preventDefault(); + if (dragIdx === null || dragIdx === idx) return; + + const reordered = [...skills]; + const [moved] = reordered.splice(dragIdx, 1); + reordered.splice(idx, 0, moved); + setSkills(reordered); + setDragIdx(idx); + } + + async function handleDragEnd() { + if (dragIdx === null) return; + setDragIdx(null); + + const order = skills.map((s, i) => ({ id: s.id, sortOrder: i })); + try { + await fetch('/api/settings/skills/reorder', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order }), + }); + } catch { + await loadSkills(); // revert on error + } + } + + if (loading) { + return ( +
+

Lade Skills...

+
+ ); + } + + return ( +
+
+

Skills (Analysemodi)

+ {!showEditor && ( + + )} +
+ + {/* Message */} + {message && !showEditor && ( +

+ {message.text} +

+ )} + + {/* Editor */} + {showEditor && ( +
+

+ {editing ? `Skill bearbeiten: ${editing.name}` : 'Neuen Skill erstellen'} +

+ +
+
+ + handleNameChange(e.target.value)} + placeholder="z.B. Rechtsgutachten" + className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50" + /> +
+
+ + handleSlugChange(e.target.value)} + placeholder="z.B. rechtsgutachten" + className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted/50" + /> +

Eindeutiger Bezeichner (Kleinbuchstaben, Bindestriche)

+
+
+ +
+ + setForm({ ...form, description: e.target.value })} + placeholder="Kurze Beschreibung des Skills" + className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50" + /> +
+ +
+ +