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:
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')
|
||||
}
|
||||
Reference in New Issue
Block a user