From f358c1d6e6385e363fd2d01e8220c3f828869a8e Mon Sep 17 00:00:00 2001 From: Benjamin Boenisch Date: Sat, 14 Feb 2026 22:07:42 +0100 Subject: [PATCH] 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 --- .gitignore | 1 + .../app/(sdk)/sdk/academy/page.tsx | 703 ++++++ .../app/(sdk)/sdk/document-crawler/page.tsx | 839 +++++++ .../app/(sdk)/sdk/dsb-portal/page.tsx | 2068 +++++++++++++++++ .../app/(sdk)/sdk/incidents/page.tsx | 706 ++++++ .../app/(sdk)/sdk/industry-templates/page.tsx | 879 +++++++ .../app/(sdk)/sdk/multi-tenant/page.tsx | 1663 +++++++++++++ admin-compliance/app/(sdk)/sdk/sso/page.tsx | 1482 ++++++++++++ .../app/(sdk)/sdk/whistleblower/page.tsx | 669 ++++++ .../api/sdk/v1/academy/[[...path]]/route.ts | 136 ++ .../api/sdk/v1/crawler/[[...path]]/route.ts | 114 + .../app/api/sdk/v1/dsb/[[...path]]/route.ts | 109 + .../api/sdk/v1/incidents/[[...path]]/route.ts | 137 ++ .../api/sdk/v1/industry/[[...path]]/route.ts | 74 + .../sdk/v1/multi-tenant/[[...path]]/route.ts | 111 + .../api/sdk/v1/reporting/[[...path]]/route.ts | 75 + .../app/api/sdk/v1/sso/[[...path]]/route.ts | 111 + .../api/sdk/v1/vendors/[[...path]]/route.ts | 135 ++ .../sdk/v1/whistleblower/[[...path]]/route.ts | 147 ++ admin-compliance/lib/sdk/academy/api.ts | 576 +++++ admin-compliance/lib/sdk/academy/index.ts | 6 + admin-compliance/lib/sdk/academy/types.ts | 285 +++ admin-compliance/lib/sdk/incidents/api.ts | 845 +++++++ admin-compliance/lib/sdk/incidents/types.ts | 447 ++++ admin-compliance/lib/sdk/whistleblower/api.ts | 755 ++++++ .../lib/sdk/whistleblower/types.ts | 381 +++ 26 files changed, 13454 insertions(+) create mode 100644 admin-compliance/app/(sdk)/sdk/academy/page.tsx create mode 100644 admin-compliance/app/(sdk)/sdk/document-crawler/page.tsx create mode 100644 admin-compliance/app/(sdk)/sdk/dsb-portal/page.tsx create mode 100644 admin-compliance/app/(sdk)/sdk/incidents/page.tsx create mode 100644 admin-compliance/app/(sdk)/sdk/industry-templates/page.tsx create mode 100644 admin-compliance/app/(sdk)/sdk/multi-tenant/page.tsx create mode 100644 admin-compliance/app/(sdk)/sdk/sso/page.tsx create mode 100644 admin-compliance/app/(sdk)/sdk/whistleblower/page.tsx create mode 100644 admin-compliance/app/api/sdk/v1/academy/[[...path]]/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/crawler/[[...path]]/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/dsb/[[...path]]/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/incidents/[[...path]]/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/industry/[[...path]]/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/multi-tenant/[[...path]]/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/reporting/[[...path]]/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/sso/[[...path]]/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/vendors/[[...path]]/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/whistleblower/[[...path]]/route.ts create mode 100644 admin-compliance/lib/sdk/academy/api.ts create mode 100644 admin-compliance/lib/sdk/academy/index.ts create mode 100644 admin-compliance/lib/sdk/academy/types.ts create mode 100644 admin-compliance/lib/sdk/incidents/api.ts create mode 100644 admin-compliance/lib/sdk/incidents/types.ts create mode 100644 admin-compliance/lib/sdk/whistleblower/api.ts create mode 100644 admin-compliance/lib/sdk/whistleblower/types.ts diff --git a/.gitignore b/.gitignore index 9ca6b48..05a5119 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ backups/*.backup *.mp4 *.mp3 *.wav +ai-compliance-sdk/server diff --git a/admin-compliance/app/(sdk)/sdk/academy/page.tsx b/admin-compliance/app/(sdk)/sdk/academy/page.tsx new file mode 100644 index 0000000..acbc0c8 --- /dev/null +++ b/admin-compliance/app/(sdk)/sdk/academy/page.tsx @@ -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 ( +
+ +
+ ) +} + +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 ( +
+
+
+
+ {label} +
+
+ {value} +
+ {trend && ( +
= 0 ? 'text-green-600' : 'text-red-600'}`}> + {trend.value >= 0 ? '+' : ''}{trend.value} {trend.label} +
+ )} +
+ {icon && ( +
+ {icon} +
+ )} +
+
+ ) +} + +function CourseCard({ course, enrollmentCount }: { course: Course; enrollmentCount: number }) { + const categoryInfo = COURSE_CATEGORY_INFO[course.category] + + return ( + +
+
+
+ {/* Header Badges */} +
+ + {categoryInfo.label} + +
+ + {/* Course Title */} +

+ {course.title} +

+

+ {course.description} +

+ + {/* Course Meta */} +
+ + + + + {course.lessons.length} Lektionen + + + + + + {course.durationMinutes} Min. + + + + + + {enrollmentCount} Teilnehmer + +
+
+ + {/* Right Side - Roles */} +
+
+ {course.requiredForRoles.includes('all') ? 'Pflicht fuer alle' : `${course.requiredForRoles.length} Rollen`} +
+
+ {new Date(course.updatedAt).toLocaleDateString('de-DE')} +
+
+
+ + {/* Footer */} +
+
+ Erstellt: {new Date(course.createdAt).toLocaleDateString('de-DE')} +
+
+ + Details + +
+
+
+ + ) +} + +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 ( +
+
+
+ {/* Status Badge */} +
+ + {statusInfo.label} + + {overdue && ( + + + + + Ueberfaellig + + )} +
+ + {/* User Info */} +

+ {enrollment.userName} +

+

{enrollment.userEmail}

+

{courseName}

+ + {/* Progress Bar */} +
+
+ Fortschritt + {enrollment.progress}% +
+
+
+
+
+
+ + {/* Right Side - Deadline */} +
+
+ {enrollment.status === 'completed' + ? 'Abgeschlossen' + : overdue + ? `${Math.abs(daysUntil)} Tage ueberfaellig` + : `${daysUntil} Tage verbleibend` + } +
+
+ Frist: {new Date(enrollment.deadline).toLocaleDateString('de-DE')} +
+
+
+ + {/* Footer */} +
+
+ Gestartet: {new Date(enrollment.startedAt).toLocaleDateString('de-DE')} +
+ {enrollment.completedAt && ( +
+ Abgeschlossen: {new Date(enrollment.completedAt).toLocaleDateString('de-DE')} +
+ )} +
+
+ ) +} + +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 ( +
+ Filter: + + {/* Category Filter */} + + + {/* Enrollment Status Filter */} + + + {/* Clear Filters */} + {hasFilters && ( + + )} +
+ ) +} + +// ============================================================================= +// MAIN PAGE +// ============================================================================= + +export default function AcademyPage() { + const { state } = useSDK() + const [activeTab, setActiveTab] = useState('overview') + const [courses, setCourses] = useState([]) + const [enrollments, setEnrollments] = useState([]) + const [statistics, setStatistics] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + // Filters + const [selectedCategory, setSelectedCategory] = useState('all') + const [selectedStatus, setSelectedStatus] = useState('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 = {} + enrollments.forEach(e => { + counts[e.courseId] = (counts[e.courseId] || 0) + 1 + }) + return counts + }, [enrollments]) + + // Course name lookup + const courseNameById = useMemo(() => { + const map: Record = {} + 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 ( +
+ {/* Step Header */} + + + + + + Kurs erstellen + + + + {/* Tab Navigation */} + + + {/* Loading State */} + {isLoading ? ( +
+ + + + +
+ ) : activeTab === 'settings' ? ( + /* Settings Tab */ +
+
+ + + + +
+

Einstellungen

+

+ Academy-Einstellungen, E-Mail-Benachrichtigungen und Kurs-Vorlagen + werden in einer spaeteren Version verfuegbar sein. +

+
+ ) : activeTab === 'certificates' ? ( + /* Certificates Tab Placeholder */ +
+
+ + + +
+

Zertifikate

+

+ Zertifikate werden automatisch nach erfolgreichem Kursabschluss generiert. + Die Zertifikatsverwaltung wird in einer spaeteren Version verfuegbar sein. +

+ {tabCounts.certificates > 0 && ( +

+ {tabCounts.certificates} Zertifikat(e) vorhanden +

+ )} +
+ ) : ( + <> + {/* Statistics (Overview Tab) */} + {activeTab === 'overview' && statistics && ( +
+ + + + 0 ? 'red' : 'green'} + /> +
+ )} + + {/* Overdue Alert */} + {tabCounts.overdue > 0 && ( +
+
+ + + +
+
+

+ Achtung: {tabCounts.overdue} ueberfaellige Schulung(en) +

+

+ Mitarbeiter haben Pflichtschulungen nicht fristgerecht abgeschlossen. Handeln Sie umgehend. +

+
+ +
+ )} + + {/* Info Box (Overview Tab) */} + {activeTab === 'overview' && ( +
+
+ + + +
+

Schulungspflicht nach Art. 39 DSGVO

+

+ 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. +

+
+
+
+ )} + + {/* Filters */} + + + {/* Courses Tab */} + {(activeTab === 'overview' || activeTab === 'courses') && ( +
+ {activeTab === 'courses' && ( +

Kurse ({filteredCourses.length})

+ )} + {filteredCourses.map(course => ( + + ))} +
+ )} + + {/* Enrollments Tab */} + {activeTab === 'enrollments' && ( +
+

Einschreibungen ({filteredEnrollments.length})

+ {filteredEnrollments.map(enrollment => ( + + ))} +
+ )} + + {/* Empty States */} + {activeTab === 'courses' && filteredCourses.length === 0 && ( +
+
+ + + +
+

Keine Kurse gefunden

+

+ {selectedCategory !== 'all' + ? 'Passen Sie die Filter an oder' + : 'Es sind noch keine Kurse vorhanden.' + } +

+ {selectedCategory !== 'all' ? ( + + ) : ( + + + + + Ersten Kurs erstellen + + )} +
+ )} + + {activeTab === 'enrollments' && filteredEnrollments.length === 0 && ( +
+
+ + + +
+

Keine Einschreibungen gefunden

+

+ {selectedStatus !== 'all' + ? 'Passen Sie die Filter an.' + : 'Es sind noch keine Mitarbeiter in Kurse eingeschrieben.' + } +

+ {selectedStatus !== 'all' && ( + + )} +
+ )} + + )} +
+ ) +} diff --git a/admin-compliance/app/(sdk)/sdk/document-crawler/page.tsx b/admin-compliance/app/(sdk)/sdk/document-crawler/page.tsx new file mode 100644 index 0000000..265edc4 --- /dev/null +++ b/admin-compliance/app/(sdk)/sdk/document-crawler/page.tsx @@ -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 + 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 = { + 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([]) + const [loading, setLoading] = useState(true) + const [showForm, setShowForm] = useState(false) + const [formName, setFormName] = useState('') + const [formPath, setFormPath] = useState('') + const [testResult, setTestResult] = useState>({}) + + 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 ( +
+
+

Crawl-Quellen

+ +
+ + {showForm && ( +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + +
+
+ )} + + {loading ? ( +
Laden...
+ ) : sources.length === 0 ? ( +
+

Keine Quellen konfiguriert

+

Erstellen Sie eine Crawl-Quelle um Dokumente zu scannen.

+
+ ) : ( +
+ {sources.map(s => ( +
+
+
+
{s.name}
+
{s.path}
+
+ Tiefe: {s.max_depth} | Formate: {(typeof s.file_extensions === 'string' ? JSON.parse(s.file_extensions) : s.file_extensions).join(', ')} +
+
+ {testResult[s.id] && ( + {testResult[s.id]} + )} + + + +
+ ))} +
+ )} +
+ ) +} + +// ============================================================================= +// TAB: CRAWL-JOBS +// ============================================================================= + +function JobsTab() { + const [jobs, setJobs] = useState([]) + const [sources, setSources] = useState([]) + 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 ( +
+ {/* Trigger form */} +
+

Neuen Crawl starten

+
+
+ + +
+
+ + +
+ +
+
+ + {/* Job list */} + {loading ? ( +
Laden...
+ ) : jobs.length === 0 ? ( +
+ Noch keine Crawl-Jobs ausgefuehrt. +
+ ) : ( +
+ {jobs.map(job => ( +
+
+
+ + {job.status} + + {job.source_name || 'Quelle'} + {job.job_type === 'delta' ? 'Delta' : 'Voll'} +
+
+ {(job.status === 'running' || job.status === 'pending') && ( + + )} + + {new Date(job.created_at).toLocaleString('de-DE')} + +
+
+ + {/* Progress */} + {job.status === 'running' && job.files_found > 0 && ( +
+
+
+
+
+ {job.files_processed} / {job.files_found} Dateien verarbeitet +
+
+ )} + + {/* Stats */} +
+
+
{job.files_found}
+
Gefunden
+
+
+
{job.files_processed}
+
Verarbeitet
+
+
+
{job.files_new}
+
Neu
+
+
+
{job.files_changed}
+
Geaendert
+
+
+
{job.files_skipped}
+
Uebersprungen
+
+
+
{job.files_error}
+
Fehler
+
+
+
+ ))} +
+ )} +
+ ) +} + +// ============================================================================= +// TAB: DOKUMENTE +// ============================================================================= + +function DocumentsTab() { + const [docs, setDocs] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + const [filterClass, setFilterClass] = useState('') + const [archiving, setArchiving] = useState>({}) + + 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 ( +
+
+

{total} Dokumente

+ +
+ + {loading ? ( +
Laden...
+ ) : docs.length === 0 ? ( +
+ Keine Dokumente gefunden. Starten Sie zuerst einen Crawl-Job. +
+ ) : ( +
+ + + + + + + + + + + + + {docs.map(doc => { + const cls = CLASSIFICATION_LABELS[doc.classification || ''] || CLASSIFICATION_LABELS['Sonstiges'] + return ( + + + + + + + + + ) + })} + +
DateiKategorieKonfidenzGroesseArchivAktionen
+
{doc.file_name}
+
{doc.source_name}
+
+ + {doc.classification_corrected && ( + * + )} + + {doc.classification_confidence != null && ( +
+
+
+
+ + {(doc.classification_confidence * 100).toFixed(0)}% + +
+ )} +
{formatSize(doc.file_size_bytes)} + {doc.archived ? ( + IPFS + ) : ( + - + )} + + {!doc.archived && ( + + )} +
+
+ )} +
+ ) +} + +// ============================================================================= +// TAB: ONBOARDING-REPORT +// ============================================================================= + +function ReportTab() { + const [reports, setReports] = useState([]) + const [activeReport, setActiveReport] = useState(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 ( +
+ + + + +
+ {score.toFixed(0)}% + Compliance +
+
+ ) + } + + return ( +
+
+

Onboarding-Report

+ +
+ + {/* Report selector */} + {reports.length > 1 && ( +
+ {reports.map(r => ( + + ))} +
+ )} + + {loading ? ( +
Laden...
+ ) : !activeReport ? ( +
+

Kein Report vorhanden

+

Fuehren Sie zuerst einen Crawl durch und generieren Sie dann einen Report.

+
+ ) : ( +
+ {/* Score + Stats */} +
+
+ +
+
+
{activeReport.total_documents_found}
+
Dokumente gefunden
+
+
+
+ {Object.keys(activeReport.classification_breakdown || {}).length} +
+
Kategorien abgedeckt
+
+
+
+ {(activeReport.gaps || []).length} +
+
Luecken identifiziert
+
+
+
+
+ + {/* Classification breakdown */} +
+

Dokumenten-Verteilung

+
+ {Object.entries(activeReport.classification_breakdown || {}).map(([cat, count]) => { + const cls = CLASSIFICATION_LABELS[cat] || CLASSIFICATION_LABELS['Sonstiges'] + return ( + + {cls.label}: {count as number} + + ) + })} + {Object.keys(activeReport.classification_breakdown || {}).length === 0 && ( + Keine Dokumente klassifiziert + )} +
+
+ + {/* Gap summary */} + {activeReport.gap_summary && ( +
+
+
{activeReport.gap_summary.critical}
+
Kritisch
+
+
+
{activeReport.gap_summary.high}
+
Hoch
+
+
+
{activeReport.gap_summary.medium}
+
Mittel
+
+
+ )} + + {/* Gap details */} + {(activeReport.gaps || []).length > 0 && ( +
+

Compliance-Luecken

+
+ {activeReport.gaps.map((gap) => ( +
+
+
+
{gap.category}
+

{gap.description}

+
+ + {gap.severity} + +
+
+ Regulierung: {gap.regulation} | Aktion: {gap.requiredAction} +
+
+ ))} +
+
+ )} +
+ )} +
+ ) +} + +// ============================================================================= +// MAIN PAGE +// ============================================================================= + +type Tab = 'sources' | 'jobs' | 'documents' | 'report' + +export default function DocumentCrawlerPage() { + const [activeTab, setActiveTab] = useState('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 ( +
+ {/* Header */} +
+

Document Crawler & Auto-Onboarding

+

+ Automatisches Scannen von Dateisystemen, KI-Klassifizierung, IPFS-Archivierung und Compliance Gap-Analyse. +

+
+ + {/* Tabs */} +
+ +
+ + {/* Tab content */} + {activeTab === 'sources' && } + {activeTab === 'jobs' && } + {activeTab === 'documents' && } + {activeTab === 'report' && } +
+ ) +} diff --git a/admin-compliance/app/(sdk)/sdk/dsb-portal/page.tsx b/admin-compliance/app/(sdk)/sdk/dsb-portal/page.tsx new file mode 100644 index 0000000..4cf7bd4 --- /dev/null +++ b/admin-compliance/app/(sdk)/sdk/dsb-portal/page.tsx @@ -0,0 +1,2068 @@ +'use client' + +import React, { useState, useEffect, useCallback, useRef } from 'react' + +// ============================================================================= +// TYPES +// ============================================================================= + +interface AssignmentOverview { + id: string + dsb_user_id: string + tenant_id: string + tenant_name: string + tenant_slug: string + status: string + contract_start: string + contract_end: string | null + monthly_hours_budget: number + notes: string + compliance_score: number + hours_this_month: number + hours_budget: number + open_task_count: number + urgent_task_count: number + next_deadline: string | null + created_at: string + updated_at: string +} + +interface DSBDashboard { + assignments: AssignmentOverview[] + total_assignments: number + active_assignments: number + total_hours_this_month: number + open_tasks: number + urgent_tasks: number + generated_at: string +} + +interface HourEntry { + id: string + assignment_id: string + date: string + hours: number + category: string + description: string + billable: boolean + created_at: string +} + +interface Task { + id: string + assignment_id: string + title: string + description: string + category: string + priority: string + status: string + due_date: string | null + completed_at: string | null + created_at: string + updated_at: string +} + +interface Communication { + id: string + assignment_id: string + direction: string + channel: string + subject: string + content: string + participants: string + created_at: string +} + +interface HoursSummary { + total_hours: number + billable_hours: number + by_category: Record + period: string +} + +// ============================================================================= +// CONSTANTS +// ============================================================================= + +const DSB_USER_ID = '00000000-0000-0000-0000-000000000001' + +const TASK_CATEGORIES = [ + 'DSFA-Pruefung', + 'Betroffenenanfrage', + 'Vorfall-Pruefung', + 'Audit-Vorbereitung', + 'Richtlinien-Pruefung', + 'Schulung', + 'Beratung', + 'Sonstiges', +] + +const HOUR_CATEGORIES = [ + 'DSFA-Pruefung', + 'Beratung', + 'Audit', + 'Schulung', + 'Vorfallreaktion', + 'Dokumentation', + 'Besprechung', + 'Sonstiges', +] + +const COMM_CHANNELS = ['E-Mail', 'Telefon', 'Besprechung', 'Portal', 'Brief'] + +const PRIORITY_LABELS: Record = { + urgent: 'Dringend', + high: 'Hoch', + medium: 'Mittel', + low: 'Niedrig', +} + +const PRIORITY_COLORS: Record = { + urgent: 'bg-red-100 text-red-700 border-red-200', + high: 'bg-orange-100 text-orange-700 border-orange-200', + medium: 'bg-blue-100 text-blue-700 border-blue-200', + low: 'bg-gray-100 text-gray-500 border-gray-200', +} + +const TASK_STATUS_LABELS: Record = { + open: 'Offen', + in_progress: 'In Bearbeitung', + waiting: 'Wartend', + completed: 'Erledigt', + cancelled: 'Abgebrochen', +} + +const TASK_STATUS_COLORS: Record = { + open: 'bg-blue-100 text-blue-700', + in_progress: 'bg-yellow-100 text-yellow-700', + waiting: 'bg-orange-100 text-orange-700', + completed: 'bg-green-100 text-green-700', + cancelled: 'bg-gray-100 text-gray-500', +} + +const ASSIGNMENT_STATUS_COLORS: Record = { + active: 'bg-green-100 text-green-700 border-green-300', + paused: 'bg-yellow-100 text-yellow-700 border-yellow-300', + terminated: 'bg-red-100 text-red-700 border-red-300', +} + +const ASSIGNMENT_STATUS_LABELS: Record = { + active: 'Aktiv', + paused: 'Pausiert', + terminated: 'Beendet', +} + +// ============================================================================= +// API HELPERS +// ============================================================================= + +async function apiFetch(url: string, options?: RequestInit): Promise { + const res = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'X-User-ID': DSB_USER_ID, + ...(options?.headers || {}), + }, + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`API Error ${res.status}: ${text || res.statusText}`) + } + return res.json() +} + +// ============================================================================= +// TOAST COMPONENT +// ============================================================================= + +interface ToastMessage { + id: number + message: string + type: 'success' | 'error' +} + +let toastIdCounter = 0 + +function useToast() { + const [toasts, setToasts] = useState([]) + + const addToast = useCallback((message: string, type: 'success' | 'error' = 'success') => { + const id = ++toastIdCounter + setToasts((prev) => [...prev, { id, message, type }]) + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, 3500) + }, []) + + return { toasts, addToast } +} + +function ToastContainer({ toasts }: { toasts: ToastMessage[] }) { + if (toasts.length === 0) return null + return ( +
+ {toasts.map((t) => ( +
+ {t.message} +
+ ))} +
+ ) +} + +// ============================================================================= +// LOADING SKELETON +// ============================================================================= + +function Skeleton({ className = '' }: { className?: string }) { + return
+} + +function DashboardSkeleton() { + return ( +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ ) +} + +// ============================================================================= +// MODAL COMPONENT +// ============================================================================= + +function Modal({ + open, + onClose, + title, + children, + maxWidth = 'max-w-lg', +}: { + open: boolean + onClose: () => void + title: string + children: React.ReactNode + maxWidth?: string +}) { + const overlayRef = useRef(null) + + useEffect(() => { + if (!open) return + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleEsc) + return () => document.removeEventListener('keydown', handleEsc) + }, [open, onClose]) + + if (!open) return null + + return ( +
{ + if (e.target === overlayRef.current) onClose() + }} + > +
+
+

{title}

+ +
+
{children}
+
+
+ ) +} + +// ============================================================================= +// STAT CARD +// ============================================================================= + +function StatCard({ + title, + value, + icon, + accent = false, +}: { + title: string + value: string | number + icon: React.ReactNode + accent?: boolean +}) { + return ( +
+
+
+ {icon} +
+
+

{title}

+

+ {value} +

+
+
+
+ ) +} + +// ============================================================================= +// COMPLIANCE PROGRESS BAR +// ============================================================================= + +function ComplianceBar({ score }: { score: number }) { + const color = + score < 40 ? 'bg-red-500' : score < 70 ? 'bg-yellow-500' : 'bg-green-500' + const textColor = + score < 40 ? 'text-red-700' : score < 70 ? 'text-yellow-700' : 'text-green-700' + return ( +
+
+
+
+ + {score}% + +
+ ) +} + +// ============================================================================= +// HOURS PROGRESS BAR +// ============================================================================= + +function HoursBar({ used, budget }: { used: number; budget: number }) { + const pct = budget > 0 ? Math.min((used / budget) * 100, 100) : 0 + const over = used > budget + return ( +
+
+
+
+ + {used}h / {budget}h + +
+ ) +} + +// ============================================================================= +// BADGE +// ============================================================================= + +function Badge({ label, className = '' }: { label: string; className?: string }) { + return ( + + {label} + + ) +} + +// ============================================================================= +// FORM COMPONENTS +// ============================================================================= + +function FormLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) { + return ( + + ) +} + +function FormInput({ + id, + type = 'text', + value, + onChange, + placeholder, + required, + min, + max, + step, +}: { + id?: string + type?: string + value: string | number + onChange: (val: string) => void + placeholder?: string + required?: boolean + min?: string | number + max?: string | number + step?: string | number +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + required={required} + min={min} + max={max} + step={step} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + /> + ) +} + +function FormTextarea({ + id, + value, + onChange, + placeholder, + rows = 3, +}: { + id?: string + value: string + onChange: (val: string) => void + placeholder?: string + rows?: number +}) { + return ( +