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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,3 +39,4 @@ backups/*.backup
|
|||||||
*.mp4
|
*.mp4
|
||||||
*.mp3
|
*.mp3
|
||||||
*.wav
|
*.wav
|
||||||
|
ai-compliance-sdk/server
|
||||||
|
|||||||
703
admin-compliance/app/(sdk)/sdk/academy/page.tsx
Normal file
703
admin-compliance/app/(sdk)/sdk/academy/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
839
admin-compliance/app/(sdk)/sdk/document-crawler/page.tsx
Normal file
839
admin-compliance/app/(sdk)/sdk/document-crawler/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
2068
admin-compliance/app/(sdk)/sdk/dsb-portal/page.tsx
Normal file
2068
admin-compliance/app/(sdk)/sdk/dsb-portal/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
706
admin-compliance/app/(sdk)/sdk/incidents/page.tsx
Normal file
706
admin-compliance/app/(sdk)/sdk/incidents/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
879
admin-compliance/app/(sdk)/sdk/industry-templates/page.tsx
Normal file
879
admin-compliance/app/(sdk)/sdk/industry-templates/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1663
admin-compliance/app/(sdk)/sdk/multi-tenant/page.tsx
Normal file
1663
admin-compliance/app/(sdk)/sdk/multi-tenant/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1482
admin-compliance/app/(sdk)/sdk/sso/page.tsx
Normal file
1482
admin-compliance/app/(sdk)/sdk/sso/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
669
admin-compliance/app/(sdk)/sdk/whistleblower/page.tsx
Normal file
669
admin-compliance/app/(sdk)/sdk/whistleblower/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
136
admin-compliance/app/api/sdk/v1/academy/[[...path]]/route.ts
Normal file
136
admin-compliance/app/api/sdk/v1/academy/[[...path]]/route.ts
Normal 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')
|
||||||
|
}
|
||||||
114
admin-compliance/app/api/sdk/v1/crawler/[[...path]]/route.ts
Normal file
114
admin-compliance/app/api/sdk/v1/crawler/[[...path]]/route.ts
Normal 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')
|
||||||
|
}
|
||||||
109
admin-compliance/app/api/sdk/v1/dsb/[[...path]]/route.ts
Normal file
109
admin-compliance/app/api/sdk/v1/dsb/[[...path]]/route.ts
Normal 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')
|
||||||
|
}
|
||||||
137
admin-compliance/app/api/sdk/v1/incidents/[[...path]]/route.ts
Normal file
137
admin-compliance/app/api/sdk/v1/incidents/[[...path]]/route.ts
Normal 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')
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
111
admin-compliance/app/api/sdk/v1/sso/[[...path]]/route.ts
Normal file
111
admin-compliance/app/api/sdk/v1/sso/[[...path]]/route.ts
Normal 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')
|
||||||
|
}
|
||||||
135
admin-compliance/app/api/sdk/v1/vendors/[[...path]]/route.ts
vendored
Normal file
135
admin-compliance/app/api/sdk/v1/vendors/[[...path]]/route.ts
vendored
Normal 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')
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
576
admin-compliance/lib/sdk/academy/api.ts
Normal file
576
admin-compliance/lib/sdk/academy/api.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
admin-compliance/lib/sdk/academy/index.ts
Normal file
6
admin-compliance/lib/sdk/academy/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Academy Module Exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './types'
|
||||||
|
export * from './api'
|
||||||
285
admin-compliance/lib/sdk/academy/types.ts
Normal file
285
admin-compliance/lib/sdk/academy/types.ts
Normal 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]
|
||||||
|
}
|
||||||
845
admin-compliance/lib/sdk/incidents/api.ts
Normal file
845
admin-compliance/lib/sdk/incidents/api.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
447
admin-compliance/lib/sdk/incidents/types.ts
Normal file
447
admin-compliance/lib/sdk/incidents/types.ts
Normal 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]
|
||||||
|
}
|
||||||
755
admin-compliance/lib/sdk/whistleblower/api.ts
Normal file
755
admin-compliance/lib/sdk/whistleblower/api.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
381
admin-compliance/lib/sdk/whistleblower/types.ts
Normal file
381
admin-compliance/lib/sdk/whistleblower/types.ts
Normal 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]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user