refactor: Consolidate standalone services into admin-v2, add new SDK modules
Remove standalone services (ai-compliance-sdk root, developer-portal, dsms-gateway, dsms-node, night-scheduler) and legacy compliance/dsgvo pages. Add new SDK pipeline modules (academy, document-crawler, dsb-portal, incidents, whistleblower, reporting, sso, multi-tenant, industry-templates). Add drafting engine, legal corpus files (AT/CH/DE), pitch-deck, blog and Förderantrag pages. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -65,7 +65,7 @@ export function Header({ title, description }: HeaderProps) {
|
||||
|
||||
{/* User Area */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-slate-500">Admin v2</span>
|
||||
<span className="text-sm text-slate-500">Admin Lehrer KI</span>
|
||||
<div className="w-8 h-8 rounded-full bg-primary-600 flex items-center justify-center text-white text-sm font-medium">
|
||||
A
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,16 @@ const categoryIcons: Record<string, React.ReactNode> = {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
),
|
||||
globe: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
),
|
||||
'code-2': (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
|
||||
const metaIcons: Record<string, React.ReactNode> = {
|
||||
@@ -138,7 +148,7 @@ export function Sidebar({ onRoleChange }: SidebarProps) {
|
||||
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-700">
|
||||
{!collapsed && (
|
||||
<Link href="/dashboard" className="font-bold text-lg">
|
||||
Admin v2
|
||||
Admin Lehrer KI
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
@@ -184,7 +194,7 @@ export function Sidebar({ onRoleChange }: SidebarProps) {
|
||||
{/* Categories */}
|
||||
<div className="px-2 space-y-1">
|
||||
{visibleCategories.map((category) => {
|
||||
const categoryHref = category.id === 'compliance-sdk' ? '/dashboard/catalog-manager' : `/${category.id === 'compliance' ? 'compliance' : category.id}`
|
||||
const categoryHref = category.id === 'compliance-sdk' ? '/sdk' : `/${category.id}`
|
||||
const isCategoryActive = category.id === 'compliance-sdk'
|
||||
? category.modules.some(m => pathname.startsWith(m.href))
|
||||
: pathname.startsWith(categoryHref)
|
||||
|
||||
@@ -15,6 +15,7 @@ interface Message {
|
||||
|
||||
interface ComplianceAdvisorWidgetProps {
|
||||
currentStep?: string
|
||||
enableDraftingEngine?: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -58,8 +59,15 @@ const EXAMPLE_QUESTIONS: Record<string, string[]> = {
|
||||
// COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) {
|
||||
export function ComplianceAdvisorWidget({ currentStep = 'default', enableDraftingEngine = false }: ComplianceAdvisorWidgetProps) {
|
||||
// Feature-flag: If Drafting Engine enabled, render DraftingEngineWidget instead
|
||||
if (enableDraftingEngine) {
|
||||
const { DraftingEngineWidget } = require('./DraftingEngineWidget')
|
||||
return <DraftingEngineWidget currentStep={currentStep} enableDraftingEngine />
|
||||
}
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
@@ -152,6 +160,11 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === agentMessageId ? { ...m, content: currentText } : m))
|
||||
)
|
||||
|
||||
// Auto-scroll during streaming
|
||||
requestAnimationFrame(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
})
|
||||
}
|
||||
|
||||
setIsTyping(false)
|
||||
@@ -214,7 +227,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-6 right-6 w-14 h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
|
||||
className="fixed bottom-6 right-[5.5rem] w-14 h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
|
||||
aria-label="Compliance Advisor oeffnen"
|
||||
>
|
||||
<svg
|
||||
@@ -235,7 +248,7 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 w-[400px] h-[500px] bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200">
|
||||
<div className={`fixed bottom-6 right-6 ${isExpanded ? 'w-[700px] h-[80vh]' : 'w-[400px] h-[500px]'} max-h-screen bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200 transition-all duration-200`}>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white px-4 py-3 rounded-t-2xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -259,25 +272,55 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
|
||||
<div className="text-xs text-white/80">KI-gestuetzter Assistent</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-white/80 hover:text-white transition-colors"
|
||||
aria-label="Schliessen"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-white/80 hover:text-white transition-colors"
|
||||
aria-label={isExpanded ? 'Verkleinern' : 'Vergroessern'}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 9L4 4m0 0v4m0-4h4m6 6l5 5m0 0v-4m0 4h-4"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-white/80 hover:text-white transition-colors"
|
||||
aria-label="Schliessen"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
|
||||
@@ -392,7 +392,7 @@ export function DocumentUploadSection({
|
||||
onOpenInEditor(doc)
|
||||
} else {
|
||||
// Default: navigate to workflow editor
|
||||
router.push(`/compliance/workflow?documentType=${documentType}&documentId=${doc.id}`)
|
||||
router.push(`/sdk/workflow?documentType=${documentType}&documentId=${doc.id}`)
|
||||
}
|
||||
}, [documentType, onOpenInEditor, router])
|
||||
|
||||
|
||||
300
admin-v2/components/sdk/DraftEditor.tsx
Normal file
300
admin-v2/components/sdk/DraftEditor.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DraftEditor - Split-Pane Editor fuer Compliance-Dokument-Entwuerfe
|
||||
*
|
||||
* Links (2/3): Gerenderter Draft mit Section-Headern
|
||||
* Rechts (1/3): Chat-Panel fuer iterative Verfeinerung
|
||||
* Oben: Document-Type Label, Depth-Level Badge, Constraint-Compliance
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
|
||||
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
|
||||
import type {
|
||||
DraftRevision,
|
||||
ConstraintCheckResult,
|
||||
ValidationResult,
|
||||
} from '@/lib/sdk/drafting-engine/types'
|
||||
|
||||
interface DraftEditorProps {
|
||||
draft: DraftRevision
|
||||
documentType: ScopeDocumentType | null
|
||||
constraintCheck: ConstraintCheckResult | null
|
||||
validationResult: ValidationResult | null
|
||||
isTyping: boolean
|
||||
onAccept: () => void
|
||||
onValidate: () => void
|
||||
onClose: () => void
|
||||
onRefine: (instruction: string) => void
|
||||
}
|
||||
|
||||
export function DraftEditor({
|
||||
draft,
|
||||
documentType,
|
||||
constraintCheck,
|
||||
validationResult,
|
||||
isTyping,
|
||||
onAccept,
|
||||
onValidate,
|
||||
onClose,
|
||||
onRefine,
|
||||
}: DraftEditorProps) {
|
||||
const [refineInput, setRefineInput] = useState('')
|
||||
const [activeSection, setActiveSection] = useState<string | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleRefine = useCallback(() => {
|
||||
if (!refineInput.trim() || isTyping) return
|
||||
onRefine(refineInput.trim())
|
||||
setRefineInput('')
|
||||
}, [refineInput, isTyping, onRefine])
|
||||
|
||||
const handleRefineKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleRefine()
|
||||
}
|
||||
}
|
||||
|
||||
const docLabel = documentType
|
||||
? DOCUMENT_TYPE_LABELS[documentType]?.split(' (')[0] || documentType
|
||||
: 'Dokument'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-900/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-6xl h-[85vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 text-white px-6 py-3 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-semibold text-sm">{docLabel} - Entwurf</div>
|
||||
<div className="text-xs text-white/70">
|
||||
{draft.sections.length} Sections | Erstellt {new Date(draft.createdAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Constraint Badge */}
|
||||
{constraintCheck && (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
constraintCheck.allowed
|
||||
? 'bg-green-500/20 text-green-100'
|
||||
: 'bg-red-500/20 text-red-100'
|
||||
}`}>
|
||||
{constraintCheck.allowed ? 'Constraints OK' : 'Constraint-Verletzung'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Validation Badge */}
|
||||
{validationResult && (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
validationResult.passed
|
||||
? 'bg-green-500/20 text-green-100'
|
||||
: 'bg-amber-500/20 text-amber-100'
|
||||
}`}>
|
||||
{validationResult.passed ? 'Validiert' : `${validationResult.errors.length} Fehler`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/80 hover:text-white transition-colors p-1"
|
||||
aria-label="Editor schliessen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Adjustment Warnings */}
|
||||
{constraintCheck && constraintCheck.adjustments.length > 0 && (
|
||||
<div className="px-6 py-2 bg-amber-50 border-b border-amber-200 shrink-0">
|
||||
{constraintCheck.adjustments.map((adj, i) => (
|
||||
<p key={i} className="text-xs text-amber-700 flex items-start gap-1">
|
||||
<svg className="w-3.5 h-3.5 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
{adj}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content: 2/3 Editor + 1/3 Chat */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: Draft Content (2/3) */}
|
||||
<div className="w-2/3 border-r border-gray-200 overflow-y-auto" ref={contentRef}>
|
||||
{/* Section Navigation */}
|
||||
<div className="sticky top-0 bg-white border-b border-gray-100 px-4 py-2 flex items-center gap-1 overflow-x-auto z-10">
|
||||
{draft.sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => {
|
||||
setActiveSection(section.id)
|
||||
document.getElementById(`section-${section.id}`)?.scrollIntoView({ behavior: 'smooth' })
|
||||
}}
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
activeSection === section.id
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{section.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="p-6 space-y-6">
|
||||
{draft.sections.map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
id={`section-${section.id}`}
|
||||
className={`rounded-lg border transition-colors ${
|
||||
activeSection === section.id
|
||||
? 'border-blue-300 bg-blue-50/30'
|
||||
: 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="px-4 py-2.5 border-b border-gray-100 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900">{section.title}</h3>
|
||||
{section.schemaField && (
|
||||
<span className="text-xs text-gray-400 font-mono">{section.schemaField}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap leading-relaxed">
|
||||
{section.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Refinement Chat (1/3) */}
|
||||
<div className="w-1/3 flex flex-col bg-gray-50">
|
||||
<div className="px-4 py-3 border-b border-gray-200 bg-white">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Verfeinerung</h3>
|
||||
<p className="text-xs text-gray-500">Geben Sie Anweisungen zur Verbesserung</p>
|
||||
</div>
|
||||
|
||||
{/* Validation Summary (if present) */}
|
||||
{validationResult && (
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<div className="space-y-1.5">
|
||||
{validationResult.errors.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-red-600">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500" />
|
||||
{validationResult.errors.length} Fehler
|
||||
</div>
|
||||
)}
|
||||
{validationResult.warnings.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-amber-600">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
{validationResult.warnings.length} Warnungen
|
||||
</div>
|
||||
)}
|
||||
{validationResult.suggestions.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-blue-600">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
{validationResult.suggestions.length} Vorschlaege
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Refinement Area */}
|
||||
<div className="flex-1 p-4 overflow-y-auto">
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-gray-500">
|
||||
Beschreiben Sie, was geaendert werden soll. Der Agent erstellt eine ueberarbeitete Version unter Beachtung der Scope-Constraints.
|
||||
</p>
|
||||
|
||||
{/* Quick Refinement Buttons */}
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
'Mehr Details hinzufuegen',
|
||||
'Platzhalter ausfuellen',
|
||||
'Rechtliche Referenzen ergaenzen',
|
||||
'Sprache vereinfachen',
|
||||
].map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
onClick={() => onRefine(suggestion)}
|
||||
disabled={isTyping}
|
||||
className="w-full text-left px-3 py-1.5 text-xs bg-white hover:bg-blue-50 border border-gray-200 rounded-md transition-colors text-gray-600 disabled:opacity-50"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refinement Input */}
|
||||
<div className="border-t border-gray-200 p-3 bg-white">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={refineInput}
|
||||
onChange={(e) => setRefineInput(e.target.value)}
|
||||
onKeyDown={handleRefineKeyDown}
|
||||
placeholder="Anweisung eingeben..."
|
||||
disabled={isTyping}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleRefine}
|
||||
disabled={!refineInput.trim() || isTyping}
|
||||
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="border-t border-gray-200 px-6 py-3 bg-white flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onValidate}
|
||||
disabled={isTyping}
|
||||
className="px-4 py-2 text-sm font-medium text-green-700 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Validieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={onAccept}
|
||||
disabled={isTyping}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Draft akzeptieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
417
admin-v2/components/sdk/DraftingEngineWidget.tsx
Normal file
417
admin-v2/components/sdk/DraftingEngineWidget.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DraftingEngineWidget - Erweitert den ComplianceAdvisor um 4 Modi
|
||||
*
|
||||
* Mode-Indicator Pills: Explain / Ask / Draft / Validate
|
||||
* Document-Type Selector aus requiredDocuments der ScopeDecision
|
||||
* Feature-Flag enableDraftingEngine fuer schrittweises Rollout
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useSDK } from '@/lib/sdk/context'
|
||||
import { useDraftingEngine } from '@/lib/sdk/drafting-engine/use-drafting-engine'
|
||||
import { DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
|
||||
import type { AgentMode } from '@/lib/sdk/drafting-engine/types'
|
||||
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
|
||||
import { DraftEditor } from './DraftEditor'
|
||||
import { ValidationReport } from './ValidationReport'
|
||||
|
||||
interface DraftingEngineWidgetProps {
|
||||
currentStep?: string
|
||||
enableDraftingEngine?: boolean
|
||||
}
|
||||
|
||||
const MODE_CONFIG: Record<AgentMode, { label: string; color: string; activeColor: string; icon: string }> = {
|
||||
explain: { label: 'Explain', color: 'bg-gray-100 text-gray-600', activeColor: 'bg-purple-100 text-purple-700 ring-1 ring-purple-300', icon: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
ask: { label: 'Ask', color: 'bg-gray-100 text-gray-600', activeColor: 'bg-amber-100 text-amber-700 ring-1 ring-amber-300', icon: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
draft: { label: 'Draft', color: 'bg-gray-100 text-gray-600', activeColor: 'bg-blue-100 text-blue-700 ring-1 ring-blue-300', icon: 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z' },
|
||||
validate: { label: 'Validate', color: 'bg-gray-100 text-gray-600', activeColor: 'bg-green-100 text-green-700 ring-1 ring-green-300', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
}
|
||||
|
||||
const EXAMPLE_QUESTIONS: Record<AgentMode, string[]> = {
|
||||
explain: [
|
||||
'Was ist ein Verarbeitungsverzeichnis?',
|
||||
'Wann brauche ich eine DSFA?',
|
||||
'Was sind TOM nach Art. 32 DSGVO?',
|
||||
],
|
||||
ask: [
|
||||
'Welche Luecken hat mein Compliance-Profil?',
|
||||
'Was fehlt noch fuer die Zertifizierung?',
|
||||
'Welche Dokumente muss ich noch erstellen?',
|
||||
],
|
||||
draft: [
|
||||
'Erstelle einen VVT-Eintrag fuer unseren Hauptprozess',
|
||||
'Erstelle TOM fuer unsere Cloud-Infrastruktur',
|
||||
'Erstelle eine Datenschutzerklaerung',
|
||||
],
|
||||
validate: [
|
||||
'Pruefe die Konsistenz meiner Dokumente',
|
||||
'Stimmen VVT und TOM ueberein?',
|
||||
'Gibt es Luecken bei den Loeschfristen?',
|
||||
],
|
||||
}
|
||||
|
||||
export function DraftingEngineWidget({
|
||||
currentStep = 'default',
|
||||
enableDraftingEngine = true,
|
||||
}: DraftingEngineWidgetProps) {
|
||||
const { state } = useSDK()
|
||||
const engine = useDraftingEngine()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [showDraftEditor, setShowDraftEditor] = useState(false)
|
||||
const [showValidationReport, setShowValidationReport] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Available document types from scope decision
|
||||
const availableDocumentTypes: ScopeDocumentType[] =
|
||||
state.complianceScope?.decision?.requiredDocuments
|
||||
?.filter(d => d.required)
|
||||
.map(d => d.documentType as ScopeDocumentType) ?? ['vvt', 'tom', 'lf']
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [engine.messages])
|
||||
|
||||
// Open draft editor when a new draft arrives
|
||||
useEffect(() => {
|
||||
if (engine.currentDraft) {
|
||||
setShowDraftEditor(true)
|
||||
}
|
||||
}, [engine.currentDraft])
|
||||
|
||||
// Open validation report when new results arrive
|
||||
useEffect(() => {
|
||||
if (engine.validationResult) {
|
||||
setShowValidationReport(true)
|
||||
}
|
||||
}, [engine.validationResult])
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
(content: string) => {
|
||||
if (!content.trim()) return
|
||||
setInputValue('')
|
||||
engine.sendMessage(content)
|
||||
},
|
||||
[engine]
|
||||
)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage(inputValue)
|
||||
}
|
||||
}
|
||||
|
||||
const exampleQuestions = EXAMPLE_QUESTIONS[engine.currentMode]
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-6 right-[5.5rem] w-14 h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
|
||||
aria-label="Drafting Engine oeffnen"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Draft Editor full-screen overlay
|
||||
if (showDraftEditor && engine.currentDraft) {
|
||||
return (
|
||||
<DraftEditor
|
||||
draft={engine.currentDraft}
|
||||
documentType={engine.activeDocumentType}
|
||||
constraintCheck={engine.constraintCheck}
|
||||
onAccept={() => {
|
||||
engine.acceptDraft()
|
||||
setShowDraftEditor(false)
|
||||
}}
|
||||
onValidate={() => {
|
||||
engine.validateDraft()
|
||||
}}
|
||||
onClose={() => setShowDraftEditor(false)}
|
||||
onRefine={(instruction: string) => {
|
||||
engine.requestDraft(instruction)
|
||||
}}
|
||||
validationResult={engine.validationResult}
|
||||
isTyping={engine.isTyping}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-6 right-6 ${isExpanded ? 'w-[700px] h-[80vh]' : 'w-[420px] h-[560px]'} max-h-screen bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200 transition-all duration-200`}>
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white px-4 py-3 rounded-t-2xl flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm">Drafting Engine</div>
|
||||
<div className="text-xs text-white/80">Compliance-Dokumententwurf</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-white/80 hover:text-white transition-colors p-1"
|
||||
aria-label={isExpanded ? 'Verkleinern' : 'Vergroessern'}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{isExpanded ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9L4 4m0 0v4m0-4h4m6 6l5 5m0 0v-4m0 4h-4" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
engine.clearMessages()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className="text-white/80 hover:text-white transition-colors p-1"
|
||||
aria-label="Schliessen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Pills */}
|
||||
<div className="flex items-center gap-1 px-3 py-2 border-b border-gray-100 bg-white">
|
||||
{(Object.keys(MODE_CONFIG) as AgentMode[]).map((mode) => {
|
||||
const config = MODE_CONFIG[mode]
|
||||
const isActive = engine.currentMode === mode
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => engine.setMode(mode)}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium transition-all ${isActive ? config.activeColor : config.color} hover:opacity-80`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={config.icon} />
|
||||
</svg>
|
||||
{config.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Document Type Selector (visible in draft/validate mode) */}
|
||||
{(engine.currentMode === 'draft' || engine.currentMode === 'validate') && (
|
||||
<div className="px-3 py-2 border-b border-gray-100 bg-gray-50/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 shrink-0">Dokument:</span>
|
||||
<select
|
||||
value={engine.activeDocumentType || ''}
|
||||
onChange={(e) => engine.setDocumentType(e.target.value as ScopeDocumentType)}
|
||||
className="flex-1 text-xs border border-gray-200 rounded-md px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
>
|
||||
<option value="">Dokumenttyp waehlen...</option>
|
||||
{availableDocumentTypes.map((dt) => (
|
||||
<option key={dt} value={dt}>
|
||||
{DOCUMENT_TYPE_LABELS[dt] || dt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Banner */}
|
||||
{engine.error && (
|
||||
<div className="mx-3 mt-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-700 flex items-center justify-between">
|
||||
<span>{engine.error}</span>
|
||||
<button onClick={() => engine.clearMessages()} className="text-red-500 hover:text-red-700 ml-2">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation Report Inline */}
|
||||
{showValidationReport && engine.validationResult && (
|
||||
<div className="mx-3 mt-2 max-h-48 overflow-y-auto">
|
||||
<ValidationReport
|
||||
result={engine.validationResult}
|
||||
onClose={() => setShowValidationReport(false)}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-gray-50">
|
||||
{engine.messages.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<div className="w-14 h-14 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<svg className="w-7 h-7 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={MODE_CONFIG[engine.currentMode].icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-1">
|
||||
{engine.currentMode === 'explain' && 'Fragen beantworten'}
|
||||
{engine.currentMode === 'ask' && 'Luecken erkennen'}
|
||||
{engine.currentMode === 'draft' && 'Dokumente entwerfen'}
|
||||
{engine.currentMode === 'validate' && 'Konsistenz pruefen'}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
{engine.currentMode === 'explain' && 'Stellen Sie Fragen zu DSGVO, AI Act und Compliance.'}
|
||||
{engine.currentMode === 'ask' && 'Identifiziert Luecken in Ihrem Compliance-Profil.'}
|
||||
{engine.currentMode === 'draft' && 'Erstellt strukturierte Compliance-Dokumente.'}
|
||||
{engine.currentMode === 'validate' && 'Prueft Cross-Dokument-Konsistenz.'}
|
||||
</p>
|
||||
|
||||
<div className="text-left space-y-2">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">Beispiele:</p>
|
||||
{exampleQuestions.map((q, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleSendMessage(q)}
|
||||
className="w-full text-left px-3 py-2 text-xs bg-white hover:bg-purple-50 border border-gray-200 rounded-lg transition-colors text-gray-700"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions for Draft/Validate */}
|
||||
{engine.currentMode === 'draft' && engine.activeDocumentType && (
|
||||
<button
|
||||
onClick={() => engine.requestDraft()}
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white text-xs font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Draft fuer {DOCUMENT_TYPE_LABELS[engine.activeDocumentType]?.split(' (')[0] || engine.activeDocumentType} erstellen
|
||||
</button>
|
||||
)}
|
||||
{engine.currentMode === 'validate' && (
|
||||
<button
|
||||
onClick={() => engine.validateDraft()}
|
||||
className="mt-4 px-4 py-2 bg-green-600 text-white text-xs font-medium rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Validierung starten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{engine.messages.map((message, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 ${
|
||||
message.role === 'user'
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-white border border-gray-200 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
<p className={`text-sm ${message.role === 'assistant' ? 'whitespace-pre-wrap' : ''}`}>
|
||||
{message.content}
|
||||
</p>
|
||||
|
||||
{/* Draft ready indicator */}
|
||||
{message.metadata?.hasDraft && engine.currentDraft && (
|
||||
<button
|
||||
onClick={() => setShowDraftEditor(true)}
|
||||
className="mt-2 flex items-center gap-1.5 px-3 py-1.5 bg-blue-50 border border-blue-200 rounded-md text-xs text-blue-700 hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Im Editor oeffnen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Validation ready indicator */}
|
||||
{message.metadata?.hasValidation && engine.validationResult && (
|
||||
<button
|
||||
onClick={() => setShowValidationReport(true)}
|
||||
className="mt-2 flex items-center gap-1.5 px-3 py-1.5 bg-green-50 border border-green-200 rounded-md text-xs text-green-700 hover:bg-green-100 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Validierungsbericht anzeigen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{engine.isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-white border border-gray-200 rounded-lg px-3 py-2">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }} />
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="border-t border-gray-200 p-3 bg-white rounded-b-2xl">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
engine.currentMode === 'draft'
|
||||
? 'Anweisung fuer den Entwurf...'
|
||||
: engine.currentMode === 'validate'
|
||||
? 'Validierungsfrage...'
|
||||
: 'Frage eingeben...'
|
||||
}
|
||||
disabled={engine.isTyping}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50"
|
||||
/>
|
||||
{engine.isTyping ? (
|
||||
<button
|
||||
onClick={engine.stopGeneration}
|
||||
className="px-3 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
title="Generierung stoppen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 6h12v12H6z" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSendMessage(inputValue)}
|
||||
disabled={!inputValue.trim()}
|
||||
className="px-3 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -363,8 +363,8 @@ export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipeline
|
||||
}, [isMobileOpen])
|
||||
|
||||
const fabPositionClasses = fabPosition === 'bottom-right'
|
||||
? 'right-4 bottom-20'
|
||||
: 'left-4 bottom-20'
|
||||
? 'right-4 bottom-6'
|
||||
: 'left-4 bottom-6'
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -411,7 +411,7 @@ export function SDKPipelineSidebar({ fabPosition = 'bottom-right' }: SDKPipeline
|
||||
{isDesktopCollapsed && (
|
||||
<button
|
||||
onClick={toggleDesktopSidebar}
|
||||
className={`hidden xl:flex fixed right-6 bottom-20 z-40 w-14 h-14 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all items-center justify-center group`}
|
||||
className={`hidden xl:flex fixed right-6 bottom-6 z-40 w-14 h-14 bg-gradient-to-r from-purple-500 to-indigo-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all items-center justify-center group`}
|
||||
aria-label="SDK Pipeline Navigation oeffnen"
|
||||
title="Pipeline anzeigen"
|
||||
>
|
||||
|
||||
@@ -781,6 +781,87 @@ export const STEP_EXPLANATIONS = {
|
||||
},
|
||||
],
|
||||
},
|
||||
'incidents': {
|
||||
title: 'Incident Management',
|
||||
description: 'Erfassen, bewerten und melden Sie Datenschutzverletzungen nach Art. 33/34 DSGVO',
|
||||
explanation: 'Das Incident Management ermoeglicht die strukturierte Erfassung und Bearbeitung von Datenschutzverletzungen. Es umfasst die Ersterfassung des Vorfalls, eine automatische Risikobewertung zur Bestimmung der Meldepflicht, einen 72-Stunden-Countdown fuer die Meldung an die Aufsichtsbehoerde, die Generierung des Meldeformulars sowie die Dokumentation aller Sofort- und Langfristmassnahmen.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
title: '72-Stunden-Frist',
|
||||
description: 'Art. 33 DSGVO: Die Aufsichtsbehoerde muss innerhalb von 72 Stunden nach Bekanntwerden einer meldepflichtigen Datenpanne informiert werden.',
|
||||
},
|
||||
{
|
||||
icon: 'info' as const,
|
||||
title: 'Risikobewertung',
|
||||
description: 'Nicht jede Datenpanne ist meldepflichtig. Die Risikobewertung hilft automatisch zu bestimmen, ob eine Meldung an die Aufsichtsbehoerde oder Betroffene erforderlich ist.',
|
||||
},
|
||||
{
|
||||
icon: 'lightbulb' as const,
|
||||
title: 'Massnahmen dokumentieren',
|
||||
description: 'Dokumentieren Sie sowohl Sofortmassnahmen (Eindaemmung) als auch langfristige Massnahmen (Praevention). Dies ist fuer Audits essentiell.',
|
||||
},
|
||||
{
|
||||
icon: 'success' as const,
|
||||
title: 'Lessons Learned',
|
||||
description: 'Schliessen Sie jeden Vorfall mit einer Ursachenanalyse und Lessons Learned ab, um kuenftige Vorfaelle zu vermeiden.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'whistleblower': {
|
||||
title: 'Hinweisgebersystem',
|
||||
description: 'Anonymes Meldesystem gemaess Hinweisgeberschutzgesetz (HinSchG)',
|
||||
explanation: 'Das Hinweisgebersystem ermoeglicht anonyme Meldungen von Missstaenden gemaess dem Hinweisgeberschutzgesetz (HinSchG). Unternehmen ab 50 Mitarbeitern sind gesetzlich verpflichtet, einen internen Meldekanal bereitzustellen. Das System bietet ein oeffentliches Meldeformular (ohne Login), einen anonymen Rueckkanal ueber Zugangscodes, Fallmanagement fuer die Ombudsperson und revisionssichere Dokumentation.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
title: 'Gesetzliche Pflicht',
|
||||
description: 'Ab 50 Mitarbeitern ist ein interner Meldekanal Pflicht (§ 12 HinSchG). Bussgeld bei Verstoessen: bis zu 50.000 EUR.',
|
||||
},
|
||||
{
|
||||
icon: 'info' as const,
|
||||
title: '7-Tage-Frist',
|
||||
description: 'Eingangsbestaetigung muss innerhalb von 7 Tagen erfolgen. Rueckmeldung an den Hinweisgeber innerhalb von 3 Monaten.',
|
||||
},
|
||||
{
|
||||
icon: 'lightbulb' as const,
|
||||
title: 'Anonymitaet schuetzen',
|
||||
description: 'Die Identitaet des Hinweisgebers darf nur mit dessen Einwilligung offengelegt werden. Das System unterstuetzt vollstaendig anonyme Meldungen.',
|
||||
},
|
||||
{
|
||||
icon: 'success' as const,
|
||||
title: 'Massnahmen-Tracking',
|
||||
description: 'Dokumentieren Sie alle ergriffenen Massnahmen. Dies dient als Nachweis fuer die Aufsichtsbehoerde.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'academy': {
|
||||
title: 'Compliance Academy',
|
||||
description: 'Schulen Sie Ihre Mitarbeiter in Datenschutz, IT-Sicherheit und KI-Kompetenz',
|
||||
explanation: 'Die Compliance Academy bietet eine integrierte Schulungsplattform fuer Mitarbeiter-Compliance-Trainings. Sie umfasst vorgefertigte Kurse zu DSGVO-Grundlagen, IT-Sicherheit, AI Literacy und Hinweisgeberschutz. Mitarbeiter absolvieren Lektionen mit Videos und Texten, beantworten Quiz-Fragen und erhalten nach erfolgreichem Abschluss ein Zertifikat. Administratoren koennen den Fortschritt aller Mitarbeiter nachverfolgen und Erinnerungen fuer jaehrliche Auffrischungen einrichten.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
title: 'DSGVO-Schulungspflicht',
|
||||
description: 'Art. 39 Abs. 1 lit. b DSGVO: Der DSB muss die Sensibilisierung und Schulung der Mitarbeiter sicherstellen. Nachweisbare Schulungen sind Pflicht.',
|
||||
},
|
||||
{
|
||||
icon: 'info' as const,
|
||||
title: 'Jaehrliche Auffrischung',
|
||||
description: 'Compliance-Schulungen sollten mindestens jaehrlich wiederholt werden. Das System erinnert automatisch an faellige Auffrischungen.',
|
||||
},
|
||||
{
|
||||
icon: 'lightbulb' as const,
|
||||
title: 'Zertifikate als Nachweis',
|
||||
description: 'Jeder abgeschlossene Kurs generiert ein PDF-Zertifikat. Dies dient als Audit-Nachweis fuer die Schulungspflicht.',
|
||||
},
|
||||
{
|
||||
icon: 'success' as const,
|
||||
title: 'Quiz-Pflicht',
|
||||
description: 'Nach jeder Lektion muss ein Quiz bestanden werden. So wird sichergestellt, dass die Inhalte verstanden wurden.',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default StepHeader
|
||||
|
||||
220
admin-v2/components/sdk/ValidationReport.tsx
Normal file
220
admin-v2/components/sdk/ValidationReport.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ValidationReport - Strukturierte Anzeige von Validierungsergebnissen
|
||||
*
|
||||
* Errors (Scope-Violations) in Rot
|
||||
* Warnings (Inkonsistenzen) in Amber
|
||||
* Suggestions in Blau
|
||||
*/
|
||||
|
||||
import { DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
|
||||
import type { ValidationResult, ValidationFinding } from '@/lib/sdk/drafting-engine/types'
|
||||
|
||||
interface ValidationReportProps {
|
||||
result: ValidationResult
|
||||
onClose: () => void
|
||||
/** Compact mode for inline display in widget */
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const SEVERITY_CONFIG = {
|
||||
error: {
|
||||
bg: 'bg-red-50',
|
||||
border: 'border-red-200',
|
||||
text: 'text-red-700',
|
||||
icon: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
label: 'Fehler',
|
||||
dotColor: 'bg-red-500',
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-amber-50',
|
||||
border: 'border-amber-200',
|
||||
text: 'text-amber-700',
|
||||
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 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z',
|
||||
label: 'Warnungen',
|
||||
dotColor: 'bg-amber-500',
|
||||
},
|
||||
suggestion: {
|
||||
bg: 'bg-blue-50',
|
||||
border: 'border-blue-200',
|
||||
text: 'text-blue-700',
|
||||
icon: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
label: 'Vorschlaege',
|
||||
dotColor: 'bg-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
function FindingCard({ finding, compact }: { finding: ValidationFinding; compact?: boolean }) {
|
||||
const config = SEVERITY_CONFIG[finding.severity]
|
||||
const docLabel = DOCUMENT_TYPE_LABELS[finding.documentType]?.split(' (')[0] || finding.documentType
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`flex items-start gap-2 px-2.5 py-1.5 ${config.bg} rounded-md border ${config.border}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full mt-1.5 shrink-0 ${config.dotColor}`} />
|
||||
<div className="min-w-0">
|
||||
<p className={`text-xs font-medium ${config.text}`}>{finding.title}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{finding.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${config.bg} rounded-lg border ${config.border} p-3`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className={`w-4 h-4 mt-0.5 shrink-0 ${config.text}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={config.icon} />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className={`text-sm font-medium ${config.text}`}>{finding.title}</h4>
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">{docLabel}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-1">{finding.description}</p>
|
||||
|
||||
{finding.crossReferenceType && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Cross-Referenz: {DOCUMENT_TYPE_LABELS[finding.crossReferenceType]?.split(' (')[0] || finding.crossReferenceType}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{finding.legalReference && (
|
||||
<p className="text-xs text-gray-500 mt-1 font-mono">{finding.legalReference}</p>
|
||||
)}
|
||||
|
||||
{finding.suggestion && (
|
||||
<div className="mt-2 flex items-start gap-1.5 px-2.5 py-1.5 bg-white/60 rounded border border-gray-100">
|
||||
<svg className="w-3.5 h-3.5 mt-0.5 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
<p className="text-xs text-gray-600">{finding.suggestion}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ValidationReport({ result, onClose, compact }: ValidationReportProps) {
|
||||
const totalFindings = result.errors.length + result.warnings.length + result.suggestions.length
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b border-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${result.passed ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
{result.passed ? 'Validierung bestanden' : 'Validierung fehlgeschlagen'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
({totalFindings} {totalFindings === 1 ? 'Fund' : 'Funde'})
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2 space-y-1.5 max-h-36 overflow-y-auto">
|
||||
{result.errors.map((f) => <FindingCard key={f.id} finding={f} compact />)}
|
||||
{result.warnings.map((f) => <FindingCard key={f.id} finding={f} compact />)}
|
||||
{result.suggestions.map((f) => <FindingCard key={f.id} finding={f} compact />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Header */}
|
||||
<div className={`rounded-lg border p-4 ${result.passed ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${result.passed ? 'bg-green-100' : 'bg-red-100'}`}>
|
||||
<svg className={`w-5 h-5 ${result.passed ? 'text-green-600' : 'text-red-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={result.passed ? 'M5 13l4 4L19 7' : 'M6 18L18 6M6 6l12 12'} />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`text-sm font-semibold ${result.passed ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{result.passed ? 'Validierung bestanden' : 'Validierung fehlgeschlagen'}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Level {result.scopeLevel} | {new Date(result.timestamp).toLocaleString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-3">
|
||||
{result.errors.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<span className="text-xs font-medium text-red-700">{result.errors.length}</span>
|
||||
</div>
|
||||
)}
|
||||
{result.warnings.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
<span className="text-xs font-medium text-amber-700">{result.warnings.length}</span>
|
||||
</div>
|
||||
)}
|
||||
{result.suggestions.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-xs font-medium text-blue-700">{result.suggestions.length}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 ml-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{result.errors.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-red-700 uppercase tracking-wide mb-2">
|
||||
Fehler ({result.errors.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{result.errors.map((f) => <FindingCard key={f.id} finding={f} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{result.warnings.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-amber-700 uppercase tracking-wide mb-2">
|
||||
Warnungen ({result.warnings.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{result.warnings.map((f) => <FindingCard key={f.id} finding={f} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions */}
|
||||
{result.suggestions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-blue-700 uppercase tracking-wide mb-2">
|
||||
Vorschlaege ({result.suggestions.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{result.suggestions.map((f) => <FindingCard key={f.id} finding={f} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
admin-v2/components/sdk/academy/CertificateViewer.tsx
Normal file
141
admin-v2/components/sdk/academy/CertificateViewer.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Certificate } from '@/lib/sdk/academy/types'
|
||||
import { downloadCertificatePDF } from '@/lib/sdk/academy/api'
|
||||
|
||||
interface CertificateViewerProps {
|
||||
certificate: Certificate
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export default function CertificateViewer({ certificate, onClose }: CertificateViewerProps) {
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleDownloadPDF = async () => {
|
||||
setDownloading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const blob = await downloadCertificatePDF(certificate.id)
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `zertifikat-${certificate.id.slice(0, 8)}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'PDF-Download fehlgeschlagen')
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const issuedDate = new Date(certificate.issuedAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric'
|
||||
})
|
||||
const validDate = new Date(certificate.validUntil).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric'
|
||||
})
|
||||
const isExpired = new Date(certificate.validUntil) < new Date()
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden">
|
||||
{/* Certificate Preview */}
|
||||
<div className="relative bg-gradient-to-br from-indigo-50 via-white to-purple-50 p-8">
|
||||
{/* Decorative border */}
|
||||
<div className="absolute inset-4 border-2 border-indigo-200 rounded-lg pointer-events-none" />
|
||||
<div className="absolute inset-5 border border-indigo-100 rounded-lg pointer-events-none" />
|
||||
|
||||
<div className="relative text-center space-y-4">
|
||||
{/* Company */}
|
||||
<p className="text-sm text-gray-400 tracking-widest uppercase">BreakPilot Compliance</p>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-2xl font-bold text-gray-900 tracking-wide">SCHULUNGSZERTIFIKAT</h2>
|
||||
|
||||
{/* Decorative line */}
|
||||
<div className="mx-auto w-24 h-0.5 bg-indigo-500" />
|
||||
|
||||
{/* Body */}
|
||||
<p className="text-sm text-gray-500">Hiermit wird bescheinigt, dass</p>
|
||||
|
||||
<p className="text-xl font-bold text-gray-900">{certificate.userName}</p>
|
||||
|
||||
<p className="text-sm text-gray-500">die folgende Compliance-Schulung erfolgreich abgeschlossen hat:</p>
|
||||
|
||||
<p className="text-lg font-semibold text-indigo-600">{certificate.courseName}</p>
|
||||
|
||||
{/* Score */}
|
||||
{certificate.score > 0 && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Testergebnis: <span className="font-semibold text-gray-700">{certificate.score}%</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<div className="flex justify-between items-center px-8 pt-4 text-xs text-gray-400">
|
||||
<span>Abschlussdatum: {issuedDate}</span>
|
||||
<span className={isExpired ? 'text-red-500 font-medium' : ''}>
|
||||
Gueltig bis: {validDate}
|
||||
{isExpired && ' (abgelaufen)'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Certificate ID */}
|
||||
<p className="text-xs text-gray-300">
|
||||
Zertifikats-Nr.: {certificate.id.slice(0, 12)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="text-xs text-gray-400">
|
||||
Elektronisch erstellt - ohne Unterschrift gueltig
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDownloadPDF}
|
||||
disabled={downloading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
{downloading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Wird erstellt...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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" />
|
||||
</svg>
|
||||
PDF herunterladen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="px-6 py-3 bg-red-50 border-t border-red-200 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
413
admin-v2/components/sdk/source-policy/AuditTab.tsx
Normal file
413
admin-v2/components/sdk/source-policy/AuditTab.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: string
|
||||
action: string
|
||||
entity_type: string
|
||||
entity_id?: string
|
||||
old_value?: any
|
||||
new_value?: any
|
||||
user_email?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface BlockedContentEntry {
|
||||
id: string
|
||||
url: string
|
||||
domain: string
|
||||
block_reason: string
|
||||
rule_id?: string
|
||||
details?: any
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface AuditTabProps {
|
||||
apiBase: string
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
|
||||
create: { label: 'Erstellt', color: 'bg-green-100 text-green-700' },
|
||||
update: { label: 'Aktualisiert', color: 'bg-blue-100 text-blue-700' },
|
||||
delete: { label: 'Geloescht', color: 'bg-red-100 text-red-700' },
|
||||
}
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
source_policy: 'Policy',
|
||||
allowed_source: 'Quelle',
|
||||
operation_permission: 'Operation',
|
||||
pii_rule: 'PII-Regel',
|
||||
}
|
||||
|
||||
const BLOCK_REASON_LABELS: Record<string, { label: string; color: string }> = {
|
||||
not_whitelisted: { label: 'Nicht in Whitelist', color: 'bg-amber-100 text-amber-700' },
|
||||
pii_detected: { label: 'PII erkannt', color: 'bg-red-100 text-red-700' },
|
||||
license_violation: { label: 'Lizenzverletzung', color: 'bg-orange-100 text-orange-700' },
|
||||
training_forbidden: { label: 'Training verboten', color: 'bg-slate-800 text-white' },
|
||||
}
|
||||
|
||||
export function AuditTab({ apiBase }: AuditTabProps) {
|
||||
const [activeView, setActiveView] = useState<'changes' | 'blocked'>('changes')
|
||||
|
||||
// Audit logs
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([])
|
||||
const [auditLoading, setAuditLoading] = useState(true)
|
||||
const [auditTotal, setAuditTotal] = useState(0)
|
||||
|
||||
// Blocked content
|
||||
const [blockedContent, setBlockedContent] = useState<BlockedContentEntry[]>([])
|
||||
const [blockedLoading, setBlockedLoading] = useState(true)
|
||||
const [blockedTotal, setBlockedTotal] = useState(0)
|
||||
|
||||
// Filters
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [entityFilter, setEntityFilter] = useState('')
|
||||
|
||||
// Export
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView === 'changes') {
|
||||
fetchAuditLogs()
|
||||
} else {
|
||||
fetchBlockedContent()
|
||||
}
|
||||
}, [activeView, dateFrom, dateTo, entityFilter])
|
||||
|
||||
const fetchAuditLogs = async () => {
|
||||
try {
|
||||
setAuditLoading(true)
|
||||
const params = new URLSearchParams()
|
||||
if (dateFrom) params.append('from', dateFrom)
|
||||
if (dateTo) params.append('to', dateTo)
|
||||
if (entityFilter) params.append('entity_type', entityFilter)
|
||||
params.append('limit', '100')
|
||||
|
||||
const res = await fetch(`${apiBase}/v1/admin/policy-audit?${params}`)
|
||||
if (!res.ok) throw new Error('Fehler beim Laden')
|
||||
|
||||
const data = await res.json()
|
||||
setAuditLogs(data.logs || [])
|
||||
setAuditTotal(data.total || 0)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setAuditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchBlockedContent = async () => {
|
||||
try {
|
||||
setBlockedLoading(true)
|
||||
const params = new URLSearchParams()
|
||||
if (dateFrom) params.append('from', dateFrom)
|
||||
if (dateTo) params.append('to', dateTo)
|
||||
params.append('limit', '100')
|
||||
|
||||
const res = await fetch(`${apiBase}/v1/admin/blocked-content?${params}`)
|
||||
if (!res.ok) throw new Error('Fehler beim Laden')
|
||||
|
||||
const data = await res.json()
|
||||
setBlockedContent(data.blocked || [])
|
||||
setBlockedTotal(data.total || 0)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setBlockedLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const exportReport = async () => {
|
||||
try {
|
||||
setExporting(true)
|
||||
const params = new URLSearchParams()
|
||||
if (dateFrom) params.append('from', dateFrom)
|
||||
if (dateTo) params.append('to', dateTo)
|
||||
params.append('format', 'download')
|
||||
|
||||
const res = await fetch(`${apiBase}/v1/admin/compliance-report?${params}`)
|
||||
if (!res.ok) throw new Error('Fehler beim Export')
|
||||
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `compliance-report-${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Toggle & Filters */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setActiveView('changes')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeView === 'changes'
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Aenderungshistorie
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('blocked')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeView === 'blocked'
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Blockierte URLs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-wrap gap-4 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-slate-600">Von:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-slate-600">Bis:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
{activeView === 'changes' && (
|
||||
<select
|
||||
value={entityFilter}
|
||||
onChange={(e) => setEntityFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="source_policy">Policies</option>
|
||||
<option value="allowed_source">Quellen</option>
|
||||
<option value="operation_permission">Operations</option>
|
||||
<option value="pii_rule">PII-Regeln</option>
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={exportReport}
|
||||
disabled={exporting}
|
||||
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 flex items-center gap-2 ml-auto"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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" />
|
||||
</svg>
|
||||
{exporting ? 'Exportiere...' : 'JSON Export'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Changes View */}
|
||||
{activeView === 'changes' && (
|
||||
<>
|
||||
{auditLoading ? (
|
||||
<div className="text-center py-12 text-slate-500">Lade Audit-Log...</div>
|
||||
) : auditLogs.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
|
||||
<svg className="w-12 h-12 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Eintraege vorhanden</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Aenderungen werden hier protokolliert.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200 text-sm text-slate-600">
|
||||
{auditTotal} Eintraege gesamt
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{auditLogs.map((log) => {
|
||||
const actionConfig = ACTION_LABELS[log.action] || { label: log.action, color: 'bg-slate-100 text-slate-700' }
|
||||
return (
|
||||
<div key={log.id} className="px-4 py-4 hover:bg-slate-50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${actionConfig.color}`}>
|
||||
{actionConfig.label}
|
||||
</span>
|
||||
<span className="text-sm text-slate-700">
|
||||
{ENTITY_LABELS[log.entity_type] || log.entity_type}
|
||||
</span>
|
||||
{log.entity_id && (
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded text-slate-500">
|
||||
{log.entity_id.substring(0, 8)}...
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{formatDate(log.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
{log.user_email && (
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
Benutzer: {log.user_email}
|
||||
</div>
|
||||
)}
|
||||
{(log.old_value || log.new_value) && (
|
||||
<div className="mt-2 flex gap-4 text-xs">
|
||||
{log.old_value && (
|
||||
<div className="flex-1 p-2 bg-red-50 rounded">
|
||||
<div className="text-red-600 font-medium mb-1">Vorher:</div>
|
||||
<pre className="text-red-700 overflow-x-auto">
|
||||
{typeof log.old_value === 'string' ? log.old_value : JSON.stringify(log.old_value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{log.new_value && (
|
||||
<div className="flex-1 p-2 bg-green-50 rounded">
|
||||
<div className="text-green-600 font-medium mb-1">Nachher:</div>
|
||||
<pre className="text-green-700 overflow-x-auto">
|
||||
{typeof log.new_value === 'string' ? log.new_value : JSON.stringify(log.new_value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Blocked Content View */}
|
||||
{activeView === 'blocked' && (
|
||||
<>
|
||||
{blockedLoading ? (
|
||||
<div className="text-center py-12 text-slate-500">Lade blockierte URLs...</div>
|
||||
) : blockedContent.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
|
||||
<svg className="w-12 h-12 mx-auto text-green-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine blockierten URLs</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Alle gecrawlten URLs waren in der Whitelist.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200 text-sm text-slate-600">
|
||||
{blockedTotal} blockierte URLs gesamt
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">URL</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Domain</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Grund</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Zeitpunkt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{blockedContent.map((entry) => {
|
||||
const reasonConfig = BLOCK_REASON_LABELS[entry.block_reason] || {
|
||||
label: entry.block_reason,
|
||||
color: 'bg-slate-100 text-slate-700',
|
||||
}
|
||||
return (
|
||||
<tr key={entry.id} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm text-slate-700 truncate max-w-md" title={entry.url}>
|
||||
{entry.url}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded">{entry.domain}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${reasonConfig.color}`}>
|
||||
{reasonConfig.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-slate-500">
|
||||
{formatDate(entry.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Auditor Info Box */}
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-xl p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-800">Fuer Auditoren</h3>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Dieses Audit-Log ist unveraenderlich und protokolliert alle Aenderungen an der Quellen-Policy.
|
||||
Jeder Eintrag enthaelt:
|
||||
</p>
|
||||
<ul className="text-sm text-blue-700 mt-2 list-disc list-inside">
|
||||
<li>Zeitstempel der Aenderung</li>
|
||||
<li>Art der Aenderung (Erstellen/Aendern/Loeschen)</li>
|
||||
<li>Betroffene Entitaet und ID</li>
|
||||
<li>Vorheriger und neuer Wert</li>
|
||||
<li>E-Mail des Benutzers (falls angemeldet)</li>
|
||||
</ul>
|
||||
<p className="text-sm text-blue-600 mt-2 font-medium">
|
||||
Der JSON-Export ist fuer die externe Pruefung und Archivierung geeignet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
271
admin-v2/components/sdk/source-policy/OperationsMatrixTab.tsx
Normal file
271
admin-v2/components/sdk/source-policy/OperationsMatrixTab.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface OperationPermission {
|
||||
id: string
|
||||
source_id: string
|
||||
operation: string
|
||||
is_allowed: boolean
|
||||
requires_citation: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
interface SourceWithOperations {
|
||||
id: string
|
||||
domain: string
|
||||
name: string
|
||||
license: string
|
||||
is_active: boolean
|
||||
operations: OperationPermission[]
|
||||
}
|
||||
|
||||
interface OperationsMatrixTabProps {
|
||||
apiBase: string
|
||||
}
|
||||
|
||||
const OPERATIONS = [
|
||||
{ id: 'lookup', name: 'Lookup', description: 'Inhalt anzeigen/durchsuchen', icon: '🔍' },
|
||||
{ id: 'rag', name: 'RAG', description: 'Retrieval Augmented Generation', icon: '🤖' },
|
||||
{ id: 'training', name: 'Training', description: 'KI-Training (VERBOTEN)', icon: '🚫' },
|
||||
{ id: 'export', name: 'Export', description: 'Daten exportieren', icon: '📤' },
|
||||
]
|
||||
|
||||
export function OperationsMatrixTab({ apiBase }: OperationsMatrixTabProps) {
|
||||
const [sources, setSources] = useState<SourceWithOperations[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [updating, setUpdating] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchMatrix()
|
||||
}, [])
|
||||
|
||||
const fetchMatrix = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch(`${apiBase}/v1/admin/operations-matrix`)
|
||||
if (!res.ok) throw new Error('Fehler beim Laden')
|
||||
|
||||
const data = await res.json()
|
||||
setSources(data.sources || [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePermission = async (
|
||||
source: SourceWithOperations,
|
||||
operationId: string,
|
||||
field: 'is_allowed' | 'requires_citation'
|
||||
) => {
|
||||
// Find the permission
|
||||
const permission = source.operations.find((op) => op.operation === operationId)
|
||||
if (!permission) return
|
||||
|
||||
// Block enabling training
|
||||
if (operationId === 'training' && field === 'is_allowed' && !permission.is_allowed) {
|
||||
setError('Training mit externen Daten ist VERBOTEN und kann nicht aktiviert werden.')
|
||||
return
|
||||
}
|
||||
|
||||
const updateId = `${permission.id}-${field}`
|
||||
setUpdating(updateId)
|
||||
|
||||
try {
|
||||
const newValue = !permission[field]
|
||||
const res = await fetch(`${apiBase}/v1/admin/operations/${permission.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: newValue }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json()
|
||||
throw new Error(errData.message || errData.error || 'Fehler beim Aktualisieren')
|
||||
}
|
||||
|
||||
fetchMatrix()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setUpdating(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-12 text-slate-500">Lade Operations-Matrix...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6">
|
||||
<h3 className="font-medium text-slate-900 mb-3">Legende</h3>
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-8 h-8 flex items-center justify-center bg-green-100 text-green-700 rounded">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-slate-600">Erlaubt</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-8 h-8 flex items-center justify-center bg-red-100 text-red-700 rounded">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-slate-600">Verboten</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-8 h-8 flex items-center justify-center bg-amber-100 text-amber-700 rounded text-xs">
|
||||
Cite
|
||||
</span>
|
||||
<span className="text-slate-600">Zitation erforderlich</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-8 h-8 flex items-center justify-center bg-slate-800 text-white rounded">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-slate-600">System-gesperrt (Training)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Matrix Table */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-x-auto">
|
||||
<table className="w-full min-w-[800px]">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Quelle</th>
|
||||
{OPERATIONS.map((op) => (
|
||||
<th key={op.id} className="text-center px-4 py-3">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-lg">{op.icon}</span>
|
||||
<span className="text-xs font-medium text-slate-500 uppercase">{op.name}</span>
|
||||
<span className="text-xs text-slate-400 font-normal">{op.description}</span>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{sources.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
|
||||
Keine Quellen vorhanden
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sources.map((source) => (
|
||||
<tr key={source.id} className={`hover:bg-slate-50 ${!source.is_active ? 'opacity-50' : ''}`}>
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">{source.name}</div>
|
||||
<code className="text-xs text-slate-500">{source.domain}</code>
|
||||
</div>
|
||||
</td>
|
||||
{OPERATIONS.map((op) => {
|
||||
const permission = source.operations.find((p) => p.operation === op.id)
|
||||
const isTraining = op.id === 'training'
|
||||
const isAllowed = permission?.is_allowed ?? false
|
||||
const requiresCitation = permission?.requires_citation ?? false
|
||||
const isUpdating = updating === `${permission?.id}-is_allowed` || updating === `${permission?.id}-requires_citation`
|
||||
|
||||
return (
|
||||
<td key={op.id} className="px-4 py-3 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{/* Is Allowed Toggle */}
|
||||
<button
|
||||
onClick={() => togglePermission(source, op.id, 'is_allowed')}
|
||||
disabled={isTraining || isUpdating || !source.is_active}
|
||||
className={`w-10 h-10 flex items-center justify-center rounded transition-colors ${
|
||||
isTraining
|
||||
? 'bg-slate-800 text-white cursor-not-allowed'
|
||||
: isAllowed
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
: 'bg-red-100 text-red-700 hover:bg-red-200'
|
||||
} ${isUpdating ? 'opacity-50' : ''}`}
|
||||
title={isTraining ? 'Training ist system-weit gesperrt' : isAllowed ? 'Klicken zum Deaktivieren' : 'Klicken zum Aktivieren'}
|
||||
>
|
||||
{isTraining ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
) : isAllowed ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Citation Required Toggle (only for allowed non-training ops) */}
|
||||
{isAllowed && !isTraining && (
|
||||
<button
|
||||
onClick={() => togglePermission(source, op.id, 'requires_citation')}
|
||||
disabled={isUpdating || !source.is_active}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
requiresCitation
|
||||
? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
|
||||
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
} ${isUpdating ? 'opacity-50' : ''}`}
|
||||
title={requiresCitation ? 'Zitation erforderlich - Klicken zum Aendern' : 'Klicken um Zitation zu erfordern'}
|
||||
>
|
||||
{requiresCitation ? 'Cite ✓' : 'Cite'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Training Warning */}
|
||||
<div className="mt-6 bg-red-50 border border-red-200 rounded-xl p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-red-800">Training-Operation: System-gesperrt</h3>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
Das Training von KI-Modellen mit gecrawlten externen Daten ist aufgrund von Urheberrechts- und
|
||||
Datenschutzbestimmungen grundsaetzlich verboten. Diese Einschraenkung ist im System hart kodiert
|
||||
und kann nicht ueber diese Oberflaeche geaendert werden.
|
||||
</p>
|
||||
<p className="text-sm text-red-600 mt-2 font-medium">
|
||||
Ausnahmen erfordern eine schriftliche Genehmigung des DSB und eine rechtliche Pruefung.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
562
admin-v2/components/sdk/source-policy/PIIRulesTab.tsx
Normal file
562
admin-v2/components/sdk/source-policy/PIIRulesTab.tsx
Normal file
@@ -0,0 +1,562 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface PIIRule {
|
||||
id: string
|
||||
name: string
|
||||
rule_type: string
|
||||
pattern: string
|
||||
severity: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface PIIMatch {
|
||||
rule_id: string
|
||||
rule_name: string
|
||||
rule_type: string
|
||||
severity: string
|
||||
match: string
|
||||
start_index: number
|
||||
end_index: number
|
||||
}
|
||||
|
||||
interface PIITestResult {
|
||||
has_pii: boolean
|
||||
matches: PIIMatch[]
|
||||
should_block: boolean
|
||||
block_level: string
|
||||
}
|
||||
|
||||
interface PIIRulesTabProps {
|
||||
apiBase: string
|
||||
onUpdate?: () => void
|
||||
}
|
||||
|
||||
const RULE_TYPES = [
|
||||
{ value: 'regex', label: 'Regex (Muster)' },
|
||||
{ value: 'keyword', label: 'Keyword (Stichwort)' },
|
||||
]
|
||||
|
||||
const SEVERITIES = [
|
||||
{ value: 'warn', label: 'Warnung', color: 'bg-amber-100 text-amber-700' },
|
||||
{ value: 'redact', label: 'Schwärzen', color: 'bg-orange-100 text-orange-700' },
|
||||
{ value: 'block', label: 'Blockieren', color: 'bg-red-100 text-red-700' },
|
||||
]
|
||||
|
||||
export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
|
||||
const [rules, setRules] = useState<PIIRule[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Test panel
|
||||
const [testText, setTestText] = useState('')
|
||||
const [testResult, setTestResult] = useState<PIITestResult | null>(null)
|
||||
const [testing, setTesting] = useState(false)
|
||||
|
||||
// Edit modal
|
||||
const [editingRule, setEditingRule] = useState<PIIRule | null>(null)
|
||||
const [isNewRule, setIsNewRule] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// New rule form
|
||||
const [newRule, setNewRule] = useState({
|
||||
name: '',
|
||||
rule_type: 'regex',
|
||||
pattern: '',
|
||||
severity: 'block',
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchRules()
|
||||
}, [])
|
||||
|
||||
const fetchRules = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch(`${apiBase}/v1/admin/pii-rules`)
|
||||
if (!res.ok) throw new Error('Fehler beim Laden')
|
||||
|
||||
const data = await res.json()
|
||||
setRules(data.rules || [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createRule = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
const res = await fetch(`${apiBase}/v1/admin/pii-rules`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newRule),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Fehler beim Erstellen')
|
||||
|
||||
setNewRule({
|
||||
name: '',
|
||||
rule_type: 'regex',
|
||||
pattern: '',
|
||||
severity: 'block',
|
||||
is_active: true,
|
||||
})
|
||||
setIsNewRule(false)
|
||||
fetchRules()
|
||||
onUpdate?.()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateRule = async () => {
|
||||
if (!editingRule) return
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${editingRule.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editingRule),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Fehler beim Aktualisieren')
|
||||
|
||||
setEditingRule(null)
|
||||
fetchRules()
|
||||
onUpdate?.()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteRule = async (id: string) => {
|
||||
if (!confirm('Regel wirklich loeschen? Diese Aktion wird im Audit-Log protokolliert.')) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Fehler beim Loeschen')
|
||||
|
||||
fetchRules()
|
||||
onUpdate?.()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRuleStatus = async (rule: PIIRule) => {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/v1/admin/pii-rules/${rule.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_active: !rule.is_active }),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Fehler beim Aendern des Status')
|
||||
|
||||
fetchRules()
|
||||
onUpdate?.()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
}
|
||||
}
|
||||
|
||||
const runTest = async () => {
|
||||
if (!testText) return
|
||||
|
||||
try {
|
||||
setTesting(true)
|
||||
const res = await fetch(`${apiBase}/v1/admin/pii-rules/test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: testText }),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Fehler beim Testen')
|
||||
|
||||
const data = await res.json()
|
||||
setTestResult(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getSeverityBadge = (severity: string) => {
|
||||
const config = SEVERITIES.find((s) => s.value === severity)
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${config?.color || 'bg-slate-100 text-slate-700'}`}>
|
||||
{config?.label || severity}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">PII-Test</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Testen Sie, ob ein Text personenbezogene Daten (PII) enthaelt.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
value={testText}
|
||||
onChange={(e) => setTestText(e.target.value)}
|
||||
placeholder="Geben Sie hier einen Text zum Testen ein...
|
||||
|
||||
Beispiel:
|
||||
Kontaktieren Sie mich unter max.mustermann@example.com oder
|
||||
rufen Sie mich an unter +49 170 1234567.
|
||||
Meine IBAN ist DE89 3704 0044 0532 0130 00."
|
||||
rows={6}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setTestText('')
|
||||
setTestResult(null)
|
||||
}}
|
||||
className="text-sm text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
<button
|
||||
onClick={runTest}
|
||||
disabled={testing || !testText}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{testing ? 'Teste...' : 'Testen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Test Results */}
|
||||
{testResult && (
|
||||
<div className={`mt-4 p-4 rounded-lg ${testResult.should_block ? 'bg-red-50 border border-red-200' : testResult.has_pii ? 'bg-amber-50 border border-amber-200' : 'bg-green-50 border border-green-200'}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{testResult.should_block ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="font-medium text-red-800">Blockiert - Kritische PII gefunden</span>
|
||||
</>
|
||||
) : testResult.has_pii ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="font-medium text-amber-800">Warnung - PII gefunden ({testResult.matches.length} Treffer)</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="font-medium text-green-800">Keine PII gefunden</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{testResult.matches.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{testResult.matches.map((match, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 text-sm bg-white bg-opacity-50 rounded px-3 py-2">
|
||||
{getSeverityBadge(match.severity)}
|
||||
<span className="text-slate-700 font-medium">{match.rule_name}</span>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
|
||||
{match.match.length > 30 ? match.match.substring(0, 30) + '...' : match.match}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rules List Header */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-semibold text-slate-900">PII-Erkennungsregeln</h3>
|
||||
<button
|
||||
onClick={() => setIsNewRule(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neue Regel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rules Table */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-500">Lade Regeln...</div>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
|
||||
<svg className="w-12 h-12 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Regeln vorhanden</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Fuegen Sie PII-Erkennungsregeln hinzu.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Name</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Typ</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Muster</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Severity</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Status</th>
|
||||
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rules.map((rule) => (
|
||||
<tr key={rule.id} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3 text-sm font-medium text-slate-800">{rule.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs bg-slate-100 text-slate-700 px-2 py-1 rounded">
|
||||
{rule.rule_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded max-w-xs truncate block">
|
||||
{rule.pattern.length > 40 ? rule.pattern.substring(0, 40) + '...' : rule.pattern}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">{getSeverityBadge(rule.severity)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => toggleRuleStatus(rule)}
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
rule.is_active
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{rule.is_active ? 'Aktiv' : 'Inaktiv'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => setEditingRule(rule)}
|
||||
className="text-purple-600 hover:text-purple-700 mr-3"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteRule(rule.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Rule Modal */}
|
||||
{isNewRule && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Neue PII-Regel</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRule.name}
|
||||
onChange={(e) => setNewRule({ ...newRule, name: e.target.value })}
|
||||
placeholder="z.B. Deutsche Telefonnummern"
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Typ *</label>
|
||||
<select
|
||||
value={newRule.rule_type}
|
||||
onChange={(e) => setNewRule({ ...newRule, rule_type: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
{RULE_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Muster *</label>
|
||||
<textarea
|
||||
value={newRule.pattern}
|
||||
onChange={(e) => setNewRule({ ...newRule, pattern: e.target.value })}
|
||||
placeholder={newRule.rule_type === 'regex' ? 'Regex-Muster, z.B. (?:\\+49|0)[\\s.-]?\\d{2,4}...' : 'Keywords getrennt durch Komma, z.B. password,secret,api_key'}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Severity *</label>
|
||||
<select
|
||||
value={newRule.severity}
|
||||
onChange={(e) => setNewRule({ ...newRule, severity: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
{SEVERITIES.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
|
||||
<button
|
||||
onClick={() => setIsNewRule(false)}
|
||||
className="px-4 py-2 text-slate-600 hover:text-slate-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={createRule}
|
||||
disabled={saving || !newRule.name || !newRule.pattern}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichere...' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Rule Modal */}
|
||||
{editingRule && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">PII-Regel bearbeiten</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingRule.name}
|
||||
onChange={(e) => setEditingRule({ ...editingRule, name: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={editingRule.rule_type}
|
||||
onChange={(e) => setEditingRule({ ...editingRule, rule_type: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
{RULE_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Muster *</label>
|
||||
<textarea
|
||||
value={editingRule.pattern}
|
||||
onChange={(e) => setEditingRule({ ...editingRule, pattern: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Severity</label>
|
||||
<select
|
||||
value={editingRule.severity}
|
||||
onChange={(e) => setEditingRule({ ...editingRule, severity: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
{SEVERITIES.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="edit_is_active"
|
||||
checked={editingRule.is_active}
|
||||
onChange={(e) => setEditingRule({ ...editingRule, is_active: e.target.checked })}
|
||||
className="w-4 h-4 text-purple-600"
|
||||
/>
|
||||
<label htmlFor="edit_is_active" className="text-sm text-slate-700">
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
|
||||
<button
|
||||
onClick={() => setEditingRule(null)}
|
||||
className="px-4 py-2 text-slate-600 hover:text-slate-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={updateRule}
|
||||
disabled={saving}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichere...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
525
admin-v2/components/sdk/source-policy/SourcesTab.tsx
Normal file
525
admin-v2/components/sdk/source-policy/SourcesTab.tsx
Normal file
@@ -0,0 +1,525 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface AllowedSource {
|
||||
id: string
|
||||
policy_id: string
|
||||
domain: string
|
||||
name: string
|
||||
license: string
|
||||
legal_basis?: string
|
||||
citation_template?: string
|
||||
trust_boost: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface SourcesTabProps {
|
||||
apiBase: string
|
||||
onUpdate?: () => void
|
||||
}
|
||||
|
||||
const LICENSES = [
|
||||
{ value: 'DL-DE-BY-2.0', label: 'Datenlizenz Deutschland' },
|
||||
{ value: 'CC-BY', label: 'Creative Commons BY' },
|
||||
{ value: 'CC-BY-SA', label: 'Creative Commons BY-SA' },
|
||||
{ value: 'CC0', label: 'Public Domain' },
|
||||
{ value: '§5 UrhG', label: 'Amtliche Werke (§5 UrhG)' },
|
||||
]
|
||||
|
||||
const BUNDESLAENDER = [
|
||||
{ value: '', label: 'Bundesebene' },
|
||||
{ value: 'NI', label: 'Niedersachsen' },
|
||||
{ value: 'BY', label: 'Bayern' },
|
||||
{ value: 'BW', label: 'Baden-Wuerttemberg' },
|
||||
{ value: 'NW', label: 'Nordrhein-Westfalen' },
|
||||
{ value: 'HE', label: 'Hessen' },
|
||||
{ value: 'SN', label: 'Sachsen' },
|
||||
{ value: 'BE', label: 'Berlin' },
|
||||
{ value: 'HH', label: 'Hamburg' },
|
||||
]
|
||||
|
||||
export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
|
||||
const [sources, setSources] = useState<AllowedSource[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [licenseFilter, setLicenseFilter] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
|
||||
|
||||
// Edit modal
|
||||
const [editingSource, setEditingSource] = useState<AllowedSource | null>(null)
|
||||
const [isNewSource, setIsNewSource] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// New source form
|
||||
const [newSource, setNewSource] = useState({
|
||||
domain: '',
|
||||
name: '',
|
||||
license: 'DL-DE-BY-2.0',
|
||||
legal_basis: '',
|
||||
citation_template: '',
|
||||
trust_boost: 0.5,
|
||||
is_active: true,
|
||||
policy_id: '', // Will be set from policies
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchSources()
|
||||
}, [licenseFilter, statusFilter])
|
||||
|
||||
const fetchSources = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams()
|
||||
if (licenseFilter) params.append('license', licenseFilter)
|
||||
if (statusFilter !== 'all') params.append('active_only', statusFilter === 'active' ? 'true' : 'false')
|
||||
|
||||
const res = await fetch(`${apiBase}/v1/admin/sources?${params}`)
|
||||
if (!res.ok) throw new Error('Fehler beim Laden')
|
||||
|
||||
const data = await res.json()
|
||||
setSources(data.sources || [])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createSource = async () => {
|
||||
try {
|
||||
setSaving(true)
|
||||
const res = await fetch(`${apiBase}/v1/admin/sources`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newSource),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Fehler beim Erstellen')
|
||||
|
||||
setNewSource({
|
||||
domain: '',
|
||||
name: '',
|
||||
license: 'DL-DE-BY-2.0',
|
||||
legal_basis: '',
|
||||
citation_template: '',
|
||||
trust_boost: 0.5,
|
||||
is_active: true,
|
||||
policy_id: '',
|
||||
})
|
||||
setIsNewSource(false)
|
||||
fetchSources()
|
||||
onUpdate?.()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateSource = async () => {
|
||||
if (!editingSource) return
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
const res = await fetch(`${apiBase}/v1/admin/sources/${editingSource.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editingSource),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Fehler beim Aktualisieren')
|
||||
|
||||
setEditingSource(null)
|
||||
fetchSources()
|
||||
onUpdate?.()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSource = async (id: string) => {
|
||||
if (!confirm('Quelle wirklich loeschen? Diese Aktion wird im Audit-Log protokolliert.')) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/v1/admin/sources/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Fehler beim Loeschen')
|
||||
|
||||
fetchSources()
|
||||
onUpdate?.()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSourceStatus = async (source: AllowedSource) => {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/v1/admin/sources/${source.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_active: !source.is_active }),
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Fehler beim Aendern des Status')
|
||||
|
||||
fetchSources()
|
||||
onUpdate?.()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
}
|
||||
}
|
||||
|
||||
const filteredSources = sources.filter((source) => {
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase()
|
||||
if (!source.domain.toLowerCase().includes(term) && !source.name.toLowerCase().includes(term)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters & Actions */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Domain oder Name suchen..."
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={licenseFilter}
|
||||
onChange={(e) => setLicenseFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Alle Lizenzen</option>
|
||||
{LICENSES.map((l) => (
|
||||
<option key={l.value} value={l.value}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as any)}
|
||||
className="px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Alle Status</option>
|
||||
<option value="active">Aktiv</option>
|
||||
<option value="inactive">Inaktiv</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setIsNewSource(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neue Quelle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sources Table */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-500">Lade Quellen...</div>
|
||||
) : filteredSources.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
|
||||
<svg className="w-12 h-12 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Quellen gefunden</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Fuegen Sie neue Quellen zur Whitelist hinzu.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Domain</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Name</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Lizenz</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Trust</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-slate-500 uppercase">Status</th>
|
||||
<th className="text-right px-4 py-3 text-xs font-medium text-slate-500 uppercase">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filteredSources.map((source) => (
|
||||
<tr key={source.id} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-sm bg-slate-100 px-2 py-1 rounded">{source.domain}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-700">{source.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">
|
||||
{source.license}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">
|
||||
{(source.trust_boost * 100).toFixed(0)}%
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => toggleSourceStatus(source)}
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
source.is_active
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{source.is_active ? 'Aktiv' : 'Inaktiv'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => setEditingSource(source)}
|
||||
className="text-purple-600 hover:text-purple-700 mr-3"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteSource(source.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Source Modal */}
|
||||
{isNewSource && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Neue Quelle hinzufuegen</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Domain *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSource.domain}
|
||||
onChange={(e) => setNewSource({ ...newSource, domain: e.target.value })}
|
||||
placeholder="z.B. nibis.de"
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSource.name}
|
||||
onChange={(e) => setNewSource({ ...newSource, name: e.target.value })}
|
||||
placeholder="z.B. NiBiS Bildungsserver"
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Lizenz *</label>
|
||||
<select
|
||||
value={newSource.license}
|
||||
onChange={(e) => setNewSource({ ...newSource, license: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
{LICENSES.map((l) => (
|
||||
<option key={l.value} value={l.value}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Rechtsgrundlage</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSource.legal_basis}
|
||||
onChange={(e) => setNewSource({ ...newSource, legal_basis: e.target.value })}
|
||||
placeholder="z.B. §5 UrhG (Amtliche Werke)"
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Trust Boost</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={newSource.trust_boost}
|
||||
onChange={(e) => setNewSource({ ...newSource, trust_boost: parseFloat(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-xs text-slate-500 text-right">
|
||||
{(newSource.trust_boost * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
|
||||
<button
|
||||
onClick={() => setIsNewSource(false)}
|
||||
className="px-4 py-2 text-slate-600 hover:text-slate-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={createSource}
|
||||
disabled={saving || !newSource.domain || !newSource.name}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichere...' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Source Modal */}
|
||||
{editingSource && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Quelle bearbeiten</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Domain</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSource.domain}
|
||||
disabled
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg bg-slate-50 text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSource.name}
|
||||
onChange={(e) => setEditingSource({ ...editingSource, name: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Lizenz *</label>
|
||||
<select
|
||||
value={editingSource.license}
|
||||
onChange={(e) => setEditingSource({ ...editingSource, license: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
{LICENSES.map((l) => (
|
||||
<option key={l.value} value={l.value}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Rechtsgrundlage</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSource.legal_basis || ''}
|
||||
onChange={(e) => setEditingSource({ ...editingSource, legal_basis: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Zitiervorlage</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSource.citation_template || ''}
|
||||
onChange={(e) => setEditingSource({ ...editingSource, citation_template: e.target.value })}
|
||||
placeholder="Quelle: {source}, {title}, {date}"
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Trust Boost</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={editingSource.trust_boost}
|
||||
onChange={(e) => setEditingSource({ ...editingSource, trust_boost: parseFloat(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-xs text-slate-500 text-right">
|
||||
{(editingSource.trust_boost * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
checked={editingSource.is_active}
|
||||
onChange={(e) => setEditingSource({ ...editingSource, is_active: e.target.checked })}
|
||||
className="w-4 h-4 text-purple-600"
|
||||
/>
|
||||
<label htmlFor="is_active" className="text-sm text-slate-700">
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-slate-100">
|
||||
<button
|
||||
onClick={() => setEditingSource(null)}
|
||||
className="px-4 py-2 text-slate-600 hover:text-slate-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={updateSource}
|
||||
disabled={saving}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichere...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user