feat: add frontend pages, API routes and libs for all SDK modules

Add Next.js pages for Academy, Whistleblower, Incidents, Document Crawler,
DSB Portal, Industry Templates, Multi-Tenant and SSO. Add API proxy routes
and TypeScript SDK client libraries. Add server binary to .gitignore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-14 22:07:42 +01:00
parent 85d2362724
commit f358c1d6e6
26 changed files with 13454 additions and 0 deletions

View File

@@ -0,0 +1,703 @@
'use client'
import React, { useState, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
Course,
CourseCategory,
Enrollment,
EnrollmentStatus,
AcademyStatistics,
COURSE_CATEGORY_INFO,
ENROLLMENT_STATUS_INFO,
isEnrollmentOverdue,
getDaysUntilDeadline
} from '@/lib/sdk/academy/types'
import { fetchSDKAcademyList } from '@/lib/sdk/academy/api'
// =============================================================================
// TYPES
// =============================================================================
type TabId = 'overview' | 'courses' | 'enrollments' | 'certificates' | 'settings'
interface Tab {
id: TabId
label: string
count?: number
countColor?: string
}
// =============================================================================
// COMPONENTS
// =============================================================================
function TabNavigation({
tabs,
activeTab,
onTabChange
}: {
tabs: Tab[]
activeTab: TabId
onTabChange: (tab: TabId) => void
}) {
return (
<div className="border-b border-gray-200">
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
px-4 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<span className="flex items-center gap-2">
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span className={`
px-2 py-0.5 text-xs rounded-full
${tab.countColor || 'bg-gray-100 text-gray-600'}
`}>
{tab.count}
</span>
)}
</span>
</button>
))}
</nav>
</div>
)
}
function StatCard({
label,
value,
color = 'gray',
icon,
trend
}: {
label: string
value: number | string
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple'
icon?: React.ReactNode
trend?: { value: number; label: string }
}) {
const colorClasses = {
gray: 'border-gray-200 text-gray-900',
blue: 'border-blue-200 text-blue-600',
yellow: 'border-yellow-200 text-yellow-600',
red: 'border-red-200 text-red-600',
green: 'border-green-200 text-green-600',
purple: 'border-purple-200 text-purple-600'
}
return (
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
<div className="flex items-start justify-between">
<div>
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : `text-${color}-600`}`}>
{label}
</div>
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
{value}
</div>
{trend && (
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
</div>
)}
</div>
{icon && (
<div className={`w-10 h-10 rounded-lg flex items-center justify-center bg-${color}-50`}>
{icon}
</div>
)}
</div>
</div>
)
}
function CourseCard({ course, enrollmentCount }: { course: Course; enrollmentCount: number }) {
const categoryInfo = COURSE_CATEGORY_INFO[course.category]
return (
<Link href={`/sdk/academy/${course.id}`}>
<div className={`
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
border-gray-200 hover:border-purple-300
`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Header Badges */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
{categoryInfo.label}
</span>
</div>
{/* Course Title */}
<h3 className="text-lg font-semibold text-gray-900 truncate">
{course.title}
</h3>
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
{course.description}
</p>
{/* Course Meta */}
<div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
{course.lessons.length} Lektionen
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{course.durationMinutes} Min.
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{enrollmentCount} Teilnehmer
</span>
</div>
</div>
{/* Right Side - Roles */}
<div className="text-right ml-4 text-gray-500">
<div className="text-sm font-medium">
{course.requiredForRoles.includes('all') ? 'Pflicht fuer alle' : `${course.requiredForRoles.length} Rollen`}
</div>
<div className="text-xs mt-0.5">
{new Date(course.updatedAt).toLocaleDateString('de-DE')}
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="text-sm text-gray-500">
Erstellt: {new Date(course.createdAt).toLocaleDateString('de-DE')}
</div>
<div className="flex items-center gap-2">
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Details
</span>
</div>
</div>
</div>
</Link>
)
}
function EnrollmentCard({ enrollment, courseName }: { enrollment: Enrollment; courseName: string }) {
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
const overdue = isEnrollmentOverdue(enrollment)
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
return (
<div className={`
bg-white rounded-xl border-2 p-6
${overdue ? 'border-red-300' :
enrollment.status === 'completed' ? 'border-green-200' :
enrollment.status === 'in_progress' ? 'border-yellow-200' :
'border-gray-200'
}
`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Status Badge */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
{statusInfo.label}
</span>
{overdue && (
<span className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full flex items-center gap-1">
<svg className="w-3 h-3" 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>
Ueberfaellig
</span>
)}
</div>
{/* User Info */}
<h3 className="text-lg font-semibold text-gray-900 truncate">
{enrollment.userName}
</h3>
<p className="text-sm text-gray-500">{enrollment.userEmail}</p>
<p className="text-sm text-gray-600 mt-1 font-medium">{courseName}</p>
{/* Progress Bar */}
<div className="mt-3">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-500">Fortschritt</span>
<span className="font-medium text-gray-700">{enrollment.progress}%</span>
</div>
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
enrollment.progress === 100 ? 'bg-green-500' :
overdue ? 'bg-red-500' :
'bg-purple-500'
}`}
style={{ width: `${enrollment.progress}%` }}
/>
</div>
</div>
</div>
{/* Right Side - Deadline */}
<div className={`text-right ml-4 ${
overdue ? 'text-red-600' :
daysUntil <= 7 ? 'text-orange-600' :
'text-gray-500'
}`}>
<div className="text-sm font-medium">
{enrollment.status === 'completed'
? 'Abgeschlossen'
: overdue
? `${Math.abs(daysUntil)} Tage ueberfaellig`
: `${daysUntil} Tage verbleibend`
}
</div>
<div className="text-xs mt-0.5">
Frist: {new Date(enrollment.deadline).toLocaleDateString('de-DE')}
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="text-sm text-gray-500">
Gestartet: {new Date(enrollment.startedAt).toLocaleDateString('de-DE')}
</div>
{enrollment.completedAt && (
<div className="text-sm text-green-600">
Abgeschlossen: {new Date(enrollment.completedAt).toLocaleDateString('de-DE')}
</div>
)}
</div>
</div>
)
}
function FilterBar({
selectedCategory,
selectedStatus,
onCategoryChange,
onStatusChange,
onClear
}: {
selectedCategory: CourseCategory | 'all'
selectedStatus: EnrollmentStatus | 'all'
onCategoryChange: (category: CourseCategory | 'all') => void
onStatusChange: (status: EnrollmentStatus | 'all') => void
onClear: () => void
}) {
const hasFilters = selectedCategory !== 'all' || selectedStatus !== 'all'
return (
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{/* Category Filter */}
<select
value={selectedCategory}
onChange={(e) => onCategoryChange(e.target.value as CourseCategory | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Kategorien</option>
{Object.entries(COURSE_CATEGORY_INFO).map(([cat, info]) => (
<option key={cat} value={cat}>{info.label}</option>
))}
</select>
{/* Enrollment Status Filter */}
<select
value={selectedStatus}
onChange={(e) => onStatusChange(e.target.value as EnrollmentStatus | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Status</option>
{Object.entries(ENROLLMENT_STATUS_INFO).map(([status, info]) => (
<option key={status} value={status}>{info.label}</option>
))}
</select>
{/* Clear Filters */}
{hasFilters && (
<button
onClick={onClear}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function AcademyPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [courses, setCourses] = useState<Course[]>([])
const [enrollments, setEnrollments] = useState<Enrollment[]>([])
const [statistics, setStatistics] = useState<AcademyStatistics | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Filters
const [selectedCategory, setSelectedCategory] = useState<CourseCategory | 'all'>('all')
const [selectedStatus, setSelectedStatus] = useState<EnrollmentStatus | 'all'>('all')
// Load data from SDK backend
useEffect(() => {
const loadData = async () => {
setIsLoading(true)
try {
const data = await fetchSDKAcademyList()
setCourses(data.courses)
setEnrollments(data.enrollments)
setStatistics(data.statistics)
} catch (error) {
console.error('Failed to load Academy data:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [])
// Calculate tab counts
const tabCounts = useMemo(() => {
return {
courses: courses.length,
enrollments: enrollments.filter(e => e.status !== 'completed').length,
certificates: enrollments.filter(e => e.certificateId).length,
overdue: enrollments.filter(e => isEnrollmentOverdue(e)).length
}
}, [courses, enrollments])
// Filtered courses
const filteredCourses = useMemo(() => {
let filtered = [...courses]
if (selectedCategory !== 'all') {
filtered = filtered.filter(c => c.category === selectedCategory)
}
return filtered.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
}, [courses, selectedCategory])
// Filtered enrollments
const filteredEnrollments = useMemo(() => {
let filtered = [...enrollments]
if (selectedStatus !== 'all') {
filtered = filtered.filter(e => e.status === selectedStatus)
}
// Sort: overdue first, then by deadline
return filtered.sort((a, b) => {
const aOverdue = isEnrollmentOverdue(a) ? -1 : 0
const bOverdue = isEnrollmentOverdue(b) ? -1 : 0
if (aOverdue !== bOverdue) return aOverdue - bOverdue
return getDaysUntilDeadline(a.deadline) - getDaysUntilDeadline(b.deadline)
})
}, [enrollments, selectedStatus])
// Enrollment counts per course
const enrollmentCountByCourseId = useMemo(() => {
const counts: Record<string, number> = {}
enrollments.forEach(e => {
counts[e.courseId] = (counts[e.courseId] || 0) + 1
})
return counts
}, [enrollments])
// Course name lookup
const courseNameById = useMemo(() => {
const map: Record<string, string> = {}
courses.forEach(c => { map[c.id] = c.title })
return map
}, [courses])
const tabs: Tab[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'courses', label: 'Kurse', count: tabCounts.courses, countColor: 'bg-blue-100 text-blue-600' },
{ id: 'enrollments', label: 'Einschreibungen', count: tabCounts.enrollments, countColor: 'bg-yellow-100 text-yellow-600' },
{ id: 'certificates', label: 'Zertifikate', count: tabCounts.certificates, countColor: 'bg-green-100 text-green-600' },
{ id: 'settings', label: 'Einstellungen' }
]
const stepInfo = STEP_EXPLANATIONS['academy']
const clearFilters = () => {
setSelectedCategory('all')
setSelectedStatus('all')
}
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader
stepId="academy"
title={stepInfo?.title || 'Compliance Academy'}
description={stepInfo?.description || 'E-Learning Plattform fuer Compliance-Schulungen'}
explanation={stepInfo?.explanation}
tips={stepInfo?.tips}
>
<Link
href="/sdk/academy/new"
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 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 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Kurs erstellen
</Link>
</StepHeader>
{/* Tab Navigation */}
<TabNavigation
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* Loading State */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<svg className="animate-spin w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
) : activeTab === 'settings' ? (
/* Settings Tab */
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
<p className="mt-2 text-gray-500">
Academy-Einstellungen, E-Mail-Benachrichtigungen und Kurs-Vorlagen
werden in einer spaeteren Version verfuegbar sein.
</p>
</div>
) : activeTab === 'certificates' ? (
/* Certificates Tab Placeholder */
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Zertifikate</h3>
<p className="mt-2 text-gray-500">
Zertifikate werden automatisch nach erfolgreichem Kursabschluss generiert.
Die Zertifikatsverwaltung wird in einer spaeteren Version verfuegbar sein.
</p>
{tabCounts.certificates > 0 && (
<p className="mt-2 text-sm text-purple-600 font-medium">
{tabCounts.certificates} Zertifikat(e) vorhanden
</p>
)}
</div>
) : (
<>
{/* Statistics (Overview Tab) */}
{activeTab === 'overview' && statistics && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
label="Kurse gesamt"
value={statistics.totalCourses}
color="gray"
/>
<StatCard
label="Aktive Teilnehmer"
value={statistics.byStatus.in_progress + statistics.byStatus.not_started}
color="blue"
/>
<StatCard
label="Abschlussrate"
value={`${statistics.completionRate}%`}
color="green"
/>
<StatCard
label="Ueberfaellig"
value={statistics.overdueCount}
color={statistics.overdueCount > 0 ? 'red' : 'green'}
/>
</div>
)}
{/* Overdue Alert */}
{tabCounts.overdue > 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
<div className="w-10 h-10 bg-red-100 rounded-full 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 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>
</div>
<div className="flex-1">
<h4 className="font-medium text-red-800">
Achtung: {tabCounts.overdue} ueberfaellige Schulung(en)
</h4>
<p className="text-sm text-red-600">
Mitarbeiter haben Pflichtschulungen nicht fristgerecht abgeschlossen. Handeln Sie umgehend.
</p>
</div>
<button
onClick={() => {
setActiveTab('enrollments')
setSelectedStatus('all')
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
>
Anzeigen
</button>
</div>
)}
{/* Info Box (Overview Tab) */}
{activeTab === 'overview' && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" 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>
<h4 className="font-medium text-blue-800">Schulungspflicht nach Art. 39 DSGVO</h4>
<p className="text-sm text-blue-600 mt-1">
Gemaess Art. 39 Abs. 1 lit. b DSGVO gehoert die Sensibilisierung und Schulung
der an den Verarbeitungsvorgaengen beteiligten Mitarbeiter zu den Aufgaben des
Datenschutzbeauftragten. Nachweisbare Compliance-Schulungen sind Pflicht und
sollten mindestens jaehrlich aufgefrischt werden.
</p>
</div>
</div>
</div>
)}
{/* Filters */}
<FilterBar
selectedCategory={selectedCategory}
selectedStatus={selectedStatus}
onCategoryChange={setSelectedCategory}
onStatusChange={setSelectedStatus}
onClear={clearFilters}
/>
{/* Courses Tab */}
{(activeTab === 'overview' || activeTab === 'courses') && (
<div className="space-y-4">
{activeTab === 'courses' && (
<h2 className="text-lg font-semibold text-gray-900">Kurse ({filteredCourses.length})</h2>
)}
{filteredCourses.map(course => (
<CourseCard
key={course.id}
course={course}
enrollmentCount={enrollmentCountByCourseId[course.id] || 0}
/>
))}
</div>
)}
{/* Enrollments Tab */}
{activeTab === 'enrollments' && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Einschreibungen ({filteredEnrollments.length})</h2>
{filteredEnrollments.map(enrollment => (
<EnrollmentCard
key={enrollment.id}
enrollment={enrollment}
courseName={courseNameById[enrollment.courseId] || 'Unbekannter Kurs'}
/>
))}
</div>
)}
{/* Empty States */}
{activeTab === 'courses' && filteredCourses.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Kurse gefunden</h3>
<p className="mt-2 text-gray-500">
{selectedCategory !== 'all'
? 'Passen Sie die Filter an oder'
: 'Es sind noch keine Kurse vorhanden.'
}
</p>
{selectedCategory !== 'all' ? (
<button
onClick={clearFilters}
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
) : (
<Link
href="/sdk/academy/new"
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 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 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Ersten Kurs erstellen
</Link>
)}
</div>
)}
{activeTab === 'enrollments' && filteredEnrollments.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Einschreibungen gefunden</h3>
<p className="mt-2 text-gray-500">
{selectedStatus !== 'all'
? 'Passen Sie die Filter an.'
: 'Es sind noch keine Mitarbeiter in Kurse eingeschrieben.'
}
</p>
{selectedStatus !== 'all' && (
<button
onClick={clearFilters}
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,839 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
// =============================================================================
// TYPES
// =============================================================================
interface CrawlSource {
id: string
name: string
source_type: string
path: string
file_extensions: string[]
max_depth: number
exclude_patterns: string[]
enabled: boolean
created_at: string
}
interface CrawlJob {
id: string
source_id: string
source_name?: string
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
job_type: 'full' | 'delta'
files_found: number
files_processed: number
files_new: number
files_changed: number
files_skipped: number
files_error: number
error_message?: string
started_at?: string
completed_at?: string
created_at: string
}
interface CrawlDocument {
id: string
file_name: string
file_extension: string
file_size_bytes: number
classification: string | null
classification_confidence: number | null
classification_corrected: boolean
extraction_status: string
archived: boolean
ipfs_cid: string | null
first_seen_at: string
last_seen_at: string
version_count: number
source_name?: string
}
interface OnboardingReport {
id: string
total_documents_found: number
classification_breakdown: Record<string, number>
gaps: GapItem[]
compliance_score: number
gap_summary?: { critical: number; high: number; medium: number }
created_at: string
}
interface GapItem {
id: string
category: string
description: string
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM'
regulation: string
requiredAction: string
}
// =============================================================================
// API HELPERS
// =============================================================================
const TENANT_ID = '00000000-0000-0000-0000-000000000001' // Default tenant
async function api(path: string, options: RequestInit = {}) {
const res = await fetch(`/api/sdk/v1/crawler/${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': TENANT_ID,
...options.headers,
},
})
if (res.status === 204) return null
return res.json()
}
// =============================================================================
// CLASSIFICATION LABELS
// =============================================================================
const CLASSIFICATION_LABELS: Record<string, { label: string; color: string }> = {
VVT: { label: 'VVT', color: 'bg-blue-100 text-blue-700' },
TOM: { label: 'TOM', color: 'bg-green-100 text-green-700' },
DSE: { label: 'DSE', color: 'bg-purple-100 text-purple-700' },
AVV: { label: 'AVV', color: 'bg-orange-100 text-orange-700' },
DSFA: { label: 'DSFA', color: 'bg-red-100 text-red-700' },
Loeschkonzept: { label: 'Loeschkonzept', color: 'bg-yellow-100 text-yellow-700' },
Einwilligung: { label: 'Einwilligung', color: 'bg-pink-100 text-pink-700' },
Vertrag: { label: 'Vertrag', color: 'bg-indigo-100 text-indigo-700' },
Richtlinie: { label: 'Richtlinie', color: 'bg-teal-100 text-teal-700' },
Schulungsnachweis: { label: 'Schulung', color: 'bg-cyan-100 text-cyan-700' },
Sonstiges: { label: 'Sonstiges', color: 'bg-gray-100 text-gray-700' },
}
const ALL_CLASSIFICATIONS = Object.keys(CLASSIFICATION_LABELS)
// =============================================================================
// TAB: QUELLEN (Sources)
// =============================================================================
function SourcesTab() {
const [sources, setSources] = useState<CrawlSource[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [formName, setFormName] = useState('')
const [formPath, setFormPath] = useState('')
const [testResult, setTestResult] = useState<Record<string, string>>({})
const loadSources = useCallback(async () => {
setLoading(true)
try {
const data = await api('sources')
setSources(data || [])
} catch { /* ignore */ }
setLoading(false)
}, [])
useEffect(() => { loadSources() }, [loadSources])
const handleCreate = async () => {
if (!formName || !formPath) return
await api('sources', {
method: 'POST',
body: JSON.stringify({ name: formName, path: formPath }),
})
setFormName('')
setFormPath('')
setShowForm(false)
loadSources()
}
const handleDelete = async (id: string) => {
await api(`sources/${id}`, { method: 'DELETE' })
loadSources()
}
const handleToggle = async (source: CrawlSource) => {
await api(`sources/${source.id}`, {
method: 'PUT',
body: JSON.stringify({ enabled: !source.enabled }),
})
loadSources()
}
const handleTest = async (id: string) => {
setTestResult(prev => ({ ...prev, [id]: 'testing...' }))
const result = await api(`sources/${id}/test`, { method: 'POST' })
setTestResult(prev => ({ ...prev, [id]: result?.message || 'Fehler' }))
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold text-gray-900">Crawl-Quellen</h2>
<button
onClick={() => setShowForm(!showForm)}
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700"
>
+ Neue Quelle
</button>
</div>
{showForm && (
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
value={formName}
onChange={e => setFormName(e.target.value)}
placeholder="z.B. Compliance-Ordner"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Pfad (relativ zu /data/crawl)</label>
<input
value={formPath}
onChange={e => setFormPath(e.target.value)}
placeholder="z.B. compliance-docs"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
/>
</div>
<div className="flex gap-2">
<button onClick={handleCreate} className="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700">
Erstellen
</button>
<button onClick={() => setShowForm(false)} className="px-4 py-2 text-gray-600 text-sm hover:text-gray-800">
Abbrechen
</button>
</div>
</div>
)}
{loading ? (
<div className="text-center py-12 text-gray-500">Laden...</div>
) : sources.length === 0 ? (
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
<p className="text-lg font-medium">Keine Quellen konfiguriert</p>
<p className="text-sm mt-1">Erstellen Sie eine Crawl-Quelle um Dokumente zu scannen.</p>
</div>
) : (
<div className="space-y-3">
{sources.map(s => (
<div key={s.id} className="bg-white rounded-xl border border-gray-200 p-5 flex items-center gap-4">
<div className={`w-3 h-3 rounded-full ${s.enabled ? 'bg-green-500' : 'bg-gray-300'}`} />
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900">{s.name}</div>
<div className="text-sm text-gray-500 truncate">{s.path}</div>
<div className="text-xs text-gray-400 mt-1">
Tiefe: {s.max_depth} | Formate: {(typeof s.file_extensions === 'string' ? JSON.parse(s.file_extensions) : s.file_extensions).join(', ')}
</div>
</div>
{testResult[s.id] && (
<span className="text-xs text-gray-500 bg-gray-50 px-2 py-1 rounded">{testResult[s.id]}</span>
)}
<button onClick={() => handleTest(s.id)} className="text-sm text-blue-600 hover:text-blue-800">Testen</button>
<button onClick={() => handleToggle(s)} className="text-sm text-gray-600 hover:text-gray-800">
{s.enabled ? 'Deaktivieren' : 'Aktivieren'}
</button>
<button onClick={() => handleDelete(s.id)} className="text-sm text-red-600 hover:text-red-800">Loeschen</button>
</div>
))}
</div>
)}
</div>
)
}
// =============================================================================
// TAB: CRAWL-JOBS
// =============================================================================
function JobsTab() {
const [jobs, setJobs] = useState<CrawlJob[]>([])
const [sources, setSources] = useState<CrawlSource[]>([])
const [selectedSource, setSelectedSource] = useState('')
const [jobType, setJobType] = useState<'full' | 'delta'>('full')
const [loading, setLoading] = useState(true)
const loadData = useCallback(async () => {
setLoading(true)
try {
const [j, s] = await Promise.all([api('jobs'), api('sources')])
setJobs(j || [])
setSources(s || [])
if (!selectedSource && s?.length > 0) setSelectedSource(s[0].id)
} catch { /* ignore */ }
setLoading(false)
}, [selectedSource])
useEffect(() => { loadData() }, [loadData])
// Auto-refresh running jobs
useEffect(() => {
const hasRunning = jobs.some(j => j.status === 'running' || j.status === 'pending')
if (!hasRunning) return
const interval = setInterval(loadData, 3000)
return () => clearInterval(interval)
}, [jobs, loadData])
const handleTrigger = async () => {
if (!selectedSource) return
await api('jobs', {
method: 'POST',
body: JSON.stringify({ source_id: selectedSource, job_type: jobType }),
})
loadData()
}
const handleCancel = async (id: string) => {
await api(`jobs/${id}/cancel`, { method: 'POST' })
loadData()
}
const statusColor = (s: string) => {
switch (s) {
case 'completed': return 'bg-green-100 text-green-700'
case 'running': return 'bg-blue-100 text-blue-700'
case 'pending': return 'bg-yellow-100 text-yellow-700'
case 'failed': return 'bg-red-100 text-red-700'
case 'cancelled': return 'bg-gray-100 text-gray-600'
default: return 'bg-gray-100 text-gray-700'
}
}
return (
<div className="space-y-6">
{/* Trigger form */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-medium text-gray-900 mb-4">Neuen Crawl starten</h3>
<div className="flex gap-4 items-end">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">Quelle</label>
<select
value={selectedSource}
onChange={e => setSelectedSource(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
{sources.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
<select
value={jobType}
onChange={e => setJobType(e.target.value as 'full' | 'delta')}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="full">Voll-Scan</option>
<option value="delta">Delta-Scan</option>
</select>
</div>
<button
onClick={handleTrigger}
disabled={!selectedSource}
className="px-6 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
Crawl starten
</button>
</div>
</div>
{/* Job list */}
{loading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : jobs.length === 0 ? (
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
Noch keine Crawl-Jobs ausgefuehrt.
</div>
) : (
<div className="space-y-3">
{jobs.map(job => (
<div key={job.id} className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<span className={`px-2 py-1 text-xs font-medium rounded ${statusColor(job.status)}`}>
{job.status}
</span>
<span className="text-sm font-medium text-gray-900">{job.source_name || 'Quelle'}</span>
<span className="text-xs text-gray-400">{job.job_type === 'delta' ? 'Delta' : 'Voll'}</span>
</div>
<div className="flex items-center gap-2">
{(job.status === 'running' || job.status === 'pending') && (
<button onClick={() => handleCancel(job.id)} className="text-xs text-red-600 hover:text-red-800">
Abbrechen
</button>
)}
<span className="text-xs text-gray-400">
{new Date(job.created_at).toLocaleString('de-DE')}
</span>
</div>
</div>
{/* Progress */}
{job.status === 'running' && job.files_found > 0 && (
<div className="mb-3">
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 rounded-full transition-all"
style={{ width: `${(job.files_processed / job.files_found) * 100}%` }}
/>
</div>
<div className="text-xs text-gray-500 mt-1">
{job.files_processed} / {job.files_found} Dateien verarbeitet
</div>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-6 gap-2 text-center">
<div className="bg-gray-50 rounded-lg p-2">
<div className="text-lg font-bold text-gray-900">{job.files_found}</div>
<div className="text-xs text-gray-500">Gefunden</div>
</div>
<div className="bg-gray-50 rounded-lg p-2">
<div className="text-lg font-bold text-gray-900">{job.files_processed}</div>
<div className="text-xs text-gray-500">Verarbeitet</div>
</div>
<div className="bg-green-50 rounded-lg p-2">
<div className="text-lg font-bold text-green-700">{job.files_new}</div>
<div className="text-xs text-green-600">Neu</div>
</div>
<div className="bg-blue-50 rounded-lg p-2">
<div className="text-lg font-bold text-blue-700">{job.files_changed}</div>
<div className="text-xs text-blue-600">Geaendert</div>
</div>
<div className="bg-gray-50 rounded-lg p-2">
<div className="text-lg font-bold text-gray-500">{job.files_skipped}</div>
<div className="text-xs text-gray-500">Uebersprungen</div>
</div>
<div className="bg-red-50 rounded-lg p-2">
<div className="text-lg font-bold text-red-700">{job.files_error}</div>
<div className="text-xs text-red-600">Fehler</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}
// =============================================================================
// TAB: DOKUMENTE
// =============================================================================
function DocumentsTab() {
const [docs, setDocs] = useState<CrawlDocument[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [filterClass, setFilterClass] = useState('')
const [archiving, setArchiving] = useState<Record<string, boolean>>({})
const loadDocs = useCallback(async () => {
setLoading(true)
try {
const params = filterClass ? `?classification=${filterClass}` : ''
const data = await api(`documents${params}`)
setDocs(data?.documents || [])
setTotal(data?.total || 0)
} catch { /* ignore */ }
setLoading(false)
}, [filterClass])
useEffect(() => { loadDocs() }, [loadDocs])
const handleReclassify = async (docId: string, newClass: string) => {
await api(`documents/${docId}/classify`, {
method: 'PUT',
body: JSON.stringify({ classification: newClass }),
})
loadDocs()
}
const handleArchive = async (docId: string) => {
setArchiving(prev => ({ ...prev, [docId]: true }))
try {
await api(`documents/${docId}/archive`, { method: 'POST' })
loadDocs()
} catch { /* ignore */ }
setArchiving(prev => ({ ...prev, [docId]: false }))
}
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">{total} Dokumente</h2>
<select
value={filterClass}
onChange={e => setFilterClass(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">Alle Kategorien</option>
{ALL_CLASSIFICATIONS.map(c => (
<option key={c} value={c}>{CLASSIFICATION_LABELS[c]?.label || c}</option>
))}
</select>
</div>
{loading ? (
<div className="text-center py-12 text-gray-500">Laden...</div>
) : docs.length === 0 ? (
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
Keine Dokumente gefunden. Starten Sie zuerst einen Crawl-Job.
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="text-left px-4 py-3 font-medium">Datei</th>
<th className="text-left px-4 py-3 font-medium">Kategorie</th>
<th className="text-center px-4 py-3 font-medium">Konfidenz</th>
<th className="text-right px-4 py-3 font-medium">Groesse</th>
<th className="text-center px-4 py-3 font-medium">Archiv</th>
<th className="text-right px-4 py-3 font-medium">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{docs.map(doc => {
const cls = CLASSIFICATION_LABELS[doc.classification || ''] || CLASSIFICATION_LABELS['Sonstiges']
return (
<tr key={doc.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div className="font-medium text-gray-900 truncate max-w-xs">{doc.file_name}</div>
<div className="text-xs text-gray-400">{doc.source_name}</div>
</td>
<td className="px-4 py-3">
<select
value={doc.classification || 'Sonstiges'}
onChange={e => handleReclassify(doc.id, e.target.value)}
className={`px-2 py-1 text-xs font-medium rounded border-0 ${cls.color}`}
>
{ALL_CLASSIFICATIONS.map(c => (
<option key={c} value={c}>{CLASSIFICATION_LABELS[c].label}</option>
))}
</select>
{doc.classification_corrected && (
<span className="ml-1 text-xs text-orange-500" title="Manuell korrigiert">*</span>
)}
</td>
<td className="px-4 py-3 text-center">
{doc.classification_confidence != null && (
<div className="inline-flex items-center gap-1">
<div className="w-12 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-500 rounded-full"
style={{ width: `${doc.classification_confidence * 100}%` }}
/>
</div>
<span className="text-xs text-gray-500">
{(doc.classification_confidence * 100).toFixed(0)}%
</span>
</div>
)}
</td>
<td className="px-4 py-3 text-right text-gray-500">{formatSize(doc.file_size_bytes)}</td>
<td className="px-4 py-3 text-center">
{doc.archived ? (
<span className="text-green-600 text-xs font-medium" title={doc.ipfs_cid || ''}>IPFS</span>
) : (
<span className="text-gray-400 text-xs">-</span>
)}
</td>
<td className="px-4 py-3 text-right">
{!doc.archived && (
<button
onClick={() => handleArchive(doc.id)}
disabled={archiving[doc.id]}
className="text-xs text-purple-600 hover:text-purple-800 disabled:opacity-50"
>
{archiving[doc.id] ? 'Archiviert...' : 'Archivieren'}
</button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
// =============================================================================
// TAB: ONBOARDING-REPORT
// =============================================================================
function ReportTab() {
const [reports, setReports] = useState<OnboardingReport[]>([])
const [activeReport, setActiveReport] = useState<OnboardingReport | null>(null)
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const loadReports = useCallback(async () => {
setLoading(true)
try {
const data = await api('reports')
setReports(data || [])
if (data?.length > 0 && !activeReport) {
const detail = await api(`reports/${data[0].id}`)
setActiveReport(detail)
}
} catch { /* ignore */ }
setLoading(false)
}, [activeReport])
useEffect(() => { loadReports() }, [loadReports])
const handleGenerate = async () => {
setGenerating(true)
try {
const result = await api('reports/generate', {
method: 'POST',
body: JSON.stringify({}),
})
setActiveReport(result)
loadReports()
} catch { /* ignore */ }
setGenerating(false)
}
const handleSelectReport = async (id: string) => {
const detail = await api(`reports/${id}`)
setActiveReport(detail)
}
// Compliance score ring
const ComplianceRing = ({ score }: { score: number }) => {
const radius = 50
const circumference = 2 * Math.PI * radius
const offset = circumference - (score / 100) * circumference
const color = score >= 75 ? '#16a34a' : score >= 50 ? '#f59e0b' : '#dc2626'
return (
<div className="relative w-36 h-36">
<svg className="w-full h-full -rotate-90">
<circle cx="68" cy="68" r={radius} fill="none" stroke="#e5e7eb" strokeWidth="8" />
<circle
cx="68" cy="68" r={radius} fill="none"
stroke={color} strokeWidth="8"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-1000"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-bold" style={{ color }}>{score.toFixed(0)}%</span>
<span className="text-xs text-gray-500">Compliance</span>
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Onboarding-Report</h2>
<button
onClick={handleGenerate}
disabled={generating}
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{generating ? 'Generiere...' : 'Neuen Report erstellen'}
</button>
</div>
{/* Report selector */}
{reports.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-2">
{reports.map(r => (
<button
key={r.id}
onClick={() => handleSelectReport(r.id)}
className={`px-3 py-1.5 text-xs rounded-lg border whitespace-nowrap ${
activeReport?.id === r.id
? 'bg-purple-50 border-purple-300 text-purple-700'
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
{new Date(r.created_at).toLocaleString('de-DE')} {r.compliance_score.toFixed(0)}%
</button>
))}
</div>
)}
{loading ? (
<div className="text-center py-12 text-gray-500">Laden...</div>
) : !activeReport ? (
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
<p className="text-lg font-medium">Kein Report vorhanden</p>
<p className="text-sm mt-1">Fuehren Sie zuerst einen Crawl durch und generieren Sie dann einen Report.</p>
</div>
) : (
<div className="space-y-6">
{/* Score + Stats */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center gap-8">
<ComplianceRing score={activeReport.compliance_score} />
<div className="flex-1 grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-gray-50 rounded-xl">
<div className="text-3xl font-bold text-gray-900">{activeReport.total_documents_found}</div>
<div className="text-sm text-gray-500">Dokumente gefunden</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-xl">
<div className="text-3xl font-bold text-gray-900">
{Object.keys(activeReport.classification_breakdown || {}).length}
</div>
<div className="text-sm text-gray-500">Kategorien abgedeckt</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-xl">
<div className="text-3xl font-bold text-red-600">
{(activeReport.gaps || []).length}
</div>
<div className="text-sm text-gray-500">Luecken identifiziert</div>
</div>
</div>
</div>
</div>
{/* Classification breakdown */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-medium text-gray-900 mb-4">Dokumenten-Verteilung</h3>
<div className="flex flex-wrap gap-2">
{Object.entries(activeReport.classification_breakdown || {}).map(([cat, count]) => {
const cls = CLASSIFICATION_LABELS[cat] || CLASSIFICATION_LABELS['Sonstiges']
return (
<span key={cat} className={`px-3 py-1.5 text-sm font-medium rounded-lg ${cls.color}`}>
{cls.label}: {count as number}
</span>
)
})}
{Object.keys(activeReport.classification_breakdown || {}).length === 0 && (
<span className="text-gray-400 text-sm">Keine Dokumente klassifiziert</span>
)}
</div>
</div>
{/* Gap summary */}
{activeReport.gap_summary && (
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-red-50 rounded-xl border border-red-100">
<div className="text-3xl font-bold text-red-600">{activeReport.gap_summary.critical}</div>
<div className="text-sm text-red-600 font-medium">Kritisch</div>
</div>
<div className="text-center p-4 bg-orange-50 rounded-xl border border-orange-100">
<div className="text-3xl font-bold text-orange-600">{activeReport.gap_summary.high}</div>
<div className="text-sm text-orange-600 font-medium">Hoch</div>
</div>
<div className="text-center p-4 bg-yellow-50 rounded-xl border border-yellow-100">
<div className="text-3xl font-bold text-yellow-600">{activeReport.gap_summary.medium}</div>
<div className="text-sm text-yellow-600 font-medium">Mittel</div>
</div>
</div>
)}
{/* Gap details */}
{(activeReport.gaps || []).length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-medium text-gray-900 mb-4">Compliance-Luecken</h3>
<div className="space-y-3">
{activeReport.gaps.map((gap) => (
<div
key={gap.id}
className={`p-4 rounded-lg border-l-4 ${
gap.severity === 'CRITICAL'
? 'bg-red-50 border-red-500'
: gap.severity === 'HIGH'
? 'bg-orange-50 border-orange-500'
: 'bg-yellow-50 border-yellow-500'
}`}
>
<div className="flex items-start justify-between">
<div>
<div className="font-medium text-gray-900">{gap.category}</div>
<p className="text-sm text-gray-600 mt-1">{gap.description}</p>
</div>
<span className={`px-2 py-1 text-xs font-medium rounded ${
gap.severity === 'CRITICAL' ? 'bg-red-100 text-red-700'
: gap.severity === 'HIGH' ? 'bg-orange-100 text-orange-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{gap.severity}
</span>
</div>
<div className="mt-2 text-xs text-gray-500">
Regulierung: {gap.regulation} | Aktion: {gap.requiredAction}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
type Tab = 'sources' | 'jobs' | 'documents' | 'report'
export default function DocumentCrawlerPage() {
const [activeTab, setActiveTab] = useState<Tab>('sources')
const tabs: { id: Tab; label: string }[] = [
{ id: 'sources', label: 'Quellen' },
{ id: 'jobs', label: 'Crawl-Jobs' },
{ id: 'documents', label: 'Dokumente' },
{ id: 'report', label: 'Onboarding-Report' },
]
return (
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Document Crawler & Auto-Onboarding</h1>
<p className="mt-1 text-gray-500">
Automatisches Scannen von Dateisystemen, KI-Klassifizierung, IPFS-Archivierung und Compliance Gap-Analyse.
</p>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="flex gap-6">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab content */}
{activeTab === 'sources' && <SourcesTab />}
{activeTab === 'jobs' && <JobsTab />}
{activeTab === 'documents' && <DocumentsTab />}
{activeTab === 'report' && <ReportTab />}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,706 @@
'use client'
import React, { useState, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
Incident,
IncidentSeverity,
IncidentStatus,
IncidentCategory,
IncidentStatistics,
INCIDENT_SEVERITY_INFO,
INCIDENT_STATUS_INFO,
INCIDENT_CATEGORY_INFO,
getHoursUntil72hDeadline,
is72hDeadlineExpired
} from '@/lib/sdk/incidents/types'
import { fetchSDKIncidentList, createMockIncidents, createMockStatistics } from '@/lib/sdk/incidents/api'
// =============================================================================
// TYPES
// =============================================================================
type TabId = 'overview' | 'active' | 'notification' | 'closed' | 'settings'
interface Tab {
id: TabId
label: string
count?: number
countColor?: string
}
// =============================================================================
// COMPONENTS
// =============================================================================
function TabNavigation({
tabs,
activeTab,
onTabChange
}: {
tabs: Tab[]
activeTab: TabId
onTabChange: (tab: TabId) => void
}) {
return (
<div className="border-b border-gray-200">
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
px-4 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<span className="flex items-center gap-2">
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span className={`
px-2 py-0.5 text-xs rounded-full
${tab.countColor || 'bg-gray-100 text-gray-600'}
`}>
{tab.count}
</span>
)}
</span>
</button>
))}
</nav>
</div>
)
}
function StatCard({
label,
value,
color = 'gray',
icon,
trend
}: {
label: string
value: number | string
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple' | 'orange'
icon?: React.ReactNode
trend?: { value: number; label: string }
}) {
const colorClasses: Record<string, string> = {
gray: 'border-gray-200 text-gray-900',
blue: 'border-blue-200 text-blue-600',
yellow: 'border-yellow-200 text-yellow-600',
red: 'border-red-200 text-red-600',
green: 'border-green-200 text-green-600',
purple: 'border-purple-200 text-purple-600',
orange: 'border-orange-200 text-orange-600'
}
return (
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
<div className="flex items-start justify-between">
<div>
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : ''}`}>
{label}
</div>
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
{value}
</div>
{trend && (
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
</div>
)}
</div>
{icon && (
<div className="w-10 h-10 rounded-lg flex items-center justify-center bg-gray-50">
{icon}
</div>
)}
</div>
</div>
)
}
function FilterBar({
selectedSeverity,
selectedStatus,
selectedCategory,
onSeverityChange,
onStatusChange,
onCategoryChange,
onClear
}: {
selectedSeverity: IncidentSeverity | 'all'
selectedStatus: IncidentStatus | 'all'
selectedCategory: IncidentCategory | 'all'
onSeverityChange: (severity: IncidentSeverity | 'all') => void
onStatusChange: (status: IncidentStatus | 'all') => void
onCategoryChange: (category: IncidentCategory | 'all') => void
onClear: () => void
}) {
const hasFilters = selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all'
return (
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{/* Severity Filter */}
<select
value={selectedSeverity}
onChange={(e) => onSeverityChange(e.target.value as IncidentSeverity | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Schweregrade</option>
{Object.entries(INCIDENT_SEVERITY_INFO).map(([severity, info]) => (
<option key={severity} value={severity}>{info.label}</option>
))}
</select>
{/* Status Filter */}
<select
value={selectedStatus}
onChange={(e) => onStatusChange(e.target.value as IncidentStatus | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Status</option>
{Object.entries(INCIDENT_STATUS_INFO).map(([status, info]) => (
<option key={status} value={status}>{info.label}</option>
))}
</select>
{/* Category Filter */}
<select
value={selectedCategory}
onChange={(e) => onCategoryChange(e.target.value as IncidentCategory | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Kategorien</option>
{Object.entries(INCIDENT_CATEGORY_INFO).map(([cat, info]) => (
<option key={cat} value={cat}>{info.label}</option>
))}
</select>
{/* Clear Filters */}
{hasFilters && (
<button
onClick={onClear}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)
}
/**
* 72h-Countdown-Anzeige mit visueller Farbkodierung
* Gruen > 48h, Gelb > 24h, Orange > 12h, Rot < 12h oder abgelaufen
*/
function CountdownTimer({ incident }: { incident: Incident }) {
const hoursRemaining = getHoursUntil72hDeadline(incident.detectedAt)
const expired = is72hDeadlineExpired(incident.detectedAt)
// Nicht relevant fuer abgeschlossene Vorfaelle
if (incident.status === 'closed') return null
// Bereits gemeldet
if (incident.authorityNotification && (incident.authorityNotification.status === 'submitted' || incident.authorityNotification.status === 'acknowledged')) {
return (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Gemeldet
</span>
)
}
// Keine Meldepflicht festgestellt
if (incident.riskAssessment && !incident.riskAssessment.notificationRequired) {
return (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded-full">
Keine Meldepflicht
</span>
)
}
// Abgelaufen
if (expired) {
const overdueHours = Math.abs(hoursRemaining)
return (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-bold bg-red-100 text-red-700 rounded-full animate-pulse">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{overdueHours.toFixed(0)}h ueberfaellig
</span>
)
}
// Farbkodierung: gruen > 48h, gelb > 24h, orange > 12h, rot < 12h
let colorClass: string
if (hoursRemaining > 48) {
colorClass = 'bg-green-100 text-green-700'
} else if (hoursRemaining > 24) {
colorClass = 'bg-yellow-100 text-yellow-700'
} else if (hoursRemaining > 12) {
colorClass = 'bg-orange-100 text-orange-700'
} else {
colorClass = 'bg-red-100 text-red-700'
}
return (
<span className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-bold rounded-full ${colorClass}`}>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{hoursRemaining.toFixed(0)}h verbleibend
</span>
)
}
function Badge({ bgColor, color, label }: { bgColor: string; color: string; label: string }) {
return <span className={`px-2 py-1 text-xs rounded-full ${bgColor} ${color}`}>{label}</span>
}
function IncidentCard({ incident }: { incident: Incident }) {
const severityInfo = INCIDENT_SEVERITY_INFO[incident.severity]
const statusInfo = INCIDENT_STATUS_INFO[incident.status]
const categoryInfo = INCIDENT_CATEGORY_INFO[incident.category]
const expired = is72hDeadlineExpired(incident.detectedAt)
const isNotified = incident.authorityNotification && (incident.authorityNotification.status === 'submitted' || incident.authorityNotification.status === 'acknowledged')
const severityBorderColors: Record<IncidentSeverity, string> = {
critical: 'border-red-300 hover:border-red-400',
high: 'border-orange-300 hover:border-orange-400',
medium: 'border-yellow-300 hover:border-yellow-400',
low: 'border-green-200 hover:border-green-300'
}
const borderColor = incident.status === 'closed'
? 'border-green-200 hover:border-green-300'
: expired && !isNotified
? 'border-red-400 hover:border-red-500'
: severityBorderColors[incident.severity]
const measuresCount = incident.measures.length
const completedMeasures = incident.measures.filter(m => m.status === 'completed').length
return (
<Link href={`/sdk/incidents/${incident.id}`}>
<div className={`
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
${borderColor}
`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Header Badges */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-xs text-gray-500 font-mono">
{incident.referenceNumber}
</span>
<Badge bgColor={severityInfo.bgColor} color={severityInfo.color} label={severityInfo.label} />
<Badge bgColor={categoryInfo.bgColor} color={categoryInfo.color} label={`${categoryInfo.icon} ${categoryInfo.label}`} />
<Badge bgColor={statusInfo.bgColor} color={statusInfo.color} label={statusInfo.label} />
</div>
{/* Title */}
<h3 className="text-lg font-semibold text-gray-900 truncate">
{incident.title}
</h3>
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
{incident.description}
</p>
{/* 72h Countdown - prominent */}
<div className="mt-3">
<CountdownTimer incident={incident} />
</div>
</div>
{/* Right Side - Key Numbers */}
<div className="text-right ml-4 flex-shrink-0">
<div className="text-sm text-gray-500">
Betroffene
</div>
<div className="text-xl font-bold text-gray-900">
{incident.estimatedAffectedPersons.toLocaleString('de-DE')}
</div>
<div className="text-xs text-gray-400 mt-1">
{new Date(incident.detectedAt).toLocaleDateString('de-DE')}
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-4 text-sm text-gray-500">
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
{completedMeasures}/{measuresCount} Massnahmen
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{incident.timeline.length} Eintraege
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">
{incident.assignedTo
? `Zugewiesen: ${incident.assignedTo}`
: 'Nicht zugewiesen'
}
</span>
{incident.status !== 'closed' ? (
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Bearbeiten
</span>
) : (
<span className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Details
</span>
)}
</div>
</div>
</div>
</Link>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function IncidentsPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [incidents, setIncidents] = useState<Incident[]>([])
const [statistics, setStatistics] = useState<IncidentStatistics | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Filters
const [selectedSeverity, setSelectedSeverity] = useState<IncidentSeverity | 'all'>('all')
const [selectedStatus, setSelectedStatus] = useState<IncidentStatus | 'all'>('all')
const [selectedCategory, setSelectedCategory] = useState<IncidentCategory | 'all'>('all')
// Load data
useEffect(() => {
const loadData = async () => {
setIsLoading(true)
try {
const { incidents: loadedIncidents, statistics: loadedStats } = await fetchSDKIncidentList()
setIncidents(loadedIncidents)
setStatistics(loadedStats)
} catch (error) {
console.error('Fehler beim Laden der Incident-Daten:', error)
// Fallback auf Mock-Daten
setIncidents(createMockIncidents())
setStatistics(createMockStatistics())
} finally {
setIsLoading(false)
}
}
loadData()
}, [])
// Calculate tab counts
const tabCounts = useMemo(() => {
return {
active: incidents.filter(i =>
i.status === 'detected' || i.status === 'assessment' ||
i.status === 'containment' || i.status === 'remediation'
).length,
notification: incidents.filter(i =>
i.status === 'notification_required' || i.status === 'notification_sent' ||
(i.authorityNotification !== null && i.authorityNotification.status === 'pending')
).length,
closed: incidents.filter(i => i.status === 'closed').length,
deadlineExpired: incidents.filter(i => {
if (i.status === 'closed') return false
if (i.authorityNotification && (i.authorityNotification.status === 'submitted' || i.authorityNotification.status === 'acknowledged')) return false
if (i.riskAssessment && !i.riskAssessment.notificationRequired) return false
return is72hDeadlineExpired(i.detectedAt)
}).length,
deadlineApproaching: incidents.filter(i => {
if (i.status === 'closed') return false
if (i.authorityNotification && (i.authorityNotification.status === 'submitted' || i.authorityNotification.status === 'acknowledged')) return false
const hours = getHoursUntil72hDeadline(i.detectedAt)
return hours > 0 && hours <= 24
}).length
}
}, [incidents])
// Filter incidents based on active tab and filters
const filteredIncidents = useMemo(() => {
let filtered = [...incidents]
// Tab-based filtering
if (activeTab === 'active') {
filtered = filtered.filter(i =>
i.status === 'detected' || i.status === 'assessment' ||
i.status === 'containment' || i.status === 'remediation'
)
} else if (activeTab === 'notification') {
filtered = filtered.filter(i =>
i.status === 'notification_required' || i.status === 'notification_sent' ||
(i.authorityNotification !== null && i.authorityNotification.status === 'pending')
)
} else if (activeTab === 'closed') {
filtered = filtered.filter(i => i.status === 'closed')
}
// Severity filter
if (selectedSeverity !== 'all') {
filtered = filtered.filter(i => i.severity === selectedSeverity)
}
// Status filter
if (selectedStatus !== 'all') {
filtered = filtered.filter(i => i.status === selectedStatus)
}
// Category filter
if (selectedCategory !== 'all') {
filtered = filtered.filter(i => i.category === selectedCategory)
}
// Sort: most urgent first (overdue > deadline approaching > severity > detected time)
const severityOrder: Record<IncidentSeverity, number> = { critical: 0, high: 1, medium: 2, low: 3 }
return filtered.sort((a, b) => {
// Closed always at the end
if (a.status === 'closed' !== (b.status === 'closed')) return a.status === 'closed' ? 1 : -1
// Overdue first
const aExpired = is72hDeadlineExpired(a.detectedAt)
const bExpired = is72hDeadlineExpired(b.detectedAt)
if (aExpired !== bExpired) return aExpired ? -1 : 1
// Then by severity
if (severityOrder[a.severity] !== severityOrder[b.severity]) {
return severityOrder[a.severity] - severityOrder[b.severity]
}
// Then by deadline urgency
return getHoursUntil72hDeadline(a.detectedAt) - getHoursUntil72hDeadline(b.detectedAt)
})
}, [incidents, activeTab, selectedSeverity, selectedStatus, selectedCategory])
const tabs: Tab[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'active', label: 'Aktiv', count: tabCounts.active, countColor: 'bg-orange-100 text-orange-600' },
{ id: 'notification', label: 'Meldepflichtig', count: tabCounts.notification, countColor: 'bg-red-100 text-red-600' },
{ id: 'closed', label: 'Abgeschlossen', count: tabCounts.closed, countColor: 'bg-green-100 text-green-600' },
{ id: 'settings', label: 'Einstellungen' }
]
const stepInfo = STEP_EXPLANATIONS['incidents']
const clearFilters = () => {
setSelectedSeverity('all')
setSelectedStatus('all')
setSelectedCategory('all')
}
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader
stepId="incidents"
title={stepInfo.title}
description={stepInfo.description}
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
<Link
href="/sdk/incidents/new"
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 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 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Vorfall melden
</Link>
</StepHeader>
{/* Tab Navigation */}
<TabNavigation
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* Loading State */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<svg className="animate-spin w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
) : activeTab === 'settings' ? (
/* Settings Tab */
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
<p className="mt-2 text-gray-500">
Incident-Management-Einstellungen, Eskalationswege und Meldevorlagen
werden in einer spaeteren Version verfuegbar sein.
</p>
</div>
) : (
<>
{/* Statistics (Overview Tab) */}
{activeTab === 'overview' && statistics && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
label="Gesamt Vorfaelle"
value={statistics.totalIncidents}
color="gray"
/>
<StatCard
label="Offene Vorfaelle"
value={statistics.openIncidents}
color="orange"
/>
<StatCard
label="Meldungen ausstehend"
value={statistics.notificationsPending}
color={statistics.notificationsPending > 0 ? 'red' : 'green'}
/>
<StatCard
label="Durchschn. Reaktionszeit"
value={`${statistics.averageResponseTimeHours}h`}
color="purple"
/>
</div>
)}
{/* Critical Alert: 72h deadline approaching or expired */}
{(tabCounts.deadlineExpired > 0 || tabCounts.deadlineApproaching > 0) && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
<div className="w-10 h-10 bg-red-100 rounded-full 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 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>
</div>
<div className="flex-1">
<h4 className="font-medium text-red-800">
{tabCounts.deadlineExpired > 0
? `Achtung: ${tabCounts.deadlineExpired} ueberfaellige Meldung(en) - 72-Stunden-Frist ueberschritten!`
: `Warnung: ${tabCounts.deadlineApproaching} Meldung(en) mit ablaufender 72-Stunden-Frist`
}
</h4>
<p className="text-sm text-red-600">
{tabCounts.deadlineExpired > 0
? 'Die gesetzliche Meldefrist nach Art. 33 DSGVO ist abgelaufen. Handeln Sie umgehend, um Bussgelder zu vermeiden. Verspaetete Meldungen muessen begruendet werden.'
: 'Die 72-Stunden-Meldefrist nach Art. 33 DSGVO laeuft in Kuerze ab. Fuehren Sie eine Risikobewertung durch und entscheiden Sie ueber die Meldepflicht.'
}
</p>
</div>
<button
onClick={() => {
setActiveTab('active')
setSelectedStatus('all')
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
>
Anzeigen
</button>
</div>
)}
{/* Info Box (Overview Tab) */}
{activeTab === 'overview' && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" 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>
<h4 className="font-medium text-blue-800">Art. 33/34 DSGVO - 72-Stunden-Meldepflicht</h4>
<p className="text-sm text-blue-600 mt-1">
Nach Art. 33 DSGVO muessen Datenschutzverletzungen innerhalb von 72 Stunden
an die zustaendige Aufsichtsbehoerde gemeldet werden, sofern ein Risiko fuer
die Rechte und Freiheiten der betroffenen Personen besteht. Bei hohem Risiko
muessen gemaess Art. 34 DSGVO auch die betroffenen Personen benachrichtigt werden.
Alle Vorfaelle sind unabhaengig von der Meldepflicht zu dokumentieren (Art. 33 Abs. 5).
</p>
</div>
</div>
</div>
)}
{/* Filters */}
<FilterBar
selectedSeverity={selectedSeverity}
selectedStatus={selectedStatus}
selectedCategory={selectedCategory}
onSeverityChange={setSelectedSeverity}
onStatusChange={setSelectedStatus}
onCategoryChange={setSelectedCategory}
onClear={clearFilters}
/>
{/* Incidents List */}
<div className="space-y-4">
{filteredIncidents.map(incident => (
<IncidentCard key={incident.id} incident={incident} />
))}
</div>
{/* Empty State */}
{filteredIncidents.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Vorfaelle gefunden</h3>
<p className="mt-2 text-gray-500">
{selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all'
? 'Passen Sie die Filter an oder'
: 'Es sind noch keine Vorfaelle erfasst worden.'
}
</p>
{(selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all') ? (
<button
onClick={clearFilters}
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
) : (
<Link
href="/sdk/incidents/new"
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 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 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Ersten Vorfall erfassen
</Link>
)}
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,879 @@
'use client'
/**
* Branchenspezifische Module (Phase 3.3)
*
* Industry-specific compliance template packages:
* - Browse industry templates (grid view)
* - View full detail with VVT, TOM, Risk tabs
* - Apply template packages to current compliance setup
*/
import { useState, useEffect, useCallback } from 'react'
// =============================================================================
// TYPES
// =============================================================================
interface IndustrySummary {
slug: string
name: string
description: string
icon: string
regulation_count: number
template_count: number
}
interface IndustryTemplate {
slug: string
name: string
description: string
icon: string
regulations: string[]
vvt_templates: VVTTemplate[]
tom_recommendations: TOMRecommendation[]
risk_scenarios: RiskScenario[]
}
interface VVTTemplate {
name: string
purpose: string
legal_basis: string
data_categories: string[]
data_subjects: string[]
retention_period: string
}
interface TOMRecommendation {
category: string
name: string
description: string
priority: string
}
interface RiskScenario {
name: string
description: string
likelihood: string
impact: string
mitigation: string
}
// =============================================================================
// CONSTANTS
// =============================================================================
type DetailTab = 'vvt' | 'tom' | 'risks'
const DETAIL_TABS: { key: DetailTab; label: string }[] = [
{ key: 'vvt', label: 'VVT-Vorlagen' },
{ key: 'tom', label: 'TOM-Empfehlungen' },
{ key: 'risks', label: 'Risiko-Szenarien' },
]
const PRIORITY_COLORS: Record<string, { bg: string; text: string; border: string }> = {
critical: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200' },
high: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200' },
medium: { bg: 'bg-yellow-50', text: 'text-yellow-700', border: 'border-yellow-200' },
low: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-200' },
}
const PRIORITY_LABELS: Record<string, string> = {
critical: 'Kritisch',
high: 'Hoch',
medium: 'Mittel',
low: 'Niedrig',
}
const LIKELIHOOD_COLORS: Record<string, string> = {
low: 'bg-green-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
}
const IMPACT_COLORS: Record<string, string> = {
low: 'bg-green-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
critical: 'bg-red-600',
}
const TOM_CATEGORY_ICONS: Record<string, string> = {
'Zutrittskontrolle': '\uD83D\uDEAA',
'Zugangskontrolle': '\uD83D\uDD10',
'Zugriffskontrolle': '\uD83D\uDC65',
'Trennungskontrolle': '\uD83D\uDDC2\uFE0F',
'Pseudonymisierung': '\uD83C\uDFAD',
'Verschluesselung': '\uD83D\uDD12',
'Integritaet': '\u2705',
'Verfuegbarkeit': '\u2B06\uFE0F',
'Belastbarkeit': '\uD83D\uDEE1\uFE0F',
'Wiederherstellung': '\uD83D\uDD04',
'Datenschutz-Management': '\uD83D\uDCCB',
'Auftragsverarbeitung': '\uD83D\uDCDD',
'Incident Response': '\uD83D\uDEA8',
'Schulung': '\uD83C\uDF93',
'Netzwerksicherheit': '\uD83C\uDF10',
'Datensicherung': '\uD83D\uDCBE',
'Monitoring': '\uD83D\uDCCA',
'Physische Sicherheit': '\uD83C\uDFE2',
}
// =============================================================================
// SKELETON COMPONENTS
// =============================================================================
function GridSkeleton() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-white rounded-xl border border-slate-200 p-6 animate-pulse">
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-xl bg-slate-200" />
<div className="flex-1 space-y-3">
<div className="h-5 bg-slate-200 rounded w-2/3" />
<div className="h-4 bg-slate-100 rounded w-full" />
<div className="h-4 bg-slate-100 rounded w-4/5" />
</div>
</div>
<div className="flex gap-3 mt-5">
<div className="h-6 bg-slate-100 rounded-full w-28" />
<div className="h-6 bg-slate-100 rounded-full w-24" />
</div>
</div>
))}
</div>
)
}
function DetailSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="bg-white rounded-xl border p-6 space-y-4">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-xl bg-slate-200" />
<div className="space-y-2 flex-1">
<div className="h-6 bg-slate-200 rounded w-1/3" />
<div className="h-4 bg-slate-100 rounded w-2/3" />
</div>
</div>
<div className="flex gap-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-7 bg-slate-100 rounded-full w-20" />
))}
</div>
</div>
<div className="bg-white rounded-xl border p-6 space-y-4">
<div className="flex gap-2 border-b pb-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-9 bg-slate-100 rounded-lg w-32" />
))}
</div>
{[1, 2, 3].map((i) => (
<div key={i} className="h-28 bg-slate-50 rounded-lg" />
))}
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE COMPONENT
// =============================================================================
export default function IndustryTemplatesPage() {
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const [industries, setIndustries] = useState<IndustrySummary[]>([])
const [selectedDetail, setSelectedDetail] = useState<IndustryTemplate | null>(null)
const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<DetailTab>('vvt')
const [loading, setLoading] = useState(true)
const [detailLoading, setDetailLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [detailError, setDetailError] = useState<string | null>(null)
const [applying, setApplying] = useState(false)
const [toastMessage, setToastMessage] = useState<string | null>(null)
// ---------------------------------------------------------------------------
// Data fetching
// ---------------------------------------------------------------------------
const loadIndustries = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/sdk/v1/industry/templates')
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
const data = await res.json()
setIndustries(Array.isArray(data) ? data : data.industries || data.templates || [])
} catch (err) {
console.error('Failed to load industries:', err)
setError('Branchenvorlagen konnten nicht geladen werden. Bitte pruefen Sie die Verbindung zum Backend.')
} finally {
setLoading(false)
}
}, [])
const loadDetail = useCallback(async (slug: string) => {
setDetailLoading(true)
setDetailError(null)
setSelectedSlug(slug)
setActiveTab('vvt')
try {
const [detailRes, vvtRes, tomRes, risksRes] = await Promise.all([
fetch(`/api/sdk/v1/industry/templates/${slug}`),
fetch(`/api/sdk/v1/industry/templates/${slug}/vvt`),
fetch(`/api/sdk/v1/industry/templates/${slug}/tom`),
fetch(`/api/sdk/v1/industry/templates/${slug}/risks`),
])
if (!detailRes.ok) {
throw new Error(`HTTP ${detailRes.status}: ${detailRes.statusText}`)
}
const detail: IndustryTemplate = await detailRes.json()
// Merge sub-resources if the detail endpoint did not include them
if (vvtRes.ok) {
const vvtData = await vvtRes.json()
detail.vvt_templates = vvtData.vvt_templates || vvtData.templates || vvtData || []
}
if (tomRes.ok) {
const tomData = await tomRes.json()
detail.tom_recommendations = tomData.tom_recommendations || tomData.recommendations || tomData || []
}
if (risksRes.ok) {
const risksData = await risksRes.json()
detail.risk_scenarios = risksData.risk_scenarios || risksData.scenarios || risksData || []
}
setSelectedDetail(detail)
} catch (err) {
console.error('Failed to load industry detail:', err)
setDetailError('Details konnten nicht geladen werden. Bitte versuchen Sie es erneut.')
} finally {
setDetailLoading(false)
}
}, [])
useEffect(() => {
loadIndustries()
}, [loadIndustries])
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
const handleBackToGrid = useCallback(() => {
setSelectedSlug(null)
setSelectedDetail(null)
setDetailError(null)
}, [])
const handleApplyPackage = useCallback(async () => {
if (!selectedDetail) return
setApplying(true)
try {
// Placeholder: In production this would POST to an import endpoint
await new Promise((resolve) => setTimeout(resolve, 1500))
setToastMessage(
`Branchenpaket "${selectedDetail.name}" wurde erfolgreich angewendet. ` +
`${selectedDetail.vvt_templates?.length || 0} VVT-Vorlagen, ` +
`${selectedDetail.tom_recommendations?.length || 0} TOM-Empfehlungen und ` +
`${selectedDetail.risk_scenarios?.length || 0} Risiko-Szenarien wurden importiert.`
)
} catch {
setToastMessage('Fehler beim Anwenden des Branchenpakets. Bitte versuchen Sie es erneut.')
} finally {
setApplying(false)
}
}, [selectedDetail])
// Auto-dismiss toast
useEffect(() => {
if (!toastMessage) return
const timer = setTimeout(() => setToastMessage(null), 6000)
return () => clearTimeout(timer)
}, [toastMessage])
// ---------------------------------------------------------------------------
// Render: Header
// ---------------------------------------------------------------------------
const renderHeader = () => (
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white text-lg">
{'\uD83C\uDFED'}
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900">Branchenspezifische Module</h1>
<p className="text-slate-500 mt-0.5">
Vorkonfigurierte Compliance-Pakete nach Branche
</p>
</div>
</div>
</div>
)
// ---------------------------------------------------------------------------
// Render: Error
// ---------------------------------------------------------------------------
const renderError = (message: string, onRetry: () => void) => (
<div className="bg-red-50 border border-red-200 rounded-xl p-5 flex items-start gap-3">
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="flex-1">
<p className="text-red-700 font-medium">Fehler</p>
<p className="text-red-600 text-sm mt-1">{message}</p>
</div>
<button
onClick={onRetry}
className="px-4 py-1.5 text-sm font-medium text-red-700 bg-red-100 hover:bg-red-200 rounded-lg transition-colors"
>
Erneut versuchen
</button>
</div>
)
// ---------------------------------------------------------------------------
// Render: Industry Grid
// ---------------------------------------------------------------------------
const renderGrid = () => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{industries.map((industry) => (
<button
key={industry.slug}
onClick={() => loadDetail(industry.slug)}
className="bg-white rounded-xl border border-slate-200 p-6 text-left hover:border-emerald-300 hover:shadow-md transition-all duration-200 group"
>
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-100 flex items-center justify-center text-3xl flex-shrink-0 group-hover:from-emerald-100 group-hover:to-teal-100 transition-colors">
{industry.icon}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-slate-900 group-hover:text-emerald-700 transition-colors">
{industry.name}
</h3>
<p className="text-sm text-slate-500 mt-1 line-clamp-2">
{industry.description}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 mt-4">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-100">
<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 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>
{industry.regulation_count} Regulierungen
</span>
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-teal-50 text-teal-700 border border-teal-100">
<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="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
{industry.template_count} Vorlagen
</span>
</div>
</button>
))}
</div>
)
// ---------------------------------------------------------------------------
// Render: Detail View - Header
// ---------------------------------------------------------------------------
const renderDetailHeader = () => {
if (!selectedDetail) return null
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<button
onClick={handleBackToGrid}
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-emerald-600 transition-colors mb-4"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurueck zur Uebersicht
</button>
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-100 flex items-center justify-center text-4xl flex-shrink-0">
{selectedDetail.icon}
</div>
<div className="flex-1">
<h2 className="text-xl font-bold text-slate-900">{selectedDetail.name}</h2>
<p className="text-slate-500 mt-1">{selectedDetail.description}</p>
</div>
</div>
{/* Regulation Badges */}
{selectedDetail.regulations && selectedDetail.regulations.length > 0 && (
<div className="mt-4">
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-2">
Relevante Regulierungen
</p>
<div className="flex flex-wrap gap-2">
{selectedDetail.regulations.map((reg) => (
<span
key={reg}
className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-200"
>
{reg}
</span>
))}
</div>
</div>
)}
{/* Summary stats */}
<div className="grid grid-cols-3 gap-4 mt-5 pt-5 border-t border-slate-100">
<div className="text-center">
<p className="text-2xl font-bold text-emerald-600">
{selectedDetail.vvt_templates?.length || 0}
</p>
<p className="text-xs text-slate-500 mt-0.5">VVT-Vorlagen</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-teal-600">
{selectedDetail.tom_recommendations?.length || 0}
</p>
<p className="text-xs text-slate-500 mt-0.5">TOM-Empfehlungen</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-amber-600">
{selectedDetail.risk_scenarios?.length || 0}
</p>
<p className="text-xs text-slate-500 mt-0.5">Risiko-Szenarien</p>
</div>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Render: VVT Tab
// ---------------------------------------------------------------------------
const renderVVTTab = () => {
const templates = selectedDetail?.vvt_templates || []
if (templates.length === 0) {
return (
<div className="text-center py-12 text-slate-400">
<p className="text-lg">Keine VVT-Vorlagen verfuegbar</p>
<p className="text-sm mt-1">Fuer diese Branche wurden noch keine Verarbeitungsvorlagen definiert.</p>
</div>
)
}
return (
<div className="space-y-4">
{templates.map((vvt, idx) => (
<div
key={idx}
className="bg-white border border-slate-200 rounded-lg p-5 hover:border-emerald-200 transition-colors"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h4 className="font-semibold text-slate-900">{vvt.name}</h4>
<p className="text-sm text-slate-500 mt-1">{vvt.purpose}</p>
</div>
<span className="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-slate-100 text-slate-600 flex-shrink-0">
{vvt.retention_period}
</span>
</div>
<div className="mt-3 pt-3 border-t border-slate-100">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Legal Basis */}
<div>
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">Rechtsgrundlage</p>
<p className="text-sm text-slate-700">{vvt.legal_basis}</p>
</div>
{/* Retention Period (mobile only, since shown in badge on desktop) */}
<div className="sm:hidden">
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">Aufbewahrungsfrist</p>
<p className="text-sm text-slate-700">{vvt.retention_period}</p>
</div>
{/* Data Categories */}
<div>
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">Datenkategorien</p>
<div className="flex flex-wrap gap-1.5">
{vvt.data_categories.map((cat) => (
<span
key={cat}
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-emerald-50 text-emerald-700 border border-emerald-100"
>
{cat}
</span>
))}
</div>
</div>
{/* Data Subjects */}
<div>
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">Betroffene</p>
<div className="flex flex-wrap gap-1.5">
{vvt.data_subjects.map((sub) => (
<span
key={sub}
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-teal-50 text-teal-700 border border-teal-100"
>
{sub}
</span>
))}
</div>
</div>
</div>
</div>
</div>
))}
</div>
)
}
// ---------------------------------------------------------------------------
// Render: TOM Tab
// ---------------------------------------------------------------------------
const renderTOMTab = () => {
const recommendations = selectedDetail?.tom_recommendations || []
if (recommendations.length === 0) {
return (
<div className="text-center py-12 text-slate-400">
<p className="text-lg">Keine TOM-Empfehlungen verfuegbar</p>
<p className="text-sm mt-1">Fuer diese Branche wurden noch keine technisch-organisatorischen Massnahmen definiert.</p>
</div>
)
}
// Group by category
const grouped: Record<string, TOMRecommendation[]> = {}
recommendations.forEach((tom) => {
if (!grouped[tom.category]) {
grouped[tom.category] = []
}
grouped[tom.category].push(tom)
})
return (
<div className="space-y-6">
{Object.entries(grouped).map(([category, items]) => {
const icon = TOM_CATEGORY_ICONS[category] || '\uD83D\uDD27'
return (
<div key={category}>
<div className="flex items-center gap-2 mb-3">
<span className="text-lg">{icon}</span>
<h4 className="font-semibold text-slate-800">{category}</h4>
<span className="text-xs text-slate-400 ml-1">({items.length})</span>
</div>
<div className="space-y-3 ml-7">
{items.map((tom, idx) => {
const prio = PRIORITY_COLORS[tom.priority] || PRIORITY_COLORS.medium
const prioLabel = PRIORITY_LABELS[tom.priority] || tom.priority
return (
<div
key={idx}
className="bg-white border border-slate-200 rounded-lg p-4 hover:border-emerald-200 transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<h5 className="font-medium text-slate-900">{tom.name}</h5>
<p className="text-sm text-slate-500 mt-1">{tom.description}</p>
</div>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border flex-shrink-0 ${prio.bg} ${prio.text} ${prio.border}`}
>
{prioLabel}
</span>
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
)
}
// ---------------------------------------------------------------------------
// Render: Risk Tab
// ---------------------------------------------------------------------------
const renderRiskTab = () => {
const scenarios = selectedDetail?.risk_scenarios || []
if (scenarios.length === 0) {
return (
<div className="text-center py-12 text-slate-400">
<p className="text-lg">Keine Risiko-Szenarien verfuegbar</p>
<p className="text-sm mt-1">Fuer diese Branche wurden noch keine Risiko-Szenarien definiert.</p>
</div>
)
}
return (
<div className="space-y-4">
{scenarios.map((risk, idx) => {
const likelihoodColor = LIKELIHOOD_COLORS[risk.likelihood] || 'bg-slate-400'
const impactColor = IMPACT_COLORS[risk.impact] || 'bg-slate-400'
const likelihoodLabel = PRIORITY_LABELS[risk.likelihood] || risk.likelihood
const impactLabel = PRIORITY_LABELS[risk.impact] || risk.impact
return (
<div
key={idx}
className="bg-white border border-slate-200 rounded-lg p-5 hover:border-emerald-200 transition-colors"
>
<div className="flex items-start justify-between gap-4">
<h4 className="font-semibold text-slate-900">{risk.name}</h4>
<div className="flex items-center gap-2 flex-shrink-0">
{/* Likelihood badge */}
<div className="flex items-center gap-1.5">
<span className={`w-2.5 h-2.5 rounded-full ${likelihoodColor}`} />
<span className="text-xs text-slate-500">
Wahrsch.: <span className="font-medium text-slate-700">{likelihoodLabel}</span>
</span>
</div>
<span className="text-slate-300">|</span>
{/* Impact badge */}
<div className="flex items-center gap-1.5">
<span className={`w-2.5 h-2.5 rounded-full ${impactColor}`} />
<span className="text-xs text-slate-500">
Auswirkung: <span className="font-medium text-slate-700">{impactLabel}</span>
</span>
</div>
</div>
</div>
<p className="text-sm text-slate-500 mt-2">{risk.description}</p>
{/* Mitigation */}
<div className="mt-3 pt-3 border-t border-slate-100">
<div className="flex items-start gap-2">
<svg className="w-4 h-4 text-emerald-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<div>
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider">Massnahme</p>
<p className="text-sm text-slate-700 mt-0.5">{risk.mitigation}</p>
</div>
</div>
</div>
</div>
)
})}
</div>
)
}
// ---------------------------------------------------------------------------
// Render: Detail Tabs + Content
// ---------------------------------------------------------------------------
const renderDetailContent = () => {
if (!selectedDetail) return null
return (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
{/* Tab Navigation */}
<div className="flex border-b border-slate-200 bg-slate-50">
{DETAIL_TABS.map((tab) => {
const isActive = activeTab === tab.key
let count = 0
if (tab.key === 'vvt') count = selectedDetail.vvt_templates?.length || 0
if (tab.key === 'tom') count = selectedDetail.tom_recommendations?.length || 0
if (tab.key === 'risks') count = selectedDetail.risk_scenarios?.length || 0
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${
isActive
? 'text-emerald-700 bg-white border-b-2 border-emerald-500'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-100'
}`}
>
{tab.label}
{count > 0 && (
<span
className={`ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 rounded-full text-xs ${
isActive
? 'bg-emerald-100 text-emerald-700'
: 'bg-slate-200 text-slate-500'
}`}
>
{count}
</span>
)}
</button>
)
})}
</div>
{/* Tab Content */}
<div className="p-6">
{activeTab === 'vvt' && renderVVTTab()}
{activeTab === 'tom' && renderTOMTab()}
{activeTab === 'risks' && renderRiskTab()}
</div>
{/* Apply Button */}
<div className="px-6 py-4 border-t border-slate-200 bg-slate-50">
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">
Importiert alle Vorlagen, Empfehlungen und Szenarien in Ihr System.
</p>
<button
onClick={handleApplyPackage}
disabled={applying}
className={`inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-semibold text-white transition-all ${
applying
? 'bg-emerald-400 cursor-not-allowed'
: 'bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 shadow-sm hover:shadow-md'
}`}
>
{applying ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Wird angewendet...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Branchenpaket anwenden
</>
)}
</button>
</div>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Render: Toast
// ---------------------------------------------------------------------------
const renderToast = () => {
if (!toastMessage) return null
return (
<div className="fixed bottom-6 right-6 z-50 max-w-md animate-slide-up">
<div className="bg-slate-900 text-white rounded-xl shadow-2xl px-5 py-4 flex items-start gap-3">
<svg className="w-5 h-5 text-emerald-400 flex-shrink-0 mt-0.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>
<p className="text-sm leading-relaxed">{toastMessage}</p>
<button
onClick={() => setToastMessage(null)}
className="text-slate-400 hover:text-white flex-shrink-0 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>
)
}
// ---------------------------------------------------------------------------
// Render: Empty state
// ---------------------------------------------------------------------------
const renderEmptyState = () => (
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center">
<div className="w-16 h-16 rounded-2xl bg-emerald-50 flex items-center justify-center text-3xl mx-auto mb-4">
{'\uD83C\uDFED'}
</div>
<h3 className="text-lg font-semibold text-slate-900">Keine Branchenvorlagen verfuegbar</h3>
<p className="text-slate-500 mt-2 max-w-md mx-auto">
Es sind derzeit keine branchenspezifischen Compliance-Pakete im System hinterlegt.
Bitte kontaktieren Sie den Administrator oder versuchen Sie es spaeter erneut.
</p>
<button
onClick={loadIndustries}
className="mt-4 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 rounded-lg transition-colors"
>
Erneut laden
</button>
</div>
)
// ---------------------------------------------------------------------------
// Main Render
// ---------------------------------------------------------------------------
return (
<div className="space-y-6">
{/* Inline keyframe for toast animation */}
<style>{`
@keyframes slide-up {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
`}</style>
{renderHeader()}
{/* Error state */}
{error && renderError(error, loadIndustries)}
{/* Main Content */}
{loading ? (
selectedSlug ? <DetailSkeleton /> : <GridSkeleton />
) : selectedSlug ? (
// Detail View
<div className="space-y-6">
{detailLoading ? (
<DetailSkeleton />
) : detailError ? (
<>
<button
onClick={handleBackToGrid}
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-emerald-600 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="M15 19l-7-7 7-7" />
</svg>
Zurueck zur Uebersicht
</button>
{renderError(detailError, () => loadDetail(selectedSlug))}
</>
) : (
<>
{renderDetailHeader()}
{renderDetailContent()}
</>
)}
</div>
) : industries.length === 0 && !error ? (
renderEmptyState()
) : (
renderGrid()
)}
{/* Toast notification */}
{renderToast()}
</div>
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,669 @@
'use client'
import React, { useState, useEffect, useMemo } from 'react'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
WhistleblowerReport,
WhistleblowerStatistics,
ReportCategory,
ReportStatus,
ReportPriority,
REPORT_CATEGORY_INFO,
REPORT_STATUS_INFO,
isAcknowledgmentOverdue,
isFeedbackOverdue,
getDaysUntilAcknowledgment,
getDaysUntilFeedback
} from '@/lib/sdk/whistleblower/types'
import { fetchSDKWhistleblowerList } from '@/lib/sdk/whistleblower/api'
// =============================================================================
// TYPES
// =============================================================================
type TabId = 'overview' | 'new_reports' | 'investigation' | 'closed' | 'settings'
interface Tab {
id: TabId
label: string
count?: number
countColor?: string
}
// =============================================================================
// COMPONENTS
// =============================================================================
function TabNavigation({
tabs,
activeTab,
onTabChange
}: {
tabs: Tab[]
activeTab: TabId
onTabChange: (tab: TabId) => void
}) {
return (
<div className="border-b border-gray-200">
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
px-4 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<span className="flex items-center gap-2">
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span className={`
px-2 py-0.5 text-xs rounded-full
${tab.countColor || 'bg-gray-100 text-gray-600'}
`}>
{tab.count}
</span>
)}
</span>
</button>
))}
</nav>
</div>
)
}
function StatCard({
label,
value,
color = 'gray',
icon,
trend
}: {
label: string
value: number | string
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple'
icon?: React.ReactNode
trend?: { value: number; label: string }
}) {
const colorClasses = {
gray: 'border-gray-200 text-gray-900',
blue: 'border-blue-200 text-blue-600',
yellow: 'border-yellow-200 text-yellow-600',
red: 'border-red-200 text-red-600',
green: 'border-green-200 text-green-600',
purple: 'border-purple-200 text-purple-600'
}
return (
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
<div className="flex items-start justify-between">
<div>
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : `text-${color}-600`}`}>
{label}
</div>
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
{value}
</div>
{trend && (
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
</div>
)}
</div>
{icon && (
<div className={`w-10 h-10 rounded-lg flex items-center justify-center bg-${color}-50`}>
{icon}
</div>
)}
</div>
</div>
)
}
function FilterBar({
selectedCategory,
selectedStatus,
selectedPriority,
onCategoryChange,
onStatusChange,
onPriorityChange,
onClear
}: {
selectedCategory: ReportCategory | 'all'
selectedStatus: ReportStatus | 'all'
selectedPriority: ReportPriority | 'all'
onCategoryChange: (category: ReportCategory | 'all') => void
onStatusChange: (status: ReportStatus | 'all') => void
onPriorityChange: (priority: ReportPriority | 'all') => void
onClear: () => void
}) {
const hasFilters = selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all'
return (
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{/* Category Filter */}
<select
value={selectedCategory}
onChange={(e) => onCategoryChange(e.target.value as ReportCategory | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Kategorien</option>
{Object.entries(REPORT_CATEGORY_INFO).map(([key, info]) => (
<option key={key} value={key}>{info.label}</option>
))}
</select>
{/* Status Filter */}
<select
value={selectedStatus}
onChange={(e) => onStatusChange(e.target.value as ReportStatus | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Status</option>
{Object.entries(REPORT_STATUS_INFO).map(([status, info]) => (
<option key={status} value={status}>{info.label}</option>
))}
</select>
{/* Priority Filter */}
<select
value={selectedPriority}
onChange={(e) => onPriorityChange(e.target.value as ReportPriority | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Prioritaeten</option>
<option value="critical">Kritisch</option>
<option value="high">Hoch</option>
<option value="normal">Normal</option>
<option value="low">Niedrig</option>
</select>
{/* Clear Filters */}
{hasFilters && (
<button
onClick={onClear}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)
}
function ReportCard({ report }: { report: WhistleblowerReport }) {
const categoryInfo = REPORT_CATEGORY_INFO[report.category]
const statusInfo = REPORT_STATUS_INFO[report.status]
const isClosed = report.status === 'closed' || report.status === 'rejected'
const ackOverdue = isAcknowledgmentOverdue(report)
const fbOverdue = isFeedbackOverdue(report)
const daysAck = getDaysUntilAcknowledgment(report)
const daysFb = getDaysUntilFeedback(report)
const completedMeasures = report.measures.filter(m => m.status === 'completed').length
const totalMeasures = report.measures.length
const priorityLabels: Record<ReportPriority, string> = {
low: 'Niedrig',
normal: 'Normal',
high: 'Hoch',
critical: 'Kritisch'
}
return (
<div className={`
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
${ackOverdue || fbOverdue ? 'border-red-300 hover:border-red-400' :
report.priority === 'critical' ? 'border-orange-300 hover:border-orange-400' :
isClosed ? 'border-green-200 hover:border-green-300' :
'border-gray-200 hover:border-purple-300'
}
`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Header Badges */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-xs text-gray-500 font-mono">
{report.referenceNumber}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
{categoryInfo.label}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
{statusInfo.label}
</span>
{report.isAnonymous && (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded-full flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Anonym
</span>
)}
{report.priority === 'critical' && (
<span className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full flex items-center gap-1">
<svg className="w-3 h-3" 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>
Kritisch
</span>
)}
{report.priority === 'high' && (
<span className="px-2 py-1 text-xs bg-orange-100 text-orange-700 rounded-full">
Hoch
</span>
)}
</div>
{/* Title */}
<h3 className="text-lg font-semibold text-gray-900 truncate">
{report.title}
</h3>
{/* Description Preview */}
{report.description && (
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
{report.description}
</p>
)}
{/* Deadline Info */}
{!isClosed && (
<div className="flex items-center gap-4 mt-3 text-xs">
{report.status === 'new' && (
<span className={`flex items-center gap-1 ${ackOverdue ? 'text-red-600 font-medium' : daysAck <= 2 ? 'text-orange-600' : 'text-gray-500'}`}>
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{ackOverdue
? `Bestaetigung ${Math.abs(daysAck)} Tage ueberfaellig`
: `Bestaetigung in ${daysAck} Tagen`
}
</span>
)}
<span className={`flex items-center gap-1 ${fbOverdue ? 'text-red-600 font-medium' : daysFb <= 14 ? 'text-orange-600' : 'text-gray-500'}`}>
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{fbOverdue
? `Rueckmeldung ${Math.abs(daysFb)} Tage ueberfaellig`
: `Rueckmeldung in ${daysFb} Tagen`
}
</span>
</div>
)}
</div>
{/* Right Side - Date & Priority */}
<div className={`text-right ml-4 ${
ackOverdue || fbOverdue ? 'text-red-600' :
report.priority === 'critical' ? 'text-orange-600' :
'text-gray-500'
}`}>
<div className="text-sm font-medium">
{isClosed
? statusInfo.label
: ackOverdue
? 'Ueberfaellig'
: priorityLabels[report.priority]
}
</div>
<div className="text-xs mt-0.5">
{new Date(report.receivedAt).toLocaleDateString('de-DE')}
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm text-gray-500">
{report.assignedTo
? `Zugewiesen: ${report.assignedTo}`
: 'Nicht zugewiesen'
}
</div>
{report.attachments.length > 0 && (
<span className="flex items-center gap-1 text-xs text-gray-400">
<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="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
{report.attachments.length} Anhang{report.attachments.length !== 1 ? 'e' : ''}
</span>
)}
{totalMeasures > 0 && (
<span className={`flex items-center gap-1 text-xs ${completedMeasures === totalMeasures ? 'text-green-600' : 'text-yellow-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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
{completedMeasures}/{totalMeasures} Massnahmen
</span>
)}
{report.messages.length > 0 && (
<span className="flex items-center gap-1 text-xs text-gray-400">
<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="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{report.messages.length} Nachricht{report.messages.length !== 1 ? 'en' : ''}
</span>
)}
</div>
<div className="flex items-center gap-2">
{!isClosed && (
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Bearbeiten
</span>
)}
{isClosed && (
<span className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Details
</span>
)}
</div>
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function WhistleblowerPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [reports, setReports] = useState<WhistleblowerReport[]>([])
const [statistics, setStatistics] = useState<WhistleblowerStatistics | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Filters
const [selectedCategory, setSelectedCategory] = useState<ReportCategory | 'all'>('all')
const [selectedStatus, setSelectedStatus] = useState<ReportStatus | 'all'>('all')
const [selectedPriority, setSelectedPriority] = useState<ReportPriority | 'all'>('all')
// Load data from SDK backend
useEffect(() => {
const loadData = async () => {
setIsLoading(true)
try {
const { reports: wbReports, statistics: wbStats } = await fetchSDKWhistleblowerList()
setReports(wbReports)
setStatistics(wbStats)
} catch (error) {
console.error('Failed to load Whistleblower data:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [])
// Locally computed overdue counts (always fresh)
const overdueCounts = useMemo(() => {
const overdueAck = reports.filter(r => isAcknowledgmentOverdue(r)).length
const overdueFb = reports.filter(r => isFeedbackOverdue(r)).length
return { overdueAck, overdueFb }
}, [reports])
// Calculate tab counts
const tabCounts = useMemo(() => {
const investigationStatuses: ReportStatus[] = ['acknowledged', 'under_review', 'investigation', 'measures_taken']
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
return {
new_reports: reports.filter(r => r.status === 'new').length,
investigation: reports.filter(r => investigationStatuses.includes(r.status)).length,
closed: reports.filter(r => closedStatuses.includes(r.status)).length
}
}, [reports])
// Filter reports based on active tab and filters
const filteredReports = useMemo(() => {
let filtered = [...reports]
// Tab-based filtering
const investigationStatuses: ReportStatus[] = ['acknowledged', 'under_review', 'investigation', 'measures_taken']
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
if (activeTab === 'new_reports') {
filtered = filtered.filter(r => r.status === 'new')
} else if (activeTab === 'investigation') {
filtered = filtered.filter(r => investigationStatuses.includes(r.status))
} else if (activeTab === 'closed') {
filtered = filtered.filter(r => closedStatuses.includes(r.status))
}
// Category filter
if (selectedCategory !== 'all') {
filtered = filtered.filter(r => r.category === selectedCategory)
}
// Status filter
if (selectedStatus !== 'all') {
filtered = filtered.filter(r => r.status === selectedStatus)
}
// Priority filter
if (selectedPriority !== 'all') {
filtered = filtered.filter(r => r.priority === selectedPriority)
}
// Sort: overdue first, then by priority, then by date
return filtered.sort((a, b) => {
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
const getUrgency = (r: WhistleblowerReport) => {
if (closedStatuses.includes(r.status)) return 1000
const ackOd = isAcknowledgmentOverdue(r)
const fbOd = isFeedbackOverdue(r)
if (ackOd || fbOd) return -100
const priorityScore = { critical: 0, high: 1, normal: 2, low: 3 }
return priorityScore[r.priority] ?? 2
}
const urgencyDiff = getUrgency(a) - getUrgency(b)
if (urgencyDiff !== 0) return urgencyDiff
return new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
})
}, [reports, activeTab, selectedCategory, selectedStatus, selectedPriority])
const tabs: Tab[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'new_reports', label: 'Neue Meldungen', count: tabCounts.new_reports, countColor: 'bg-blue-100 text-blue-600' },
{ id: 'investigation', label: 'In Untersuchung', count: tabCounts.investigation, countColor: 'bg-yellow-100 text-yellow-600' },
{ id: 'closed', label: 'Abgeschlossen', count: tabCounts.closed, countColor: 'bg-green-100 text-green-600' },
{ id: 'settings', label: 'Einstellungen' }
]
const stepInfo = STEP_EXPLANATIONS['whistleblower']
const clearFilters = () => {
setSelectedCategory('all')
setSelectedStatus('all')
setSelectedPriority('all')
}
return (
<div className="space-y-6">
{/* Step Header - NO "create report" button (reports come from the public form) */}
<StepHeader
stepId="whistleblower"
title={stepInfo.title}
description={stepInfo.description}
explanation={stepInfo.explanation}
tips={stepInfo.tips}
/>
{/* Tab Navigation */}
<TabNavigation
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* Loading State */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<svg className="animate-spin w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
) : activeTab === 'settings' ? (
/* Settings Tab */
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
<p className="mt-2 text-gray-500">
Hinweisgebersystem-Einstellungen, Meldekanal-Konfiguration, Ombudsperson-Verwaltung
und E-Mail-Vorlagen werden in einer spaeteren Version verfuegbar sein.
</p>
</div>
) : (
<>
{/* Statistics (Overview Tab) */}
{activeTab === 'overview' && statistics && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
label="Gesamt Meldungen"
value={statistics.totalReports}
color="gray"
/>
<StatCard
label="Neue Meldungen"
value={statistics.newReports}
color="blue"
/>
<StatCard
label="In Untersuchung"
value={statistics.underReview}
color="yellow"
/>
<StatCard
label="Ueberfaellige Bestaetigung"
value={overdueCounts.overdueAck}
color={overdueCounts.overdueAck > 0 ? 'red' : 'green'}
/>
</div>
)}
{/* Overdue Alert for Acknowledgment Deadline (7 days HinSchG) */}
{(overdueCounts.overdueAck > 0 || overdueCounts.overdueFb > 0) && (activeTab === 'overview' || activeTab === 'new_reports' || activeTab === 'investigation') && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
<div className="w-10 h-10 bg-red-100 rounded-full 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 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>
</div>
<div className="flex-1">
<h4 className="font-medium text-red-800">
Achtung: Gesetzliche Fristen ueberschritten
</h4>
<p className="text-sm text-red-600 mt-0.5">
{overdueCounts.overdueAck > 0 && (
<span>{overdueCounts.overdueAck} Meldung(en) ohne Eingangsbestaetigung (mehr als 7 Tage, HinSchG ss 17 Abs. 1). </span>
)}
{overdueCounts.overdueFb > 0 && (
<span>{overdueCounts.overdueFb} Meldung(en) ohne Rueckmeldung (mehr als 3 Monate, HinSchG ss 17 Abs. 2). </span>
)}
Handeln Sie umgehend, um Bussgelder und Haftungsrisiken zu vermeiden.
</p>
</div>
<button
onClick={() => {
if (overdueCounts.overdueAck > 0) {
setActiveTab('new_reports')
} else {
setActiveTab('investigation')
}
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
>
Anzeigen
</button>
</div>
)}
{/* Info Box about HinSchG Deadlines (Overview Tab) */}
{activeTab === 'overview' && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" 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>
<h4 className="font-medium text-blue-800">HinSchG-Fristen</h4>
<p className="text-sm text-blue-600 mt-1">
Nach dem Hinweisgeberschutzgesetz (HinSchG) gelten folgende Fristen:
Die Eingangsbestaetigung muss innerhalb von <strong>7 Tagen</strong> an den
Hinweisgeber versendet werden (ss 17 Abs. 1 S. 2).
Eine Rueckmeldung ueber ergriffene Massnahmen muss innerhalb von <strong>3 Monaten</strong> nach
Eingangsbestaetigung erfolgen (ss 17 Abs. 2).
Der Schutz des Hinweisgebers vor Repressalien ist zwingend sicherzustellen (ss 36).
</p>
</div>
</div>
</div>
)}
{/* Filters */}
<FilterBar
selectedCategory={selectedCategory}
selectedStatus={selectedStatus}
selectedPriority={selectedPriority}
onCategoryChange={setSelectedCategory}
onStatusChange={setSelectedStatus}
onPriorityChange={setSelectedPriority}
onClear={clearFilters}
/>
{/* Report List */}
<div className="space-y-4">
{filteredReports.map(report => (
<ReportCard key={report.id} report={report} />
))}
</div>
{/* Empty State */}
{filteredReports.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Meldungen gefunden</h3>
<p className="mt-2 text-gray-500">
{selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all'
? 'Passen Sie die Filter an oder setzen Sie sie zurueck.'
: 'Es sind noch keine Meldungen im Hinweisgebersystem vorhanden. Meldungen werden ueber das oeffentliche Meldeformular eingereicht.'
}
</p>
{(selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all') && (
<button
onClick={clearFilters}
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,136 @@
/**
* Academy API Proxy - Catch-all route
* Proxies all /api/sdk/v1/academy/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/academy`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
const authHeader = request.headers.get('authorization')
if (authHeader) {
headers['Authorization'] = authHeader
}
const tenantHeader = request.headers.get('x-tenant-id')
if (tenantHeader) {
headers['X-Tenant-Id'] = tenantHeader
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const contentType = request.headers.get('content-type')
if (contentType?.includes('application/json')) {
try {
const text = await request.text()
if (text && text.trim()) {
fetchOptions.body = text
}
} catch {
// Empty or invalid body - continue without
}
}
}
const response = await fetch(url, fetchOptions)
// Handle non-JSON responses (e.g., PDF certificates)
const responseContentType = response.headers.get('content-type')
if (responseContentType?.includes('application/pdf') ||
responseContentType?.includes('application/octet-stream')) {
const blob = await response.blob()
return new NextResponse(blob, {
status: response.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
})
}
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Academy API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PATCH')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,114 @@
/**
* Document Crawler API Proxy - Catch-all route
* Proxies all /api/sdk/v1/crawler/* requests to document-crawler service (port 8098)
*/
import { NextRequest, NextResponse } from 'next/server'
const CRAWLER_BACKEND_URL = process.env.CRAWLER_API_URL || 'http://document-crawler:8098'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${CRAWLER_BACKEND_URL}/api/v1/crawler`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
// Forward all relevant headers
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
for (const name of headerNames) {
const value = request.headers.get(name)
if (value) {
headers[name] = value
}
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
// Forward body for non-GET requests
if (method !== 'GET' && method !== 'DELETE') {
try {
const body = await request.json()
fetchOptions.body = JSON.stringify(body)
} catch {
// No body or non-JSON body
}
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
// Handle 204 No Content
if (response.status === 204) {
return new NextResponse(null, { status: 204 })
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Document Crawler API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum Document Crawler Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,109 @@
/**
* DSB Portal API Proxy - Catch-all route
* Proxies all /api/sdk/v1/dsb/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/dsb`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
for (const name of headerNames) {
const value = request.headers.get(name)
if (value) {
headers[name] = value
}
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
try {
const body = await request.text()
if (body) {
fetchOptions.body = body
}
} catch {
// No body to forward
}
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('DSB API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,137 @@
/**
* Incidents/Breach Management API Proxy - Catch-all route
* Proxies all /api/sdk/v1/incidents/* requests to ai-compliance-sdk backend
* Supports PDF generation for authority notification forms
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/incidents`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
const authHeader = request.headers.get('authorization')
if (authHeader) {
headers['Authorization'] = authHeader
}
const tenantHeader = request.headers.get('x-tenant-id')
if (tenantHeader) {
headers['X-Tenant-Id'] = tenantHeader
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const contentType = request.headers.get('content-type')
if (contentType?.includes('application/json')) {
try {
const text = await request.text()
if (text && text.trim()) {
fetchOptions.body = text
}
} catch {
// Empty or invalid body
}
}
}
const response = await fetch(url, fetchOptions)
// Handle non-JSON responses (PDF authority forms, exports)
const responseContentType = response.headers.get('content-type')
if (responseContentType?.includes('application/pdf') ||
responseContentType?.includes('application/octet-stream')) {
const blob = await response.blob()
return new NextResponse(blob, {
status: response.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
})
}
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Incidents API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PATCH')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,74 @@
/**
* Industry Templates API Proxy - Catch-all route
* Proxies all /api/sdk/v1/industry/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/industry`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
for (const name of headerNames) {
const value = request.headers.get(name)
if (value) {
headers[name] = value
}
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Industry API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}

View File

@@ -0,0 +1,111 @@
/**
* Multi-Tenant API Proxy - Catch-all route
* Proxies all /api/sdk/v1/multi-tenant/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/multi-tenant`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
// Forward all relevant headers
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
for (const name of headerNames) {
const value = request.headers.get(name)
if (value) {
headers[name] = value
}
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
// Forward body for POST/PUT/PATCH/DELETE
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
try {
const body = await request.text()
if (body) {
fetchOptions.body = body
}
} catch {
// No body to forward
}
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Multi-Tenant API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,75 @@
/**
* Reporting API Proxy - Catch-all route
* Proxies all /api/sdk/v1/reporting/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/reporting`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
// Forward all relevant headers
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
for (const name of headerNames) {
const value = request.headers.get(name)
if (value) {
headers[name] = value
}
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Reporting API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}

View File

@@ -0,0 +1,111 @@
/**
* SSO API Proxy - Catch-all route
* Proxies all /api/sdk/v1/sso/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/sso`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
// Forward all relevant headers
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
for (const name of headerNames) {
const value = request.headers.get(name)
if (value) {
headers[name] = value
}
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
// Forward body for POST/PUT/PATCH/DELETE
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
try {
const body = await request.text()
if (body) {
fetchOptions.body = body
}
} catch {
// No body to forward
}
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('SSO API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,135 @@
/**
* Vendor Compliance API Proxy - Catch-all route
* Proxies all /api/sdk/v1/vendors/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/vendors`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
// Forward all relevant headers
const headerNames = ['authorization', 'x-tenant-id', 'x-user-id', 'x-namespace-id', 'x-tenant-slug']
for (const name of headerNames) {
const value = request.headers.get(name)
if (value) {
headers[name] = value
}
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const contentType = request.headers.get('content-type')
if (contentType?.includes('application/json')) {
try {
const text = await request.text()
if (text && text.trim()) {
fetchOptions.body = text
}
} catch {
// Empty or invalid body - continue without
}
}
}
const response = await fetch(url, fetchOptions)
// Handle non-JSON responses (e.g., PDF exports)
const responseContentType = response.headers.get('content-type')
if (responseContentType?.includes('application/pdf') ||
responseContentType?.includes('application/octet-stream')) {
const blob = await response.blob()
return new NextResponse(blob, {
status: response.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
})
}
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Vendor Compliance API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PATCH')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -0,0 +1,147 @@
/**
* Whistleblower API Proxy - Catch-all route
* Proxies all /api/sdk/v1/whistleblower/* requests to ai-compliance-sdk backend
* Supports multipart/form-data for file uploads
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${SDK_BACKEND_URL}/sdk/v1/whistleblower`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {}
const contentType = request.headers.get('content-type')
// Forward auth headers
const authHeader = request.headers.get('authorization')
if (authHeader) {
headers['Authorization'] = authHeader
}
const tenantHeader = request.headers.get('x-tenant-id')
if (tenantHeader) {
headers['X-Tenant-Id'] = tenantHeader
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(60000), // 60s for file uploads
}
if (['POST', 'PUT', 'PATCH'].includes(method)) {
if (contentType?.includes('multipart/form-data')) {
// Forward multipart form data (file uploads)
const formData = await request.formData()
fetchOptions.body = formData
// Don't set Content-Type - let fetch set it with boundary
} else if (contentType?.includes('application/json')) {
headers['Content-Type'] = 'application/json'
try {
const text = await request.text()
if (text && text.trim()) {
fetchOptions.body = text
}
} catch {
// Empty or invalid body
}
} else {
headers['Content-Type'] = 'application/json'
}
} else {
headers['Content-Type'] = 'application/json'
}
const response = await fetch(url, fetchOptions)
// Handle non-JSON responses (e.g., PDF exports, file downloads)
const responseContentType = response.headers.get('content-type')
if (responseContentType?.includes('application/pdf') ||
responseContentType?.includes('application/octet-stream') ||
responseContentType?.includes('image/')) {
const blob = await response.blob()
return new NextResponse(blob, {
status: response.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
})
}
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Whistleblower API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PATCH')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}