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

1
.gitignore vendored
View File

@@ -39,3 +39,4 @@ backups/*.backup
*.mp4
*.mp3
*.wav
ai-compliance-sdk/server

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')
}

View File

@@ -0,0 +1,576 @@
/**
* Academy API Client
*
* API client for the Compliance E-Learning Academy module
* Connects to the ai-compliance-sdk backend via Next.js proxy
*/
import {
Course,
CourseCategory,
CourseCreateRequest,
CourseUpdateRequest,
Enrollment,
EnrollmentStatus,
EnrollmentListResponse,
EnrollUserRequest,
UpdateProgressRequest,
Certificate,
AcademyStatistics,
SubmitQuizRequest,
SubmitQuizResponse,
isEnrollmentOverdue
} from './types'
// =============================================================================
// CONFIGURATION
// =============================================================================
const ACADEMY_API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'http://localhost:8093'
const API_TIMEOUT = 30000 // 30 seconds
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function getTenantId(): string {
if (typeof window !== 'undefined') {
return localStorage.getItem('bp_tenant_id') || 'default-tenant'
}
return 'default-tenant'
}
function getAuthHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
'X-Tenant-ID': getTenantId()
}
if (typeof window !== 'undefined') {
const token = localStorage.getItem('authToken')
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const userId = localStorage.getItem('bp_user_id')
if (userId) {
headers['X-User-ID'] = userId
}
}
return headers
}
async function fetchWithTimeout<T>(
url: string,
options: RequestInit = {},
timeout: number = API_TIMEOUT
): Promise<T> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
...getAuthHeaders(),
...options.headers
}
})
if (!response.ok) {
const errorBody = await response.text()
let errorMessage = `HTTP ${response.status}: ${response.statusText}`
try {
const errorJson = JSON.parse(errorBody)
errorMessage = errorJson.error || errorJson.message || errorMessage
} catch {
// Keep the HTTP status message
}
throw new Error(errorMessage)
}
// Handle empty responses
const contentType = response.headers.get('content-type')
if (contentType && contentType.includes('application/json')) {
return response.json()
}
return {} as T
} finally {
clearTimeout(timeoutId)
}
}
// =============================================================================
// COURSE CRUD
// =============================================================================
/**
* Alle Kurse abrufen
*/
export async function fetchCourses(): Promise<Course[]> {
return fetchWithTimeout<Course[]>(
`${ACADEMY_API_BASE}/api/v1/academy/courses`
)
}
/**
* Einzelnen Kurs abrufen
*/
export async function fetchCourse(id: string): Promise<Course> {
return fetchWithTimeout<Course>(
`${ACADEMY_API_BASE}/api/v1/academy/courses/${id}`
)
}
/**
* Neuen Kurs erstellen
*/
export async function createCourse(request: CourseCreateRequest): Promise<Course> {
return fetchWithTimeout<Course>(
`${ACADEMY_API_BASE}/api/v1/academy/courses`,
{
method: 'POST',
body: JSON.stringify(request)
}
)
}
/**
* Kurs aktualisieren
*/
export async function updateCourse(id: string, update: CourseUpdateRequest): Promise<Course> {
return fetchWithTimeout<Course>(
`${ACADEMY_API_BASE}/api/v1/academy/courses/${id}`,
{
method: 'PUT',
body: JSON.stringify(update)
}
)
}
/**
* Kurs loeschen
*/
export async function deleteCourse(id: string): Promise<void> {
await fetchWithTimeout<void>(
`${ACADEMY_API_BASE}/api/v1/academy/courses/${id}`,
{
method: 'DELETE'
}
)
}
// =============================================================================
// ENROLLMENTS
// =============================================================================
/**
* Einschreibungen abrufen (optional gefiltert nach Kurs-ID)
*/
export async function fetchEnrollments(courseId?: string): Promise<Enrollment[]> {
const params = new URLSearchParams()
if (courseId) {
params.set('courseId', courseId)
}
const queryString = params.toString()
const url = `${ACADEMY_API_BASE}/api/v1/academy/enrollments${queryString ? `?${queryString}` : ''}`
return fetchWithTimeout<Enrollment[]>(url)
}
/**
* Benutzer in einen Kurs einschreiben
*/
export async function enrollUser(request: EnrollUserRequest): Promise<Enrollment> {
return fetchWithTimeout<Enrollment>(
`${ACADEMY_API_BASE}/api/v1/academy/enrollments`,
{
method: 'POST',
body: JSON.stringify(request)
}
)
}
/**
* Fortschritt einer Einschreibung aktualisieren
*/
export async function updateProgress(enrollmentId: string, update: UpdateProgressRequest): Promise<Enrollment> {
return fetchWithTimeout<Enrollment>(
`${ACADEMY_API_BASE}/api/v1/academy/enrollments/${enrollmentId}/progress`,
{
method: 'PUT',
body: JSON.stringify(update)
}
)
}
/**
* Einschreibung als abgeschlossen markieren
*/
export async function completeEnrollment(enrollmentId: string): Promise<Enrollment> {
return fetchWithTimeout<Enrollment>(
`${ACADEMY_API_BASE}/api/v1/academy/enrollments/${enrollmentId}/complete`,
{
method: 'POST'
}
)
}
// =============================================================================
// CERTIFICATES
// =============================================================================
/**
* Zertifikat abrufen
*/
export async function fetchCertificate(id: string): Promise<Certificate> {
return fetchWithTimeout<Certificate>(
`${ACADEMY_API_BASE}/api/v1/academy/certificates/${id}`
)
}
/**
* Zertifikat generieren nach erfolgreichem Kursabschluss
*/
export async function generateCertificate(enrollmentId: string): Promise<Certificate> {
return fetchWithTimeout<Certificate>(
`${ACADEMY_API_BASE}/api/v1/academy/enrollments/${enrollmentId}/certificate`,
{
method: 'POST'
}
)
}
// =============================================================================
// QUIZ
// =============================================================================
/**
* Quiz-Antworten einreichen und auswerten
*/
export async function submitQuiz(lessonId: string, answers: SubmitQuizRequest): Promise<SubmitQuizResponse> {
return fetchWithTimeout<SubmitQuizResponse>(
`${ACADEMY_API_BASE}/api/v1/academy/lessons/${lessonId}/quiz`,
{
method: 'POST',
body: JSON.stringify(answers)
}
)
}
// =============================================================================
// STATISTICS
// =============================================================================
/**
* Academy-Statistiken abrufen
*/
export async function fetchAcademyStatistics(): Promise<AcademyStatistics> {
return fetchWithTimeout<AcademyStatistics>(
`${ACADEMY_API_BASE}/api/v1/academy/statistics`
)
}
// =============================================================================
// SDK PROXY FUNCTION (wraps fetchCourses + fetchAcademyStatistics)
// =============================================================================
/**
* Kurse und Statistiken laden - mit Fallback auf Mock-Daten
*/
export async function fetchSDKAcademyList(): Promise<{
courses: Course[]
enrollments: Enrollment[]
statistics: AcademyStatistics
}> {
try {
const [courses, enrollments, statistics] = await Promise.all([
fetchCourses(),
fetchEnrollments(),
fetchAcademyStatistics()
])
return { courses, enrollments, statistics }
} catch (error) {
console.error('Failed to load Academy data from backend, using mock data:', error)
// Fallback to mock data
const courses = createMockCourses()
const enrollments = createMockEnrollments()
const statistics = createMockStatistics(courses, enrollments)
return { courses, enrollments, statistics }
}
}
// =============================================================================
// MOCK DATA (Fallback / Demo)
// =============================================================================
/**
* Demo-Kurse mit deutschen Titeln erstellen
*/
export function createMockCourses(): Course[] {
const now = new Date()
return [
{
id: 'course-001',
title: 'DSGVO-Grundlagen fuer Mitarbeiter',
description: 'Umfassende Einfuehrung in die Datenschutz-Grundverordnung. Dieses Pflichttraining vermittelt die wichtigsten Grundsaetze des Datenschutzes, Betroffenenrechte und die korrekte Handhabung personenbezogener Daten im Arbeitsalltag.',
category: 'dsgvo_basics',
durationMinutes: 90,
requiredForRoles: ['all'],
createdAt: new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString(),
lessons: [
{
id: 'lesson-001-01',
courseId: 'course-001',
order: 1,
title: 'Was ist die DSGVO?',
type: 'text',
contentMarkdown: '# Was ist die DSGVO?\n\nDie Datenschutz-Grundverordnung (DSGVO) ist eine Verordnung der Europaeischen Union...',
durationMinutes: 15
},
{
id: 'lesson-001-02',
courseId: 'course-001',
order: 2,
title: 'Die 7 Grundsaetze der DSGVO',
type: 'video',
contentMarkdown: 'Videoerklaerung der Grundsaetze: Rechtmaessigkeit, Zweckbindung, Datenminimierung, Richtigkeit, Speicherbegrenzung, Integritaet und Vertraulichkeit, Rechenschaftspflicht.',
durationMinutes: 20,
videoUrl: '/videos/dsgvo-grundsaetze.mp4'
},
{
id: 'lesson-001-03',
courseId: 'course-001',
order: 3,
title: 'Betroffenenrechte (Art. 15-21)',
type: 'text',
contentMarkdown: '# Betroffenenrechte\n\nUebersicht der Betroffenenrechte: Auskunft, Berichtigung, Loeschung, Einschraenkung, Datenuebertragbarkeit, Widerspruch.',
durationMinutes: 20
},
{
id: 'lesson-001-04',
courseId: 'course-001',
order: 4,
title: 'Personenbezogene Daten im Arbeitsalltag',
type: 'video',
contentMarkdown: 'Praxisbeispiele fuer den korrekten Umgang mit personenbezogenen Daten am Arbeitsplatz.',
durationMinutes: 15,
videoUrl: '/videos/dsgvo-praxis.mp4'
},
{
id: 'lesson-001-05',
courseId: 'course-001',
order: 5,
title: 'Wissenstest: DSGVO-Grundlagen',
type: 'quiz',
contentMarkdown: 'Testen Sie Ihr Wissen zu den DSGVO-Grundlagen.',
durationMinutes: 20
}
]
},
{
id: 'course-002',
title: 'IT-Sicherheit & Cybersecurity Awareness',
description: 'Sensibilisierung fuer IT-Sicherheitsrisiken und Best Practices im Umgang mit Phishing, Passwoertern, Social Engineering und sicherer Kommunikation.',
category: 'it_security',
durationMinutes: 60,
requiredForRoles: ['all'],
createdAt: new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(),
lessons: [
{
id: 'lesson-002-01',
courseId: 'course-002',
order: 1,
title: 'Phishing erkennen und vermeiden',
type: 'video',
contentMarkdown: 'Wie erkennt man Phishing-E-Mails und was tut man im Verdachtsfall?',
durationMinutes: 15,
videoUrl: '/videos/phishing-awareness.mp4'
},
{
id: 'lesson-002-02',
courseId: 'course-002',
order: 2,
title: 'Sichere Passwoerter und MFA',
type: 'text',
contentMarkdown: '# Sichere Passwoerter\n\nRichtlinien fuer starke Passwoerter, Passwort-Manager und Multi-Faktor-Authentifizierung.',
durationMinutes: 15
},
{
id: 'lesson-002-03',
courseId: 'course-002',
order: 3,
title: 'Social Engineering und Manipulation',
type: 'text',
contentMarkdown: '# Social Engineering\n\nMethoden von Angreifern zur Manipulation von Mitarbeitern und Schutzmassnahmen.',
durationMinutes: 15
},
{
id: 'lesson-002-04',
courseId: 'course-002',
order: 4,
title: 'Wissenstest: IT-Sicherheit',
type: 'quiz',
contentMarkdown: 'Testen Sie Ihr Wissen zur IT-Sicherheit.',
durationMinutes: 15
}
]
},
{
id: 'course-003',
title: 'AI Literacy - Sicherer Umgang mit KI',
description: 'Grundlagen kuenstlicher Intelligenz, EU AI Act, verantwortungsvoller Einsatz von KI-Werkzeugen und Risiken bei der Nutzung von Large Language Models (LLMs) im Unternehmen.',
category: 'ai_literacy',
durationMinutes: 75,
requiredForRoles: ['admin', 'data_protection_officer'],
createdAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(),
lessons: [
{
id: 'lesson-003-01',
courseId: 'course-003',
order: 1,
title: 'Was ist Kuenstliche Intelligenz?',
type: 'text',
contentMarkdown: '# Was ist KI?\n\nGrundlagen von Machine Learning, Deep Learning und Large Language Models in verstaendlicher Sprache.',
durationMinutes: 15
},
{
id: 'lesson-003-02',
courseId: 'course-003',
order: 2,
title: 'Der EU AI Act - Was bedeutet er fuer uns?',
type: 'video',
contentMarkdown: 'Ueberblick ueber den EU AI Act, Risikoklassen und Anforderungen fuer Unternehmen.',
durationMinutes: 20,
videoUrl: '/videos/eu-ai-act.mp4'
},
{
id: 'lesson-003-03',
courseId: 'course-003',
order: 3,
title: 'KI-Werkzeuge sicher nutzen',
type: 'text',
contentMarkdown: '# KI-Werkzeuge sicher nutzen\n\nRichtlinien fuer den Einsatz von ChatGPT, Copilot & Co.',
durationMinutes: 20
},
{
id: 'lesson-003-04',
courseId: 'course-003',
order: 4,
title: 'Wissenstest: AI Literacy',
type: 'quiz',
contentMarkdown: 'Testen Sie Ihr Wissen zum sicheren Umgang mit KI.',
durationMinutes: 20
}
]
}
]
}
/**
* Demo-Einschreibungen erstellen
*/
export function createMockEnrollments(): Enrollment[] {
const now = new Date()
return [
{
id: 'enr-001',
courseId: 'course-001',
userId: 'user-001',
userName: 'Maria Fischer',
userEmail: 'maria.fischer@example.de',
status: 'in_progress',
progress: 40,
startedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(),
deadline: new Date(now.getTime() + 20 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'enr-002',
courseId: 'course-002',
userId: 'user-002',
userName: 'Stefan Mueller',
userEmail: 'stefan.mueller@example.de',
status: 'completed',
progress: 100,
startedAt: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000).toISOString(),
certificateId: 'cert-001',
deadline: new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'enr-003',
courseId: 'course-001',
userId: 'user-003',
userName: 'Laura Schneider',
userEmail: 'laura.schneider@example.de',
status: 'not_started',
progress: 0,
startedAt: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString(),
deadline: new Date(now.getTime() + 28 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'enr-004',
courseId: 'course-003',
userId: 'user-004',
userName: 'Thomas Wagner',
userEmail: 'thomas.wagner@example.de',
status: 'expired',
progress: 25,
startedAt: new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000).toISOString(),
deadline: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'enr-005',
courseId: 'course-002',
userId: 'user-005',
userName: 'Julia Becker',
userEmail: 'julia.becker@example.de',
status: 'in_progress',
progress: 50,
startedAt: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(),
deadline: new Date(now.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString()
}
]
}
/**
* Demo-Statistiken aus Kursen und Einschreibungen berechnen
*/
export function createMockStatistics(courses?: Course[], enrollments?: Enrollment[]): AcademyStatistics {
const c = courses || createMockCourses()
const e = enrollments || createMockEnrollments()
const completedCount = e.filter(en => en.status === 'completed').length
const completionRate = e.length > 0 ? Math.round((completedCount / e.length) * 100) : 0
const overdueCount = e.filter(en => isEnrollmentOverdue(en)).length
return {
totalCourses: c.length,
totalEnrollments: e.length,
completionRate,
overdueCount,
byCategory: {
dsgvo_basics: c.filter(co => co.category === 'dsgvo_basics').length,
it_security: c.filter(co => co.category === 'it_security').length,
ai_literacy: c.filter(co => co.category === 'ai_literacy').length,
whistleblower_protection: c.filter(co => co.category === 'whistleblower_protection').length,
custom: c.filter(co => co.category === 'custom').length,
},
byStatus: {
not_started: e.filter(en => en.status === 'not_started').length,
in_progress: e.filter(en => en.status === 'in_progress').length,
completed: e.filter(en => en.status === 'completed').length,
expired: e.filter(en => en.status === 'expired').length,
}
}
}

View File

@@ -0,0 +1,6 @@
/**
* Academy Module Exports
*/
export * from './types'
export * from './api'

View File

@@ -0,0 +1,285 @@
/**
* Academy (E-Learning / Compliance Academy) Types
*
* TypeScript definitions for the E-Learning Academy module
* Provides course management, enrollment tracking, and certificate generation
* for DSGVO, IT-Security, AI Literacy, and Whistleblower compliance training
*/
// =============================================================================
// ENUMS & CONSTANTS
// =============================================================================
export type CourseCategory =
| 'dsgvo_basics' // DSGVO-Grundlagen
| 'it_security' // IT-Sicherheit
| 'ai_literacy' // AI Literacy
| 'whistleblower_protection' // Hinweisgeberschutz
| 'custom' // Benutzerdefiniert
export type EnrollmentStatus =
| 'not_started' // Nicht gestartet
| 'in_progress' // In Bearbeitung
| 'completed' // Abgeschlossen
| 'expired' // Abgelaufen
export type LessonType = 'video' | 'text' | 'quiz'
// =============================================================================
// COURSE CATEGORY METADATA
// =============================================================================
export interface CourseCategoryInfo {
label: string
description: string
icon: string
color: string
bgColor: string
}
export const COURSE_CATEGORY_INFO: Record<CourseCategory, CourseCategoryInfo> = {
dsgvo_basics: {
label: 'DSGVO-Grundlagen',
description: 'Grundlagenwissen zur Datenschutz-Grundverordnung fuer alle Mitarbeiter',
icon: 'Shield',
color: 'text-blue-700',
bgColor: 'bg-blue-100'
},
it_security: {
label: 'IT-Sicherheit',
description: 'Cybersecurity Awareness und sichere IT-Nutzung im Arbeitsalltag',
icon: 'Lock',
color: 'text-red-700',
bgColor: 'bg-red-100'
},
ai_literacy: {
label: 'AI Literacy',
description: 'Sicherer und verantwortungsvoller Umgang mit kuenstlicher Intelligenz',
icon: 'Brain',
color: 'text-purple-700',
bgColor: 'bg-purple-100'
},
whistleblower_protection: {
label: 'Hinweisgeberschutz',
description: 'Hinweisgeberschutzgesetz (HinSchG) und interne Meldestellen',
icon: 'Megaphone',
color: 'text-orange-700',
bgColor: 'bg-orange-100'
},
custom: {
label: 'Benutzerdefiniert',
description: 'Individuell erstellte Schulungsinhalte und unternehmensspezifische Kurse',
icon: 'Pencil',
color: 'text-gray-700',
bgColor: 'bg-gray-100'
}
}
// =============================================================================
// ENROLLMENT STATUS METADATA
// =============================================================================
export const ENROLLMENT_STATUS_INFO: Record<EnrollmentStatus, { label: string; color: string; bgColor: string; borderColor: string }> = {
not_started: {
label: 'Nicht gestartet',
color: 'text-gray-700',
bgColor: 'bg-gray-100',
borderColor: 'border-gray-200'
},
in_progress: {
label: 'In Bearbeitung',
color: 'text-yellow-700',
bgColor: 'bg-yellow-100',
borderColor: 'border-yellow-200'
},
completed: {
label: 'Abgeschlossen',
color: 'text-green-700',
bgColor: 'bg-green-100',
borderColor: 'border-green-200'
},
expired: {
label: 'Abgelaufen',
color: 'text-red-700',
bgColor: 'bg-red-100',
borderColor: 'border-red-200'
}
}
// =============================================================================
// MAIN INTERFACES
// =============================================================================
export interface Course {
id: string
title: string
description: string
category: CourseCategory
lessons: Lesson[]
durationMinutes: number
requiredForRoles: string[]
createdAt: string
updatedAt: string
}
export interface Lesson {
id: string
courseId: string
title: string
type: LessonType
contentMarkdown: string
videoUrl?: string
order: number
durationMinutes: number
}
export interface QuizQuestion {
id: string
lessonId: string
question: string
options: string[]
correctOptionIndex: number
explanation: string
}
export interface Enrollment {
id: string
courseId: string
userId: string
userName: string
userEmail: string
status: EnrollmentStatus
progress: number // 0-100
startedAt: string
completedAt?: string
certificateId?: string
deadline: string
}
export interface Certificate {
id: string
enrollmentId: string
courseId: string
userId: string
userName: string
courseName: string
issuedAt: string
validUntil: string
pdfUrl: string
}
// =============================================================================
// STATISTICS
// =============================================================================
export interface AcademyStatistics {
totalCourses: number
totalEnrollments: number
completionRate: number // 0-100
overdueCount: number
byCategory: Record<CourseCategory, number>
byStatus: Record<EnrollmentStatus, number>
}
// =============================================================================
// API TYPES (REQUEST / RESPONSE)
// =============================================================================
export interface CourseListResponse {
courses: Course[]
total: number
page: number
pageSize: number
}
export interface EnrollmentListResponse {
enrollments: Enrollment[]
total: number
page: number
pageSize: number
}
export interface CourseCreateRequest {
title: string
description: string
category: CourseCategory
durationMinutes: number
requiredForRoles?: string[]
}
export interface CourseUpdateRequest {
title?: string
description?: string
category?: CourseCategory
durationMinutes?: number
requiredForRoles?: string[]
}
export interface EnrollUserRequest {
courseId: string
userId: string
userName: string
userEmail: string
deadline: string
}
export interface UpdateProgressRequest {
progress: number
lessonId?: string
}
export interface SubmitQuizRequest {
answers: number[] // Index der ausgewaehlten Antwort pro Frage
}
export interface SubmitQuizResponse {
score: number
passed: boolean
correctAnswers: number
totalQuestions: number
results: { questionId: string; correct: boolean; explanation: string }[]
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
/**
* Berechnet die Abschlussrate fuer eine Liste von Einschreibungen in Prozent (0-100)
*/
export function getCompletionPercentage(enrollments: Enrollment[]): number {
if (enrollments.length === 0) return 0
const completed = enrollments.filter(e => e.status === 'completed').length
return Math.round((completed / enrollments.length) * 100)
}
/**
* Prueft ob eine Einschreibung ueberfaellig ist (Deadline ueberschritten und nicht abgeschlossen)
*/
export function isEnrollmentOverdue(enrollment: Enrollment): boolean {
if (enrollment.status === 'completed' || enrollment.status === 'expired') {
return false
}
const deadlineDate = new Date(enrollment.deadline)
const now = new Date()
return deadlineDate.getTime() < now.getTime()
}
/**
* Berechnet die verbleibenden Tage bis zur Deadline
* Negative Werte bedeuten ueberfaellig
*/
export function getDaysUntilDeadline(deadline: string): number {
const deadlineDate = new Date(deadline)
const now = new Date()
const diff = deadlineDate.getTime() - now.getTime()
return Math.ceil(diff / (1000 * 60 * 60 * 24))
}
export function getCategoryInfo(category: CourseCategory): CourseCategoryInfo {
return COURSE_CATEGORY_INFO[category]
}
export function getStatusInfo(status: EnrollmentStatus) {
return ENROLLMENT_STATUS_INFO[status]
}

View File

@@ -0,0 +1,845 @@
/**
* Incident/Breach Management API Client
*
* API client for DSGVO Art. 33/34 Incident & Data Breach Management
* Connects via Next.js proxy to the ai-compliance-sdk backend
*/
import {
Incident,
IncidentListResponse,
IncidentFilters,
IncidentCreateRequest,
IncidentUpdateRequest,
IncidentStatistics,
IncidentMeasure,
TimelineEntry,
RiskAssessmentRequest,
RiskAssessment,
AuthorityNotification,
DataSubjectNotification,
IncidentSeverity,
IncidentStatus,
IncidentCategory,
calculateRiskLevel,
isNotificationRequired,
get72hDeadline
} from './types'
// =============================================================================
// CONFIGURATION
// =============================================================================
const INCIDENTS_API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'http://localhost:8093'
const API_TIMEOUT = 30000 // 30 seconds
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function getTenantId(): string {
if (typeof window !== 'undefined') {
return localStorage.getItem('bp_tenant_id') || 'default-tenant'
}
return 'default-tenant'
}
function getAuthHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
'X-Tenant-ID': getTenantId()
}
if (typeof window !== 'undefined') {
const token = localStorage.getItem('authToken')
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const userId = localStorage.getItem('bp_user_id')
if (userId) {
headers['X-User-ID'] = userId
}
}
return headers
}
async function fetchWithTimeout<T>(
url: string,
options: RequestInit = {},
timeout: number = API_TIMEOUT
): Promise<T> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
...getAuthHeaders(),
...options.headers
}
})
if (!response.ok) {
const errorBody = await response.text()
let errorMessage = `HTTP ${response.status}: ${response.statusText}`
try {
const errorJson = JSON.parse(errorBody)
errorMessage = errorJson.error || errorJson.message || errorMessage
} catch {
// Keep the HTTP status message
}
throw new Error(errorMessage)
}
// Handle empty responses
const contentType = response.headers.get('content-type')
if (contentType && contentType.includes('application/json')) {
return response.json()
}
return {} as T
} finally {
clearTimeout(timeoutId)
}
}
// =============================================================================
// INCIDENT LIST & CRUD
// =============================================================================
/**
* Alle Vorfaelle abrufen mit optionalen Filtern
*/
export async function fetchIncidents(filters?: IncidentFilters): Promise<IncidentListResponse> {
const params = new URLSearchParams()
if (filters) {
if (filters.status) {
const statuses = Array.isArray(filters.status) ? filters.status : [filters.status]
statuses.forEach(s => params.append('status', s))
}
if (filters.severity) {
const severities = Array.isArray(filters.severity) ? filters.severity : [filters.severity]
severities.forEach(s => params.append('severity', s))
}
if (filters.category) {
const categories = Array.isArray(filters.category) ? filters.category : [filters.category]
categories.forEach(c => params.append('category', c))
}
if (filters.assignedTo) params.set('assignedTo', filters.assignedTo)
if (filters.overdue !== undefined) params.set('overdue', String(filters.overdue))
if (filters.search) params.set('search', filters.search)
if (filters.dateFrom) params.set('dateFrom', filters.dateFrom)
if (filters.dateTo) params.set('dateTo', filters.dateTo)
}
const queryString = params.toString()
const url = `${INCIDENTS_API_BASE}/api/v1/incidents${queryString ? `?${queryString}` : ''}`
return fetchWithTimeout<IncidentListResponse>(url)
}
/**
* Einzelnen Vorfall per ID abrufen
*/
export async function fetchIncident(id: string): Promise<Incident> {
return fetchWithTimeout<Incident>(`${INCIDENTS_API_BASE}/api/v1/incidents/${id}`)
}
/**
* Neuen Vorfall erstellen
*/
export async function createIncident(request: IncidentCreateRequest): Promise<Incident> {
return fetchWithTimeout<Incident>(`${INCIDENTS_API_BASE}/api/v1/incidents`, {
method: 'POST',
body: JSON.stringify(request)
})
}
/**
* Vorfall aktualisieren
*/
export async function updateIncident(id: string, update: IncidentUpdateRequest): Promise<Incident> {
return fetchWithTimeout<Incident>(`${INCIDENTS_API_BASE}/api/v1/incidents/${id}`, {
method: 'PUT',
body: JSON.stringify(update)
})
}
/**
* Vorfall loeschen (Soft Delete)
*/
export async function deleteIncident(id: string): Promise<void> {
await fetchWithTimeout<void>(`${INCIDENTS_API_BASE}/api/v1/incidents/${id}`, {
method: 'DELETE'
})
}
// =============================================================================
// RISK ASSESSMENT
// =============================================================================
/**
* Risikobewertung fuer einen Vorfall durchfuehren (Art. 33 DSGVO)
*/
export async function submitRiskAssessment(
incidentId: string,
assessment: RiskAssessmentRequest
): Promise<Incident> {
return fetchWithTimeout<Incident>(
`${INCIDENTS_API_BASE}/api/v1/incidents/${incidentId}/risk-assessment`,
{
method: 'POST',
body: JSON.stringify(assessment)
}
)
}
// =============================================================================
// AUTHORITY NOTIFICATION (Art. 33 DSGVO)
// =============================================================================
/**
* Meldeformular fuer die Aufsichtsbehoerde generieren
*/
export async function generateAuthorityForm(incidentId: string): Promise<Blob> {
const response = await fetch(
`${INCIDENTS_API_BASE}/api/v1/incidents/${incidentId}/authority-form/pdf`,
{
headers: getAuthHeaders()
}
)
if (!response.ok) {
throw new Error(`PDF-Generierung fehlgeschlagen: ${response.statusText}`)
}
return response.blob()
}
/**
* Meldung an die Aufsichtsbehoerde einreichen (Art. 33 DSGVO)
*/
export async function submitAuthorityNotification(
incidentId: string,
data: Partial<AuthorityNotification>
): Promise<Incident> {
return fetchWithTimeout<Incident>(
`${INCIDENTS_API_BASE}/api/v1/incidents/${incidentId}/authority-notification`,
{
method: 'POST',
body: JSON.stringify(data)
}
)
}
// =============================================================================
// DATA SUBJECT NOTIFICATION (Art. 34 DSGVO)
// =============================================================================
/**
* Betroffene Personen benachrichtigen (Art. 34 DSGVO)
*/
export async function sendDataSubjectNotification(
incidentId: string,
data: Partial<DataSubjectNotification>
): Promise<Incident> {
return fetchWithTimeout<Incident>(
`${INCIDENTS_API_BASE}/api/v1/incidents/${incidentId}/data-subject-notification`,
{
method: 'POST',
body: JSON.stringify(data)
}
)
}
// =============================================================================
// MEASURES (Massnahmen)
// =============================================================================
/**
* Massnahme hinzufuegen (Sofort-, Korrektur- oder Praeventionsmassnahme)
*/
export async function addMeasure(
incidentId: string,
measure: Omit<IncidentMeasure, 'id' | 'incidentId'>
): Promise<Incident> {
return fetchWithTimeout<Incident>(
`${INCIDENTS_API_BASE}/api/v1/incidents/${incidentId}/measures`,
{
method: 'POST',
body: JSON.stringify(measure)
}
)
}
/**
* Massnahme aktualisieren
*/
export async function updateMeasure(
measureId: string,
update: Partial<IncidentMeasure>
): Promise<IncidentMeasure> {
return fetchWithTimeout<IncidentMeasure>(
`${INCIDENTS_API_BASE}/api/v1/measures/${measureId}`,
{
method: 'PUT',
body: JSON.stringify(update)
}
)
}
/**
* Massnahme als abgeschlossen markieren
*/
export async function completeMeasure(measureId: string): Promise<IncidentMeasure> {
return fetchWithTimeout<IncidentMeasure>(
`${INCIDENTS_API_BASE}/api/v1/measures/${measureId}/complete`,
{
method: 'POST'
}
)
}
// =============================================================================
// TIMELINE
// =============================================================================
/**
* Zeitleisteneintrag hinzufuegen
*/
export async function addTimelineEntry(
incidentId: string,
entry: Omit<TimelineEntry, 'id' | 'incidentId'>
): Promise<Incident> {
return fetchWithTimeout<Incident>(
`${INCIDENTS_API_BASE}/api/v1/incidents/${incidentId}/timeline`,
{
method: 'POST',
body: JSON.stringify(entry)
}
)
}
// =============================================================================
// CLOSE INCIDENT
// =============================================================================
/**
* Vorfall abschliessen mit Lessons Learned
*/
export async function closeIncident(
incidentId: string,
lessonsLearned: string
): Promise<Incident> {
return fetchWithTimeout<Incident>(
`${INCIDENTS_API_BASE}/api/v1/incidents/${incidentId}/close`,
{
method: 'POST',
body: JSON.stringify({ lessonsLearned })
}
)
}
// =============================================================================
// STATISTICS
// =============================================================================
/**
* Vorfall-Statistiken abrufen
*/
export async function fetchIncidentStatistics(): Promise<IncidentStatistics> {
return fetchWithTimeout<IncidentStatistics>(
`${INCIDENTS_API_BASE}/api/v1/incidents/statistics`
)
}
// =============================================================================
// SDK PROXY FUNCTION (mit Fallback auf Mock-Daten)
// =============================================================================
/**
* Fetch Incident-Liste via SDK-Proxy mit Fallback auf Mock-Daten
*/
export async function fetchSDKIncidentList(): Promise<{ incidents: Incident[]; statistics: IncidentStatistics }> {
try {
const res = await fetch('/api/sdk/v1/incidents', {
headers: getAuthHeaders()
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = await res.json()
const incidents: Incident[] = data.incidents || []
// Statistiken lokal berechnen
const statistics = computeStatistics(incidents)
return { incidents, statistics }
} catch (error) {
console.warn('SDK-Backend nicht erreichbar, verwende Mock-Daten:', error)
const incidents = createMockIncidents()
const statistics = createMockStatistics()
return { incidents, statistics }
}
}
/**
* Statistiken lokal aus Incident-Liste berechnen
*/
function computeStatistics(incidents: Incident[]): IncidentStatistics {
const countBy = <K extends string>(items: { [key: string]: unknown }[], field: string): Record<K, number> => {
const result: Record<string, number> = {}
items.forEach(item => {
const key = String(item[field])
result[key] = (result[key] || 0) + 1
})
return result as Record<K, number>
}
const statusCounts = countBy<IncidentStatus>(incidents as unknown as { [key: string]: unknown }[], 'status')
const severityCounts = countBy<IncidentSeverity>(incidents as unknown as { [key: string]: unknown }[], 'severity')
const categoryCounts = countBy<IncidentCategory>(incidents as unknown as { [key: string]: unknown }[], 'category')
const openIncidents = incidents.filter(i => i.status !== 'closed').length
const notificationsPending = incidents.filter(i =>
i.authorityNotification !== null &&
i.authorityNotification.status === 'pending' &&
i.status !== 'closed'
).length
// Durchschnittliche Reaktionszeit berechnen
let totalResponseHours = 0
let respondedCount = 0
incidents.forEach(i => {
if (i.riskAssessment && i.riskAssessment.assessedAt) {
const detected = new Date(i.detectedAt).getTime()
const assessed = new Date(i.riskAssessment.assessedAt).getTime()
totalResponseHours += (assessed - detected) / (1000 * 60 * 60)
respondedCount++
}
})
return {
totalIncidents: incidents.length,
openIncidents,
notificationsPending,
averageResponseTimeHours: respondedCount > 0 ? Math.round(totalResponseHours / respondedCount * 10) / 10 : 0,
bySeverity: {
low: severityCounts['low'] || 0,
medium: severityCounts['medium'] || 0,
high: severityCounts['high'] || 0,
critical: severityCounts['critical'] || 0
},
byCategory: {
data_breach: categoryCounts['data_breach'] || 0,
unauthorized_access: categoryCounts['unauthorized_access'] || 0,
data_loss: categoryCounts['data_loss'] || 0,
system_compromise: categoryCounts['system_compromise'] || 0,
phishing: categoryCounts['phishing'] || 0,
ransomware: categoryCounts['ransomware'] || 0,
insider_threat: categoryCounts['insider_threat'] || 0,
physical_breach: categoryCounts['physical_breach'] || 0,
other: categoryCounts['other'] || 0
},
byStatus: {
detected: statusCounts['detected'] || 0,
assessment: statusCounts['assessment'] || 0,
containment: statusCounts['containment'] || 0,
notification_required: statusCounts['notification_required'] || 0,
notification_sent: statusCounts['notification_sent'] || 0,
remediation: statusCounts['remediation'] || 0,
closed: statusCounts['closed'] || 0
}
}
}
// =============================================================================
// MOCK DATA (Demo-Daten fuer Entwicklung und Tests)
// =============================================================================
/**
* Erstellt Demo-Vorfaelle fuer die Entwicklung
*/
export function createMockIncidents(): Incident[] {
const now = new Date()
return [
// 1. Gerade erkannt - noch nicht bewertet (detected/new)
{
id: 'inc-001',
referenceNumber: 'INC-2026-000001',
title: 'Unbefugter Zugriff auf Schuelerdatenbank',
description: 'Ein ehemaliger Mitarbeiter hat sich mit noch aktiven Zugangsdaten in die Schuelerdatenbank eingeloggt. Der Zugriff wurde durch die Log-Analyse entdeckt.',
category: 'unauthorized_access',
severity: 'high',
status: 'detected',
detectedAt: new Date(now.getTime() - 3 * 60 * 60 * 1000).toISOString(), // 3 Stunden her
detectedBy: 'Log-Analyse (automatisiert)',
affectedSystems: ['Schuelerdatenbank', 'Schulverwaltungssystem'],
affectedDataCategories: ['Personenbezogene Daten', 'Daten von Kindern', 'Gesundheitsdaten'],
estimatedAffectedPersons: 800,
riskAssessment: null,
authorityNotification: null,
dataSubjectNotification: null,
measures: [],
timeline: [
{
id: 'tl-001',
incidentId: 'inc-001',
timestamp: new Date(now.getTime() - 3 * 60 * 60 * 1000).toISOString(),
action: 'Vorfall erkannt',
description: 'Automatische Log-Analyse meldet verdaechtigen Login eines deaktivierten Kontos',
performedBy: 'SIEM-System'
}
],
assignedTo: undefined
},
// 2. In Bewertung (assessment) - Risikobewertung laeuft
{
id: 'inc-002',
referenceNumber: 'INC-2026-000002',
title: 'E-Mail mit Kundendaten an falschen Empfaenger',
description: 'Ein Mitarbeiter hat eine Excel-Datei mit Kundendaten (Name, Adresse, Vertragsnummer) an einen falschen E-Mail-Empfaenger gesendet. Der Empfaenger wurde kontaktiert und hat die Loeschung bestaetigt.',
category: 'data_breach',
severity: 'medium',
status: 'assessment',
detectedAt: new Date(now.getTime() - 18 * 60 * 60 * 1000).toISOString(), // 18 Stunden her
detectedBy: 'Vertriebsabteilung',
affectedSystems: ['E-Mail-System (Exchange)'],
affectedDataCategories: ['Personenbezogene Daten', 'Kundendaten'],
estimatedAffectedPersons: 150,
riskAssessment: {
id: 'ra-002',
assessedBy: 'DSB Mueller',
assessedAt: new Date(now.getTime() - 12 * 60 * 60 * 1000).toISOString(),
likelihoodScore: 3,
impactScore: 2,
overallRisk: 'medium',
notificationRequired: false,
reasoning: 'Empfaenger hat Loeschung bestaetigt. Datenkategorie: allgemeine Kontaktdaten und Vertragsnummern. Geringes Risiko fuer betroffene Personen.'
},
authorityNotification: {
id: 'an-002',
authority: 'LfD Niedersachsen',
deadline72h: new Date(new Date(now.getTime() - 18 * 60 * 60 * 1000).getTime() + 72 * 60 * 60 * 1000).toISOString(),
status: 'pending',
formData: {}
},
dataSubjectNotification: null,
measures: [
{
id: 'meas-001',
incidentId: 'inc-002',
title: 'Empfaenger kontaktiert',
description: 'Falscher Empfaenger kontaktiert mit Bitte um Loeschung',
type: 'immediate',
status: 'completed',
responsible: 'Vertriebsleitung',
dueDate: new Date(now.getTime() - 16 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(now.getTime() - 15 * 60 * 60 * 1000).toISOString()
}
],
timeline: [
{
id: 'tl-002',
incidentId: 'inc-002',
timestamp: new Date(now.getTime() - 18 * 60 * 60 * 1000).toISOString(),
action: 'Vorfall gemeldet',
description: 'Mitarbeiter meldet versehentlichen E-Mail-Versand',
performedBy: 'M. Schmidt (Vertrieb)'
},
{
id: 'tl-003',
incidentId: 'inc-002',
timestamp: new Date(now.getTime() - 15 * 60 * 60 * 1000).toISOString(),
action: 'Sofortmassnahme',
description: 'Empfaenger kontaktiert und Loeschung bestaetigt',
performedBy: 'Vertriebsleitung'
},
{
id: 'tl-004',
incidentId: 'inc-002',
timestamp: new Date(now.getTime() - 12 * 60 * 60 * 1000).toISOString(),
action: 'Risikobewertung',
description: 'Bewertung durchgefuehrt - mittleres Risiko, keine Meldepflicht',
performedBy: 'DSB Mueller'
}
],
assignedTo: 'DSB Mueller'
},
// 3. Gemeldet (notification_sent) - Ransomware-Angriff
{
id: 'inc-003',
referenceNumber: 'INC-2026-000003',
title: 'Ransomware-Angriff auf Dateiserver',
description: 'Am Montagmorgen wurde ein Ransomware-Angriff auf den zentralen Dateiserver erkannt. Mehrere verschluesselte Dateien wurden identifiziert. Der Angriffsvektor war eine Phishing-E-Mail an einen Mitarbeiter.',
category: 'ransomware',
severity: 'critical',
status: 'notification_sent',
detectedAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString(),
detectedBy: 'IT-Sicherheitsteam',
affectedSystems: ['Dateiserver (FS-01)', 'E-Mail-System', 'Backup-Server'],
affectedDataCategories: ['Personenbezogene Daten', 'Beschaeftigtendaten', 'Kundendaten', 'Finanzdaten'],
estimatedAffectedPersons: 2500,
riskAssessment: {
id: 'ra-003',
assessedBy: 'DSB Mueller',
assessedAt: new Date(now.getTime() - 4.5 * 24 * 60 * 60 * 1000).toISOString(),
likelihoodScore: 5,
impactScore: 5,
overallRisk: 'critical',
notificationRequired: true,
reasoning: 'Hohes Risiko fuer Rechte und Freiheiten der betroffenen Personen durch potentiellen Zugriff auf personenbezogene Daten und Finanzdaten. Verschluesselung betrifft Verfuegbarkeit, Exfiltration nicht auszuschliessen.'
},
authorityNotification: {
id: 'an-003',
authority: 'LfD Niedersachsen',
deadline72h: new Date(new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).getTime() + 72 * 60 * 60 * 1000).toISOString(),
submittedAt: new Date(now.getTime() - 4 * 24 * 60 * 60 * 1000).toISOString(),
status: 'submitted',
formData: {
referenceNumber: 'LfD-NI-2026-04821',
incidentType: 'Ransomware',
affectedPersons: 2500
},
pdfUrl: '/api/sdk/v1/incidents/inc-003/authority-form.pdf'
},
dataSubjectNotification: {
id: 'dsn-003',
notificationRequired: true,
templateText: 'Sehr geehrte Damen und Herren, wir informieren Sie ueber einen Sicherheitsvorfall, bei dem moeglicherweise Ihre personenbezogenen Daten betroffen sind...',
sentAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(),
recipientCount: 2500,
method: 'email'
},
measures: [
{
id: 'meas-002',
incidentId: 'inc-003',
title: 'Netzwerksegmentierung',
description: 'Betroffene Systeme vom Netzwerk isoliert',
type: 'immediate',
status: 'completed',
responsible: 'IT-Sicherheitsteam',
dueDate: new Date(now.getTime() - 4.8 * 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(now.getTime() - 4.9 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'meas-003',
incidentId: 'inc-003',
title: 'Passwoerter zuruecksetzen',
description: 'Alle Benutzerpasswoerter zurueckgesetzt',
type: 'immediate',
status: 'completed',
responsible: 'IT-Administration',
dueDate: new Date(now.getTime() - 4.5 * 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(now.getTime() - 4.5 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'meas-004',
incidentId: 'inc-003',
title: 'E-Mail-Security Gateway implementieren',
description: 'Implementierung eines fortgeschrittenen E-Mail-Sicherheitsgateways mit Sandboxing',
type: 'preventive',
status: 'in_progress',
responsible: 'IT-Sicherheitsteam',
dueDate: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'meas-005',
incidentId: 'inc-003',
title: 'Mitarbeiterschulung Phishing',
description: 'Verpflichtende Schulung fuer alle Mitarbeiter zum Thema Phishing-Erkennung',
type: 'preventive',
status: 'planned',
responsible: 'Personalwesen',
dueDate: new Date(now.getTime() + 60 * 24 * 60 * 60 * 1000).toISOString()
}
],
timeline: [
{
id: 'tl-005',
incidentId: 'inc-003',
timestamp: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString(),
action: 'Vorfall erkannt',
description: 'IT-Sicherheitsteam erkennt ungewoehnliche Verschluesselungsaktivitaet',
performedBy: 'IT-Sicherheitsteam'
},
{
id: 'tl-006',
incidentId: 'inc-003',
timestamp: new Date(now.getTime() - 4.9 * 24 * 60 * 60 * 1000).toISOString(),
action: 'Eindaemmung gestartet',
description: 'Netzwerksegmentierung und Isolation betroffener Systeme',
performedBy: 'IT-Sicherheitsteam'
},
{
id: 'tl-007',
incidentId: 'inc-003',
timestamp: new Date(now.getTime() - 4.5 * 24 * 60 * 60 * 1000).toISOString(),
action: 'Risikobewertung abgeschlossen',
description: 'Kritisches Risiko festgestellt - Meldepflicht ausgeloest',
performedBy: 'DSB Mueller'
},
{
id: 'tl-008',
incidentId: 'inc-003',
timestamp: new Date(now.getTime() - 4 * 24 * 60 * 60 * 1000).toISOString(),
action: 'Behoerdenbenachrichtigung',
description: 'Meldung an LfD Niedersachsen eingereicht',
performedBy: 'DSB Mueller'
},
{
id: 'tl-009',
incidentId: 'inc-003',
timestamp: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(),
action: 'Betroffene benachrichtigt',
description: '2.500 betroffene Personen per E-Mail informiert',
performedBy: 'Kommunikationsabteilung'
}
],
assignedTo: 'DSB Mueller'
},
// 4. Abgeschlossener Vorfall (closed) - Phishing
{
id: 'inc-004',
referenceNumber: 'INC-2026-000004',
title: 'Phishing-Angriff auf Personalabteilung',
description: 'Gezielter Phishing-Angriff auf die Personalabteilung. Ein Mitarbeiter hat Zugangsdaten auf einer gefaelschten Login-Seite eingegeben. Das Konto wurde sofort gesperrt. Keine Datenexfiltration festgestellt.',
category: 'phishing',
severity: 'high',
status: 'closed',
detectedAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(),
detectedBy: 'IT-Sicherheitsteam (SIEM-Alert)',
affectedSystems: ['Active Directory', 'HR-Portal'],
affectedDataCategories: ['Beschaeftigtendaten', 'Personenbezogene Daten'],
estimatedAffectedPersons: 0,
riskAssessment: {
id: 'ra-004',
assessedBy: 'DSB Mueller',
assessedAt: new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000).toISOString(),
likelihoodScore: 4,
impactScore: 3,
overallRisk: 'high',
notificationRequired: true,
reasoning: 'Zugangsdaten kompromittiert, potentieller Zugriff auf Personaldaten. Keine Exfiltration festgestellt, dennoch Meldung wegen Kompromittierung der Zugangsdaten.'
},
authorityNotification: {
id: 'an-004',
authority: 'LfD Niedersachsen',
deadline72h: new Date(new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).getTime() + 72 * 60 * 60 * 1000).toISOString(),
submittedAt: new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000).toISOString(),
status: 'acknowledged',
formData: {
referenceNumber: 'LfD-NI-2026-03912',
incidentType: 'Phishing',
affectedPersons: 0
}
},
dataSubjectNotification: {
id: 'dsn-004',
notificationRequired: false,
templateText: '',
recipientCount: 0,
method: 'email'
},
measures: [
{
id: 'meas-006',
incidentId: 'inc-004',
title: 'Konto gesperrt',
description: 'Kompromittiertes Benutzerkonto sofort gesperrt',
type: 'immediate',
status: 'completed',
responsible: 'IT-Administration',
dueDate: new Date(now.getTime() - 29.8 * 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(now.getTime() - 29.9 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'meas-007',
incidentId: 'inc-004',
title: 'MFA fuer alle Mitarbeiter',
description: 'Einfuehrung von Multi-Faktor-Authentifizierung fuer alle Konten',
type: 'preventive',
status: 'completed',
responsible: 'IT-Sicherheitsteam',
dueDate: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000).toISOString()
}
],
timeline: [
{
id: 'tl-010',
incidentId: 'inc-004',
timestamp: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(),
action: 'SIEM-Alert',
description: 'Verdaechtiger Login-Versuch aus unbekannter Region erkannt',
performedBy: 'IT-Sicherheitsteam'
},
{
id: 'tl-011',
incidentId: 'inc-004',
timestamp: new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000).toISOString(),
action: 'Behoerdenbenachrichtigung',
description: 'Meldung an LfD Niedersachsen',
performedBy: 'DSB Mueller'
},
{
id: 'tl-012',
incidentId: 'inc-004',
timestamp: new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000).toISOString(),
action: 'Vorfall abgeschlossen',
description: 'Alle Massnahmen umgesetzt, keine Datenexfiltration festgestellt',
performedBy: 'DSB Mueller'
}
],
assignedTo: 'DSB Mueller',
closedAt: new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000).toISOString(),
lessonsLearned: '1. MFA haette den Zugriff verhindert (jetzt implementiert). 2. E-Mail-Security-Gateway muss verbesserte Phishing-Erkennung erhalten. 3. Regelmaessige Phishing-Simulationen fuer alle Mitarbeiter einfuehren.'
}
]
}
/**
* Erstellt Mock-Statistiken fuer die Entwicklung
*/
export function createMockStatistics(): IncidentStatistics {
return {
totalIncidents: 4,
openIncidents: 3,
notificationsPending: 1,
averageResponseTimeHours: 8.5,
bySeverity: {
low: 0,
medium: 1,
high: 2,
critical: 1
},
byCategory: {
data_breach: 1,
unauthorized_access: 1,
data_loss: 0,
system_compromise: 0,
phishing: 1,
ransomware: 1,
insider_threat: 0,
physical_breach: 0,
other: 0
},
byStatus: {
detected: 1,
assessment: 1,
containment: 0,
notification_required: 0,
notification_sent: 1,
remediation: 0,
closed: 1
}
}
}

View File

@@ -0,0 +1,447 @@
/**
* Incident/Breach Management Types (Datenpannen-Management)
*
* TypeScript definitions for DSGVO Art. 33/34 Incident & Data Breach Management
* 72-Stunden-Meldefrist an die Aufsichtsbehoerde
*/
// =============================================================================
// ENUMS & CONSTANTS
// =============================================================================
export type IncidentSeverity = 'low' | 'medium' | 'high' | 'critical'
export type IncidentStatus =
| 'detected' // Erkannt
| 'assessment' // Bewertung laeuft
| 'containment' // Eindaemmung
| 'notification_required' // Meldepflichtig - Meldung steht aus
| 'notification_sent' // Gemeldet an Aufsichtsbehoerde
| 'remediation' // Behebung laeuft
| 'closed' // Abgeschlossen
export type IncidentCategory =
| 'data_breach' // Datenpanne / Datenschutzverletzung
| 'unauthorized_access' // Unbefugter Zugriff
| 'data_loss' // Datenverlust
| 'system_compromise' // Systemkompromittierung
| 'phishing' // Phishing-Angriff
| 'ransomware' // Ransomware
| 'insider_threat' // Insider-Bedrohung
| 'physical_breach' // Physischer Sicherheitsvorfall
| 'other' // Sonstiges
// =============================================================================
// SEVERITY METADATA
// =============================================================================
export interface IncidentSeverityInfo {
label: string
description: string
color: string
bgColor: string
}
export const INCIDENT_SEVERITY_INFO: Record<IncidentSeverity, IncidentSeverityInfo> = {
low: {
label: 'Niedrig',
description: 'Geringes Risiko fuer betroffene Personen, keine Meldepflicht erwartet',
color: 'text-green-700',
bgColor: 'bg-green-100'
},
medium: {
label: 'Mittel',
description: 'Moderates Risiko, Meldepflicht an Aufsichtsbehoerde moeglich',
color: 'text-yellow-700',
bgColor: 'bg-yellow-100'
},
high: {
label: 'Hoch',
description: 'Hohes Risiko, Meldepflicht an Aufsichtsbehoerde wahrscheinlich',
color: 'text-orange-700',
bgColor: 'bg-orange-100'
},
critical: {
label: 'Kritisch',
description: 'Sehr hohes Risiko, Meldepflicht an Aufsichtsbehoerde und Betroffene',
color: 'text-red-700',
bgColor: 'bg-red-100'
}
}
// =============================================================================
// STATUS METADATA
// =============================================================================
export interface IncidentStatusInfo {
label: string
description: string
color: string
bgColor: string
}
export const INCIDENT_STATUS_INFO: Record<IncidentStatus, IncidentStatusInfo> = {
detected: {
label: 'Erkannt',
description: 'Vorfall wurde erkannt und dokumentiert',
color: 'text-blue-700',
bgColor: 'bg-blue-100'
},
assessment: {
label: 'Bewertung',
description: 'Risikobewertung und Einschaetzung der Meldepflicht',
color: 'text-yellow-700',
bgColor: 'bg-yellow-100'
},
containment: {
label: 'Eindaemmung',
description: 'Sofortmassnahmen zur Eindaemmung werden durchgefuehrt',
color: 'text-orange-700',
bgColor: 'bg-orange-100'
},
notification_required: {
label: 'Meldepflichtig',
description: 'Meldung an Aufsichtsbehoerde erforderlich (Art. 33 DSGVO)',
color: 'text-red-700',
bgColor: 'bg-red-100'
},
notification_sent: {
label: 'Gemeldet',
description: 'Meldung an die Aufsichtsbehoerde wurde eingereicht',
color: 'text-purple-700',
bgColor: 'bg-purple-100'
},
remediation: {
label: 'Behebung',
description: 'Langfristige Behebungs- und Praeventionsmassnahmen',
color: 'text-indigo-700',
bgColor: 'bg-indigo-100'
},
closed: {
label: 'Abgeschlossen',
description: 'Vorfall vollstaendig bearbeitet und dokumentiert',
color: 'text-green-700',
bgColor: 'bg-green-100'
}
}
// =============================================================================
// CATEGORY METADATA
// =============================================================================
export interface IncidentCategoryInfo {
label: string
description: string
icon: string
color: string
bgColor: string
}
export const INCIDENT_CATEGORY_INFO: Record<IncidentCategory, IncidentCategoryInfo> = {
data_breach: {
label: 'Datenpanne',
description: 'Allgemeine Datenschutzverletzung mit Offenlegung personenbezogener Daten',
icon: '\u{1F4C4}',
color: 'text-red-700',
bgColor: 'bg-red-100'
},
unauthorized_access: {
label: 'Unbefugter Zugriff',
description: 'Unberechtigter Zugriff auf Systeme oder Daten',
icon: '\u{1F6AB}',
color: 'text-red-700',
bgColor: 'bg-red-100'
},
data_loss: {
label: 'Datenverlust',
description: 'Verlust von Daten durch technischen Fehler oder Versehen',
icon: '\u{1F4BE}',
color: 'text-orange-700',
bgColor: 'bg-orange-100'
},
system_compromise: {
label: 'Systemkompromittierung',
description: 'System wurde durch Angreifer kompromittiert',
icon: '\u{1F4BB}',
color: 'text-red-700',
bgColor: 'bg-red-100'
},
phishing: {
label: 'Phishing-Angriff',
description: 'Taeuschendes Abfangen von Zugangsdaten oder Daten',
icon: '\u{1F3A3}',
color: 'text-orange-700',
bgColor: 'bg-orange-100'
},
ransomware: {
label: 'Ransomware',
description: 'Verschluesselung von Daten durch Schadsoftware',
icon: '\u{1F512}',
color: 'text-red-700',
bgColor: 'bg-red-100'
},
insider_threat: {
label: 'Insider-Bedrohung',
description: 'Vorsaetzlicher oder fahrlaessiger Verstoss durch Mitarbeiter',
icon: '\u{1F464}',
color: 'text-purple-700',
bgColor: 'bg-purple-100'
},
physical_breach: {
label: 'Physischer Sicherheitsvorfall',
description: 'Einbruch, Diebstahl von Geraeten oder physische Zugriffe',
icon: '\u{1F3E2}',
color: 'text-gray-700',
bgColor: 'bg-gray-100'
},
other: {
label: 'Sonstiges',
description: 'Sonstiger Datenschutzvorfall',
icon: '\u{2753}',
color: 'text-gray-700',
bgColor: 'bg-gray-100'
}
}
// =============================================================================
// MAIN INTERFACES
// =============================================================================
export interface RiskAssessment {
id: string
assessedBy: string
assessedAt: string
likelihoodScore: number // 1-5 (1 = sehr unwahrscheinlich, 5 = sehr wahrscheinlich)
impactScore: number // 1-5 (1 = gering, 5 = katastrophal)
overallRisk: IncidentSeverity // Berechnetes Gesamtrisiko
notificationRequired: boolean // Art. 33 Bewertung
reasoning: string // Begruendung der Bewertung
}
export interface AuthorityNotification {
id: string
authority: string // z.B. "LfD Niedersachsen"
deadline72h: string // 72 Stunden nach Erkennung (Art. 33)
submittedAt?: string
status: 'pending' | 'submitted' | 'acknowledged'
formData: Record<string, unknown>
pdfUrl?: string
}
export interface DataSubjectNotification {
id: string
notificationRequired: boolean // Art. 34 Bewertung
templateText: string
sentAt?: string
recipientCount: number
method: 'email' | 'letter' | 'portal' | 'public'
}
export interface IncidentMeasure {
id: string
incidentId: string
title: string
description: string
type: 'immediate' | 'corrective' | 'preventive'
status: 'planned' | 'in_progress' | 'completed'
responsible: string
dueDate: string
completedAt?: string
}
export interface TimelineEntry {
id: string
incidentId: string
timestamp: string
action: string
description: string
performedBy: string
}
export interface Incident {
id: string
referenceNumber: string // z.B. "INC-2025-000001"
title: string
description: string
category: IncidentCategory
severity: IncidentSeverity
status: IncidentStatus
// Erkennung
detectedAt: string
detectedBy: string
// Betroffene Systeme & Daten
affectedSystems: string[]
affectedDataCategories: string[]
estimatedAffectedPersons: number
// Risikobewertung
riskAssessment: RiskAssessment | null
// Meldungen
authorityNotification: AuthorityNotification | null
dataSubjectNotification: DataSubjectNotification | null
// Massnahmen & Verlauf
measures: IncidentMeasure[]
timeline: TimelineEntry[]
// Zuweisung
assignedTo?: string
// Abschluss
closedAt?: string
lessonsLearned?: string
}
// =============================================================================
// STATISTICS
// =============================================================================
export interface IncidentStatistics {
totalIncidents: number
openIncidents: number
notificationsPending: number
averageResponseTimeHours: number
bySeverity: Record<IncidentSeverity, number>
byCategory: Record<IncidentCategory, number>
byStatus: Record<IncidentStatus, number>
}
// =============================================================================
// API TYPES
// =============================================================================
export interface IncidentFilters {
status?: IncidentStatus | IncidentStatus[]
severity?: IncidentSeverity | IncidentSeverity[]
category?: IncidentCategory | IncidentCategory[]
assignedTo?: string
overdue?: boolean
search?: string
dateFrom?: string
dateTo?: string
}
export interface IncidentListResponse {
incidents: Incident[]
total: number
page: number
pageSize: number
}
export interface IncidentCreateRequest {
title: string
description: string
category: IncidentCategory
severity: IncidentSeverity
detectedAt: string
detectedBy: string
affectedSystems: string[]
affectedDataCategories: string[]
estimatedAffectedPersons: number
assignedTo?: string
}
export interface IncidentUpdateRequest {
title?: string
description?: string
category?: IncidentCategory
severity?: IncidentSeverity
status?: IncidentStatus
affectedSystems?: string[]
affectedDataCategories?: string[]
estimatedAffectedPersons?: number
assignedTo?: string
}
export interface RiskAssessmentRequest {
likelihoodScore: number // 1-5
impactScore: number // 1-5
reasoning: string
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
/**
* Berechnet die verbleibenden Stunden bis zur 72h-Meldefrist (Art. 33 DSGVO)
*/
export function getHoursUntil72hDeadline(detectedAt: string): number {
const detected = new Date(detectedAt)
const deadline = new Date(detected.getTime() + 72 * 60 * 60 * 1000)
const now = new Date()
const diff = deadline.getTime() - now.getTime()
return Math.round(diff / (1000 * 60 * 60) * 10) / 10
}
/**
* Prueft ob die 72-Stunden-Meldefrist abgelaufen ist
*/
export function is72hDeadlineExpired(detectedAt: string): boolean {
const detected = new Date(detectedAt)
const deadline = new Date(detected.getTime() + 72 * 60 * 60 * 1000)
return new Date() > deadline
}
/**
* Berechnet die Risikostufe basierend auf Eintrittswahrscheinlichkeit und Auswirkung
* Risiko-Matrix:
* likelihood x impact >= 20 -> critical
* likelihood x impact >= 12 -> high
* likelihood x impact >= 6 -> medium
* sonst -> low
*/
export function calculateRiskLevel(likelihood: number, impact: number): IncidentSeverity {
const riskScore = likelihood * impact
if (riskScore >= 20) return 'critical'
if (riskScore >= 12) return 'high'
if (riskScore >= 6) return 'medium'
return 'low'
}
/**
* Prueft ob eine Meldung an die Aufsichtsbehoerde erforderlich ist
* Bei hohem oder kritischem Risiko ist eine Meldung gemaess Art. 33 DSGVO erforderlich
*/
export function isNotificationRequired(riskAssessment: RiskAssessment): boolean {
return riskAssessment.overallRisk === 'high' || riskAssessment.overallRisk === 'critical'
}
/**
* Generiert eine Referenznummer fuer einen Vorfall
*/
export function generateIncidentReferenceNumber(year: number, sequence: number): string {
return `INC-${year}-${String(sequence).padStart(6, '0')}`
}
/**
* Gibt die 72h-Deadline als Date zurueck
*/
export function get72hDeadline(detectedAt: string): Date {
const detected = new Date(detectedAt)
return new Date(detected.getTime() + 72 * 60 * 60 * 1000)
}
/**
* Gibt die Severity-Info zurueck
*/
export function getSeverityInfo(severity: IncidentSeverity): IncidentSeverityInfo {
return INCIDENT_SEVERITY_INFO[severity]
}
/**
* Gibt die Status-Info zurueck
*/
export function getStatusInfo(status: IncidentStatus): IncidentStatusInfo {
return INCIDENT_STATUS_INFO[status]
}
/**
* Gibt die Kategorie-Info zurueck
*/
export function getCategoryInfo(category: IncidentCategory): IncidentCategoryInfo {
return INCIDENT_CATEGORY_INFO[category]
}

View File

@@ -0,0 +1,755 @@
/**
* Whistleblower System API Client
*
* API client for Hinweisgeberschutzgesetz (HinSchG) compliant
* Whistleblower/Hinweisgebersystem management
* Connects to the ai-compliance-sdk backend
*/
import {
WhistleblowerReport,
WhistleblowerStatistics,
ReportListResponse,
ReportFilters,
PublicReportSubmission,
ReportUpdateRequest,
MessageSendRequest,
AnonymousMessage,
WhistleblowerMeasure,
FileAttachment,
ReportCategory,
ReportStatus,
ReportPriority,
generateAccessKey
} from './types'
// =============================================================================
// CONFIGURATION
// =============================================================================
const WB_API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'http://localhost:8093'
const API_TIMEOUT = 30000 // 30 seconds
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function getTenantId(): string {
if (typeof window !== 'undefined') {
return localStorage.getItem('bp_tenant_id') || 'default-tenant'
}
return 'default-tenant'
}
function getAuthHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
'X-Tenant-ID': getTenantId()
}
if (typeof window !== 'undefined') {
const token = localStorage.getItem('authToken')
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const userId = localStorage.getItem('bp_user_id')
if (userId) {
headers['X-User-ID'] = userId
}
}
return headers
}
async function fetchWithTimeout<T>(
url: string,
options: RequestInit = {},
timeout: number = API_TIMEOUT
): Promise<T> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
...getAuthHeaders(),
...options.headers
}
})
if (!response.ok) {
const errorBody = await response.text()
let errorMessage = `HTTP ${response.status}: ${response.statusText}`
try {
const errorJson = JSON.parse(errorBody)
errorMessage = errorJson.error || errorJson.message || errorMessage
} catch {
// Keep the HTTP status message
}
throw new Error(errorMessage)
}
// Handle empty responses
const contentType = response.headers.get('content-type')
if (contentType && contentType.includes('application/json')) {
return response.json()
}
return {} as T
} finally {
clearTimeout(timeoutId)
}
}
// =============================================================================
// ADMIN CRUD - Reports
// =============================================================================
/**
* Alle Meldungen abrufen (Admin)
*/
export async function fetchReports(filters?: ReportFilters): Promise<ReportListResponse> {
const params = new URLSearchParams()
if (filters) {
if (filters.status) {
const statuses = Array.isArray(filters.status) ? filters.status : [filters.status]
statuses.forEach(s => params.append('status', s))
}
if (filters.category) {
const categories = Array.isArray(filters.category) ? filters.category : [filters.category]
categories.forEach(c => params.append('category', c))
}
if (filters.priority) params.set('priority', filters.priority)
if (filters.assignedTo) params.set('assignedTo', filters.assignedTo)
if (filters.isAnonymous !== undefined) params.set('isAnonymous', String(filters.isAnonymous))
if (filters.search) params.set('search', filters.search)
if (filters.dateFrom) params.set('dateFrom', filters.dateFrom)
if (filters.dateTo) params.set('dateTo', filters.dateTo)
}
const queryString = params.toString()
const url = `${WB_API_BASE}/api/v1/admin/whistleblower/reports${queryString ? `?${queryString}` : ''}`
return fetchWithTimeout<ReportListResponse>(url)
}
/**
* Einzelne Meldung abrufen (Admin)
*/
export async function fetchReport(id: string): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}`
)
}
/**
* Meldung aktualisieren (Status, Prioritaet, Kategorie, Zuweisung)
*/
export async function updateReport(id: string, update: ReportUpdateRequest): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}`,
{
method: 'PUT',
body: JSON.stringify(update)
}
)
}
/**
* Meldung loeschen (soft delete)
*/
export async function deleteReport(id: string): Promise<void> {
await fetchWithTimeout<void>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}`,
{
method: 'DELETE'
}
)
}
// =============================================================================
// PUBLIC ENDPOINTS - Kein Auth erforderlich
// =============================================================================
/**
* Neue Meldung einreichen (oeffentlich, keine Auth)
*/
export async function submitPublicReport(
data: PublicReportSubmission
): Promise<{ report: WhistleblowerReport; accessKey: string }> {
const response = await fetch(
`${WB_API_BASE}/api/v1/public/whistleblower/submit`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}
)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return response.json()
}
/**
* Meldung ueber Zugangscode abrufen (oeffentlich, keine Auth)
*/
export async function fetchReportByAccessKey(
accessKey: string
): Promise<WhistleblowerReport> {
const response = await fetch(
`${WB_API_BASE}/api/v1/public/whistleblower/report/${accessKey}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' }
}
)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return response.json()
}
// =============================================================================
// WORKFLOW ACTIONS
// =============================================================================
/**
* Eingangsbestaetigung versenden (HinSchG ss 17 Abs. 1)
*/
export async function acknowledgeReport(id: string): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/acknowledge`,
{
method: 'POST'
}
)
}
/**
* Untersuchung starten
*/
export async function startInvestigation(id: string): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/investigate`,
{
method: 'POST'
}
)
}
/**
* Massnahme zu einer Meldung hinzufuegen
*/
export async function addMeasure(
id: string,
measure: Omit<WhistleblowerMeasure, 'id' | 'reportId' | 'completedAt'>
): Promise<WhistleblowerMeasure> {
return fetchWithTimeout<WhistleblowerMeasure>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/measures`,
{
method: 'POST',
body: JSON.stringify(measure)
}
)
}
/**
* Meldung abschliessen mit Begruendung
*/
export async function closeReport(
id: string,
resolution: { reason: string; notes: string }
): Promise<WhistleblowerReport> {
return fetchWithTimeout<WhistleblowerReport>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${id}/close`,
{
method: 'POST',
body: JSON.stringify(resolution)
}
)
}
// =============================================================================
// ANONYMOUS MESSAGING
// =============================================================================
/**
* Nachricht im anonymen Kanal senden
*/
export async function sendMessage(
reportId: string,
message: string,
role: 'reporter' | 'ombudsperson'
): Promise<AnonymousMessage> {
return fetchWithTimeout<AnonymousMessage>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${reportId}/messages`,
{
method: 'POST',
body: JSON.stringify({ senderRole: role, message })
}
)
}
/**
* Nachrichten fuer eine Meldung abrufen
*/
export async function fetchMessages(reportId: string): Promise<AnonymousMessage[]> {
return fetchWithTimeout<AnonymousMessage[]>(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${reportId}/messages`
)
}
// =============================================================================
// ATTACHMENTS
// =============================================================================
/**
* Anhang zu einer Meldung hochladen
*/
export async function uploadAttachment(
reportId: string,
file: File
): Promise<FileAttachment> {
const formData = new FormData()
formData.append('file', file)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 60000) // 60s for uploads
try {
const headers: HeadersInit = {
'X-Tenant-ID': getTenantId()
}
if (typeof window !== 'undefined') {
const token = localStorage.getItem('authToken')
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
}
const response = await fetch(
`${WB_API_BASE}/api/v1/admin/whistleblower/reports/${reportId}/attachments`,
{
method: 'POST',
headers,
body: formData,
signal: controller.signal
}
)
if (!response.ok) {
throw new Error(`Upload fehlgeschlagen: ${response.statusText}`)
}
return response.json()
} finally {
clearTimeout(timeoutId)
}
}
/**
* Anhang loeschen
*/
export async function deleteAttachment(id: string): Promise<void> {
await fetchWithTimeout<void>(
`${WB_API_BASE}/api/v1/admin/whistleblower/attachments/${id}`,
{
method: 'DELETE'
}
)
}
// =============================================================================
// STATISTICS
// =============================================================================
/**
* Statistiken fuer das Whistleblower-Dashboard abrufen
*/
export async function fetchWhistleblowerStatistics(): Promise<WhistleblowerStatistics> {
return fetchWithTimeout<WhistleblowerStatistics>(
`${WB_API_BASE}/api/v1/admin/whistleblower/statistics`
)
}
// =============================================================================
// SDK PROXY FUNCTION (via Next.js proxy)
// =============================================================================
/**
* Fetch Whistleblower-Daten via SDK Proxy mit Fallback auf Mock-Daten
*/
export async function fetchSDKWhistleblowerList(): Promise<{
reports: WhistleblowerReport[]
statistics: WhistleblowerStatistics
}> {
try {
const [reportsResponse, statsResponse] = await Promise.all([
fetchReports(),
fetchWhistleblowerStatistics()
])
return {
reports: reportsResponse.reports,
statistics: statsResponse
}
} catch (error) {
console.error('Failed to load Whistleblower data from API, using mock data:', error)
// Fallback to mock data
const reports = createMockReports()
const statistics = createMockStatistics()
return { reports, statistics }
}
}
// =============================================================================
// MOCK DATA (Demo/Entwicklung)
// =============================================================================
/**
* Erstellt Demo-Meldungen fuer Entwicklung und Praesentationen
*/
export function createMockReports(): WhistleblowerReport[] {
const now = new Date()
// Helper: Berechne Fristen
function calcDeadlines(receivedAt: Date): { ack: string; fb: string } {
const ack = new Date(receivedAt)
ack.setDate(ack.getDate() + 7)
const fb = new Date(receivedAt)
fb.setMonth(fb.getMonth() + 3)
return { ack: ack.toISOString(), fb: fb.toISOString() }
}
const received1 = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000)
const deadlines1 = calcDeadlines(received1)
const received2 = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000)
const deadlines2 = calcDeadlines(received2)
const received3 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
const deadlines3 = calcDeadlines(received3)
const received4 = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000)
const deadlines4 = calcDeadlines(received4)
return [
// Report 1: Neu
{
id: 'wb-001',
referenceNumber: 'WB-2026-000001',
accessKey: generateAccessKey(),
category: 'corruption',
status: 'new',
priority: 'high',
title: 'Unregelmaessigkeiten bei Auftragsvergabe',
description: 'Bei der Vergabe des IT-Rahmenvertrags im November wurden offenbar Angebote eines bestimmten Anbieters bevorzugt. Der zustaendige Abteilungsleiter hat private Verbindungen zum Geschaeftsfuehrer des Anbieters.',
isAnonymous: true,
receivedAt: received1.toISOString(),
deadlineAcknowledgment: deadlines1.ack,
deadlineFeedback: deadlines1.fb,
measures: [],
messages: [],
attachments: [],
auditTrail: [
{
id: 'audit-001',
action: 'report_created',
description: 'Meldung ueber Online-Meldeformular eingegangen',
performedBy: 'system',
performedAt: received1.toISOString()
}
]
},
// Report 2: In Pruefung (under_review)
{
id: 'wb-002',
referenceNumber: 'WB-2026-000002',
accessKey: generateAccessKey(),
category: 'data_protection',
status: 'under_review',
priority: 'normal',
title: 'Unerlaubte Weitergabe von Kundendaten',
description: 'Ein Mitarbeiter der Vertriebsabteilung gibt regelmaessig Kundenlisten an externe Dienstleister weiter, ohne dass eine Auftragsverarbeitungsvereinbarung vorliegt.',
isAnonymous: false,
reporterName: 'Maria Schmidt',
reporterEmail: 'maria.schmidt@example.de',
assignedTo: 'DSB Mueller',
receivedAt: received2.toISOString(),
acknowledgedAt: new Date(received2.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString(),
deadlineAcknowledgment: deadlines2.ack,
deadlineFeedback: deadlines2.fb,
measures: [],
messages: [
{
id: 'msg-001',
reportId: 'wb-002',
senderRole: 'ombudsperson',
message: 'Vielen Dank fuer Ihre Meldung. Koennen Sie uns mitteilen, welche Dienstleister konkret betroffen sind?',
createdAt: new Date(received2.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString(),
isRead: true
},
{
id: 'msg-002',
reportId: 'wb-002',
senderRole: 'reporter',
message: 'Es handelt sich um die Firma DataServ GmbH und MarketPro AG. Die Listen werden per unverschluesselter E-Mail versendet.',
createdAt: new Date(received2.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
isRead: true
}
],
attachments: [
{
id: 'att-001',
fileName: 'email_screenshot_vertrieb.png',
fileSize: 245000,
mimeType: 'image/png',
uploadedAt: received2.toISOString(),
uploadedBy: 'reporter'
}
],
auditTrail: [
{
id: 'audit-002',
action: 'report_created',
description: 'Meldung per E-Mail eingegangen',
performedBy: 'system',
performedAt: received2.toISOString()
},
{
id: 'audit-003',
action: 'acknowledged',
description: 'Eingangsbestaetigung an Hinweisgeber versendet',
performedBy: 'DSB Mueller',
performedAt: new Date(received2.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'audit-004',
action: 'status_changed',
description: 'Status geaendert: Bestaetigt -> In Pruefung',
performedBy: 'DSB Mueller',
performedAt: new Date(received2.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString()
}
]
},
// Report 3: Untersuchung (investigation)
{
id: 'wb-003',
referenceNumber: 'WB-2026-000003',
accessKey: generateAccessKey(),
category: 'product_safety',
status: 'investigation',
priority: 'critical',
title: 'Fehlende Sicherheitspruefungen bei Produktfreigabe',
description: 'In der Fertigung werden seit Wochen Produkte ohne die vorgeschriebenen Sicherheitspruefungen freigegeben. Pruefprotokolle werden nachtraeglich erstellt, ohne dass tatsaechliche Pruefungen stattfinden.',
isAnonymous: true,
assignedTo: 'Qualitaetsbeauftragter Weber',
receivedAt: received3.toISOString(),
acknowledgedAt: new Date(received3.getTime() + 2 * 24 * 60 * 60 * 1000).toISOString(),
deadlineAcknowledgment: deadlines3.ack,
deadlineFeedback: deadlines3.fb,
measures: [
{
id: 'msr-001',
reportId: 'wb-003',
title: 'Sofortiger Produktionsstopp fuer betroffene Charge',
description: 'Produktion der betroffenen Produktlinie stoppen bis Pruefverfahren sichergestellt ist',
status: 'completed',
responsible: 'Fertigungsleitung',
dueDate: new Date(received3.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(received3.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'msr-002',
reportId: 'wb-003',
title: 'Externe Pruefung der Pruefprotokolle',
description: 'Unabhaengige Pruefstelle mit der Revision aller Pruefprotokolle der letzten 6 Monate beauftragen',
status: 'in_progress',
responsible: 'Qualitaetsmanagement',
dueDate: new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000).toISOString()
}
],
messages: [],
attachments: [
{
id: 'att-002',
fileName: 'pruefprotokoll_vergleich.pdf',
fileSize: 890000,
mimeType: 'application/pdf',
uploadedAt: new Date(received3.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString(),
uploadedBy: 'ombudsperson'
}
],
auditTrail: [
{
id: 'audit-005',
action: 'report_created',
description: 'Meldung ueber Online-Meldeformular eingegangen',
performedBy: 'system',
performedAt: received3.toISOString()
},
{
id: 'audit-006',
action: 'acknowledged',
description: 'Eingangsbestaetigung versendet',
performedBy: 'Qualitaetsbeauftragter Weber',
performedAt: new Date(received3.getTime() + 2 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'audit-007',
action: 'investigation_started',
description: 'Formelle Untersuchung eingeleitet',
performedBy: 'Qualitaetsbeauftragter Weber',
performedAt: new Date(received3.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString()
}
]
},
// Report 4: Abgeschlossen (closed)
{
id: 'wb-004',
referenceNumber: 'WB-2026-000004',
accessKey: generateAccessKey(),
category: 'fraud',
status: 'closed',
priority: 'high',
title: 'Gefaelschte Reisekostenabrechnungen',
description: 'Ein leitender Mitarbeiter reicht seit ueber einem Jahr gefaelschte Reisekostenabrechnungen ein. Hotelrechnungen werden manipuliert, Taxiquittungen erfunden.',
isAnonymous: false,
reporterName: 'Thomas Klein',
reporterEmail: 'thomas.klein@example.de',
reporterPhone: '+49 170 9876543',
assignedTo: 'Compliance-Abteilung',
receivedAt: received4.toISOString(),
acknowledgedAt: new Date(received4.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString(),
deadlineAcknowledgment: deadlines4.ack,
deadlineFeedback: deadlines4.fb,
closedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(),
measures: [
{
id: 'msr-003',
reportId: 'wb-004',
title: 'Interne Revision der Reisekosten',
description: 'Pruefung aller Reisekostenabrechnungen des betroffenen Mitarbeiters der letzten 24 Monate',
status: 'completed',
responsible: 'Interne Revision',
dueDate: new Date(received4.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(received4.getTime() + 25 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'msr-004',
reportId: 'wb-004',
title: 'Arbeitsrechtliche Konsequenzen',
description: 'Einleitung arbeitsrechtlicher Schritte nach Bestaetigung des Betrugs',
status: 'completed',
responsible: 'Personalabteilung',
dueDate: new Date(received4.getTime() + 60 * 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(received4.getTime() + 55 * 24 * 60 * 60 * 1000).toISOString()
}
],
messages: [],
attachments: [
{
id: 'att-003',
fileName: 'vergleich_originalrechnung_einreichung.pdf',
fileSize: 567000,
mimeType: 'application/pdf',
uploadedAt: received4.toISOString(),
uploadedBy: 'reporter'
}
],
auditTrail: [
{
id: 'audit-008',
action: 'report_created',
description: 'Meldung per Brief eingegangen',
performedBy: 'system',
performedAt: received4.toISOString()
},
{
id: 'audit-009',
action: 'acknowledged',
description: 'Eingangsbestaetigung versendet',
performedBy: 'Compliance-Abteilung',
performedAt: new Date(received4.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString()
},
{
id: 'audit-010',
action: 'closed',
description: 'Fall abgeschlossen - Betrug bestaetigt, arbeitsrechtliche Massnahmen eingeleitet',
performedBy: 'Compliance-Abteilung',
performedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString()
}
]
}
]
}
/**
* Berechnet Statistiken aus den Mock-Daten
*/
export function createMockStatistics(): WhistleblowerStatistics {
const reports = createMockReports()
const now = new Date()
const byStatus: Record<ReportStatus, number> = {
new: 0,
acknowledged: 0,
under_review: 0,
investigation: 0,
measures_taken: 0,
closed: 0,
rejected: 0
}
const byCategory: Record<ReportCategory, number> = {
corruption: 0,
fraud: 0,
data_protection: 0,
discrimination: 0,
environment: 0,
competition: 0,
product_safety: 0,
tax_evasion: 0,
other: 0
}
reports.forEach(r => {
byStatus[r.status]++
byCategory[r.category]++
})
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
// Pruefe ueberfaellige Eingangsbestaetigungen
const overdueAcknowledgment = reports.filter(r => {
if (r.status !== 'new') return false
return now > new Date(r.deadlineAcknowledgment)
}).length
// Pruefe ueberfaellige Rueckmeldungen
const overdueFeedback = reports.filter(r => {
if (closedStatuses.includes(r.status)) return false
return now > new Date(r.deadlineFeedback)
}).length
return {
totalReports: reports.length,
newReports: byStatus.new,
underReview: byStatus.under_review + byStatus.investigation,
closed: byStatus.closed + byStatus.rejected,
overdueAcknowledgment,
overdueFeedback,
byCategory,
byStatus
}
}

View File

@@ -0,0 +1,381 @@
/**
* Whistleblower System (Hinweisgebersystem) Types
*
* TypeScript definitions for Hinweisgeberschutzgesetz (HinSchG)
* compliant Whistleblower/Hinweisgebersystem module
*/
// =============================================================================
// ENUMS & CONSTANTS
// =============================================================================
export type ReportCategory =
| 'corruption' // Korruption
| 'fraud' // Betrug
| 'data_protection' // Datenschutz
| 'discrimination' // Diskriminierung
| 'environment' // Umwelt
| 'competition' // Wettbewerb
| 'product_safety' // Produktsicherheit
| 'tax_evasion' // Steuerhinterziehung
| 'other' // Sonstiges
export type ReportStatus =
| 'new' // Neu eingegangen
| 'acknowledged' // Eingangsbestaetigung versendet
| 'under_review' // In Pruefung
| 'investigation' // Untersuchung laeuft
| 'measures_taken' // Massnahmen ergriffen
| 'closed' // Abgeschlossen
| 'rejected' // Abgelehnt
export type ReportPriority = 'low' | 'normal' | 'high' | 'critical'
// =============================================================================
// REPORT CATEGORY METADATA
// =============================================================================
export interface ReportCategoryInfo {
category: ReportCategory
label: string
description: string
icon: string
color: string
bgColor: string
}
export const REPORT_CATEGORY_INFO: Record<ReportCategory, ReportCategoryInfo> = {
corruption: {
category: 'corruption',
label: 'Korruption',
description: 'Bestechung, Bestechlichkeit, Vorteilsnahme oder Vorteilsgewaehrung',
icon: '\u{1F4B0}',
color: 'text-red-700',
bgColor: 'bg-red-100'
},
fraud: {
category: 'fraud',
label: 'Betrug',
description: 'Betrug, Untreue, Urkundenfaelschung oder sonstige Vermoegensstraftaten',
icon: '\u{1F3AD}',
color: 'text-orange-700',
bgColor: 'bg-orange-100'
},
data_protection: {
category: 'data_protection',
label: 'Datenschutz',
description: 'Verstoesse gegen Datenschutzvorschriften (DSGVO, BDSG)',
icon: '\u{1F512}',
color: 'text-blue-700',
bgColor: 'bg-blue-100'
},
discrimination: {
category: 'discrimination',
label: 'Diskriminierung',
description: 'Diskriminierung, Mobbing, sexuelle Belaestigung oder Benachteiligung',
icon: '\u{26A0}\u{FE0F}',
color: 'text-purple-700',
bgColor: 'bg-purple-100'
},
environment: {
category: 'environment',
label: 'Umwelt',
description: 'Umweltverschmutzung, illegale Entsorgung oder Verstoesse gegen Umweltauflagen',
icon: '\u{1F33F}',
color: 'text-green-700',
bgColor: 'bg-green-100'
},
competition: {
category: 'competition',
label: 'Wettbewerb',
description: 'Kartellrechtsverstoesse, unlauterer Wettbewerb, Marktmanipulation',
icon: '\u{2696}\u{FE0F}',
color: 'text-indigo-700',
bgColor: 'bg-indigo-100'
},
product_safety: {
category: 'product_safety',
label: 'Produktsicherheit',
description: 'Verstoesse gegen Produktsicherheitsvorschriften, mangelhafte Produkte, fehlende Warnhinweise',
icon: '\u{1F6E1}\u{FE0F}',
color: 'text-yellow-700',
bgColor: 'bg-yellow-100'
},
tax_evasion: {
category: 'tax_evasion',
label: 'Steuerhinterziehung',
description: 'Steuerhinterziehung, Steuerumgehung oder sonstige Steuerverstoesse',
icon: '\u{1F4C4}',
color: 'text-teal-700',
bgColor: 'bg-teal-100'
},
other: {
category: 'other',
label: 'Sonstiges',
description: 'Sonstige Verstoesse gegen geltendes Recht oder interne Richtlinien',
icon: '\u{1F4CB}',
color: 'text-gray-700',
bgColor: 'bg-gray-100'
}
}
// =============================================================================
// REPORT STATUS METADATA
// =============================================================================
export const REPORT_STATUS_INFO: Record<ReportStatus, { label: string; description: string; color: string; bgColor: string }> = {
new: {
label: 'Neu',
description: 'Meldung ist eingegangen, Eingangsbestaetigung steht aus',
color: 'text-blue-700',
bgColor: 'bg-blue-100'
},
acknowledged: {
label: 'Bestaetigt',
description: 'Eingangsbestaetigung wurde an den Hinweisgeber versendet',
color: 'text-cyan-700',
bgColor: 'bg-cyan-100'
},
under_review: {
label: 'In Pruefung',
description: 'Meldung wird inhaltlich geprueft und bewertet',
color: 'text-yellow-700',
bgColor: 'bg-yellow-100'
},
investigation: {
label: 'Untersuchung',
description: 'Formelle Untersuchung des gemeldeten Sachverhalts laeuft',
color: 'text-purple-700',
bgColor: 'bg-purple-100'
},
measures_taken: {
label: 'Massnahmen ergriffen',
description: 'Folgemaßnahmen wurden eingeleitet oder abgeschlossen',
color: 'text-orange-700',
bgColor: 'bg-orange-100'
},
closed: {
label: 'Abgeschlossen',
description: 'Fall wurde abgeschlossen und dokumentiert',
color: 'text-green-700',
bgColor: 'bg-green-100'
},
rejected: {
label: 'Abgelehnt',
description: 'Meldung wurde als unbegrundet oder nicht zustaendig abgelehnt',
color: 'text-red-700',
bgColor: 'bg-red-100'
}
}
// =============================================================================
// MAIN INTERFACES
// =============================================================================
export interface FileAttachment {
id: string
fileName: string
fileSize: number
mimeType: string
uploadedAt: string
uploadedBy: string
}
export interface AuditEntry {
id: string
action: string
description: string
performedBy: string
performedAt: string
}
export interface AnonymousMessage {
id: string
reportId: string
senderRole: 'reporter' | 'ombudsperson'
message: string
createdAt: string
isRead: boolean
}
export interface WhistleblowerMeasure {
id: string
reportId: string
title: string
description: string
status: 'planned' | 'in_progress' | 'completed'
responsible: string
dueDate: string
completedAt?: string
}
export interface WhistleblowerReport {
id: string
referenceNumber: string // z.B. "WB-2026-000042"
accessKey: string // Anonymer Zugangscode fuer den Hinweisgeber
category: ReportCategory
status: ReportStatus
priority: ReportPriority
title: string
description: string
// Hinweisgeber-Info (optional bei anonymen Meldungen)
isAnonymous: boolean
reporterName?: string
reporterEmail?: string
reporterPhone?: string
// Zuweisung
assignedTo?: string
// Zeitstempel
receivedAt: string
acknowledgedAt?: string
// Fristen gemaess HinSchG
deadlineAcknowledgment: string // 7 Tage nach Eingang (ss 17 Abs. 1 S. 2)
deadlineFeedback: string // 3 Monate nach Eingang (ss 17 Abs. 2)
closedAt?: string
// Verknuepfte Daten
measures: WhistleblowerMeasure[]
messages: AnonymousMessage[]
attachments: FileAttachment[]
auditTrail: AuditEntry[]
}
// =============================================================================
// STATISTICS
// =============================================================================
export interface WhistleblowerStatistics {
totalReports: number
newReports: number
underReview: number
closed: number
overdueAcknowledgment: number
overdueFeedback: number
byCategory: Record<ReportCategory, number>
byStatus: Record<ReportStatus, number>
}
// =============================================================================
// DEADLINE TRACKING (HinSchG)
// =============================================================================
/**
* Gibt die verbleibenden Tage bis zur Eingangsbestaetigung zurueck (7-Tage-Frist)
* Negative Werte bedeuten ueberfaellig
*/
export function getDaysUntilAcknowledgment(report: WhistleblowerReport): number {
if (report.acknowledgedAt || report.status !== 'new') {
return 0
}
const deadline = new Date(report.deadlineAcknowledgment)
const now = new Date()
const diff = deadline.getTime() - now.getTime()
return Math.ceil(diff / (1000 * 60 * 60 * 24))
}
/**
* Gibt die verbleibenden Tage bis zur Rueckmeldungsfrist zurueck (3-Monate-Frist)
* Negative Werte bedeuten ueberfaellig
*/
export function getDaysUntilFeedback(report: WhistleblowerReport): number {
if (report.status === 'closed' || report.status === 'rejected') {
return 0
}
const deadline = new Date(report.deadlineFeedback)
const now = new Date()
const diff = deadline.getTime() - now.getTime()
return Math.ceil(diff / (1000 * 60 * 60 * 24))
}
/**
* Prueft ob die Eingangsbestaetigungsfrist ueberschritten ist (7 Tage, HinSchG ss 17 Abs. 1)
*/
export function isAcknowledgmentOverdue(report: WhistleblowerReport): boolean {
if (report.acknowledgedAt || report.status !== 'new') {
return false
}
return new Date() > new Date(report.deadlineAcknowledgment)
}
/**
* Prueft ob die Rueckmeldungsfrist ueberschritten ist (3 Monate, HinSchG ss 17 Abs. 2)
*/
export function isFeedbackOverdue(report: WhistleblowerReport): boolean {
if (report.status === 'closed' || report.status === 'rejected') {
return false
}
return new Date() > new Date(report.deadlineFeedback)
}
/**
* Generiert einen anonymen Zugangscode im Format XXXX-XXXX-XXXX
*/
export function generateAccessKey(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // Kein I, O, 0, 1 fuer Lesbarkeit
let result = ''
for (let i = 0; i < 12; i++) {
if (i > 0 && i % 4 === 0) result += '-'
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result // Format: XXXX-XXXX-XXXX
}
// =============================================================================
// API TYPES
// =============================================================================
export interface ReportFilters {
status?: ReportStatus | ReportStatus[]
category?: ReportCategory | ReportCategory[]
priority?: ReportPriority
assignedTo?: string
isAnonymous?: boolean
search?: string
dateFrom?: string
dateTo?: string
}
export interface ReportListResponse {
reports: WhistleblowerReport[]
total: number
page: number
pageSize: number
}
export interface PublicReportSubmission {
category: ReportCategory
title: string
description: string
isAnonymous: boolean
reporterName?: string
reporterEmail?: string
reporterPhone?: string
}
export interface ReportUpdateRequest {
status?: ReportStatus
priority?: ReportPriority
category?: ReportCategory
assignedTo?: string
}
export interface MessageSendRequest {
senderRole: 'reporter' | 'ombudsperson'
message: string
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
export function getCategoryInfo(category: ReportCategory): ReportCategoryInfo {
return REPORT_CATEGORY_INFO[category]
}
export function getStatusInfo(status: ReportStatus) {
return REPORT_STATUS_INFO[status]
}