refactor(admin): split evidence, import, portfolio pages

Extract components and hooks from oversized pages into colocated
_components/ and _hooks/ subdirectories to enforce the 500-LOC hard cap.
page.tsx files reduced to 205, 121, and 136 LOC respectively.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-16 13:07:04 +02:00
parent 9096aad693
commit 7907b3f25b
42 changed files with 3568 additions and 3591 deletions

View File

@@ -0,0 +1,134 @@
'use client'
import type { DisplayEvidence, DisplayEvidenceType } from './EvidenceTypes'
const typeIcons: Record<DisplayEvidenceType, React.ReactNode> = {
document: (
<svg className="w-6 h-6" 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>
),
screenshot: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
),
log: (
<svg className="w-6 h-6" 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-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
),
'audit-report': (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 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>
),
certificate: (
<svg className="w-6 h-6" 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>
),
}
const statusColors = {
valid: 'bg-green-100 text-green-700 border-green-200',
expired: 'bg-red-100 text-red-700 border-red-200',
'pending-review': 'bg-yellow-100 text-yellow-700 border-yellow-200',
}
const statusLabels = {
valid: 'Gueltig',
expired: 'Abgelaufen',
'pending-review': 'Pruefung ausstehend',
}
const typeIconBg: Record<DisplayEvidenceType, string> = {
certificate: 'bg-yellow-100 text-yellow-600',
'audit-report': 'bg-purple-100 text-purple-600',
screenshot: 'bg-blue-100 text-blue-600',
log: 'bg-green-100 text-green-600',
document: 'bg-gray-100 text-gray-600',
}
export function EvidenceCard({
evidence,
onDelete,
onView,
onDownload,
}: {
evidence: DisplayEvidence
onDelete: () => void
onView: () => void
onDownload: () => void
}) {
return (
<div className={`bg-white rounded-xl border-2 p-6 ${
evidence.status === 'expired' ? 'border-red-200' :
evidence.status === 'pending-review' ? 'border-yellow-200' : 'border-gray-200'
}`}>
<div className="flex items-start gap-4">
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${typeIconBg[evidence.displayType]}`}>
{typeIcons[evidence.displayType]}
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">{evidence.name}</h3>
<span className={`px-3 py-1 text-xs rounded-full ${statusColors[evidence.status]}`}>
{statusLabels[evidence.status]}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">{evidence.description}</p>
<div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
<span>Hochgeladen: {evidence.uploadedAt.toLocaleDateString('de-DE')}</span>
{evidence.validUntil && (
<span className={evidence.status === 'expired' ? 'text-red-600' : ''}>
Gueltig bis: {evidence.validUntil.toLocaleDateString('de-DE')}
</span>
)}
<span>{evidence.fileSize}</span>
</div>
<div className="mt-3 flex items-center gap-2 flex-wrap">
{evidence.linkedRequirements.map(req => (
<span key={req} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
{req}
</span>
))}
{evidence.linkedControls.map(ctrl => (
<span key={ctrl} className="px-2 py-0.5 text-xs bg-green-50 text-green-600 rounded">
{ctrl}
</span>
))}
</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<span className="text-sm text-gray-500">Hochgeladen von: {evidence.uploadedBy}</span>
<div className="flex items-center gap-2">
<button
onClick={onView}
disabled={!evidence.fileUrl}
className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Anzeigen
</button>
<button
onClick={onDownload}
disabled={!evidence.fileUrl}
className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Herunterladen
</button>
<button
onClick={onDelete}
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
Loeschen
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,143 @@
import type { EvidenceType } from '@/lib/sdk'
export type DisplayEvidenceType = 'document' | 'screenshot' | 'log' | 'audit-report' | 'certificate'
export type DisplayFormat = 'pdf' | 'image' | 'text' | 'json'
export type DisplayStatus = 'valid' | 'expired' | 'pending-review'
export interface DisplayEvidence {
id: string
name: string
description: string
displayType: DisplayEvidenceType
format: DisplayFormat
controlId: string
linkedRequirements: string[]
linkedControls: string[]
uploadedBy: string
uploadedAt: Date
validFrom: Date
validUntil: Date | null
status: DisplayStatus
fileSize: string
fileUrl: string | null
}
export interface EvidenceTemplate {
id: string
name: string
description: string
type: EvidenceType
displayType: DisplayEvidenceType
format: DisplayFormat
controlId: string
linkedRequirements: string[]
linkedControls: string[]
uploadedBy: string
validityDays: number
fileSize: string
}
export function mapEvidenceTypeToDisplay(type: EvidenceType): DisplayEvidenceType {
switch (type) {
case 'DOCUMENT': return 'document'
case 'SCREENSHOT': return 'screenshot'
case 'LOG': return 'log'
case 'CERTIFICATE': return 'certificate'
case 'AUDIT_REPORT': return 'audit-report'
default: return 'document'
}
}
export function getEvidenceStatus(validUntil: Date | null): DisplayStatus {
if (!validUntil) return 'pending-review'
const now = new Date()
if (validUntil < now) return 'expired'
return 'valid'
}
export const evidenceTemplates: EvidenceTemplate[] = [
{
id: 'ev-dse-001',
name: 'Datenschutzerklaerung v2.3',
description: 'Aktuelle Datenschutzerklaerung fuer Website und App',
type: 'DOCUMENT',
displayType: 'document',
format: 'pdf',
controlId: 'ctrl-org-001',
linkedRequirements: ['req-gdpr-13', 'req-gdpr-14'],
linkedControls: ['ctrl-org-001'],
uploadedBy: 'DSB',
validityDays: 365,
fileSize: '245 KB',
},
{
id: 'ev-pentest-001',
name: 'Penetrationstest Report Q4/2024',
description: 'Externer Penetrationstest durch Security-Partner',
type: 'AUDIT_REPORT',
displayType: 'audit-report',
format: 'pdf',
controlId: 'ctrl-tom-001',
linkedRequirements: ['req-gdpr-32', 'req-iso-a12'],
linkedControls: ['ctrl-tom-001', 'ctrl-tom-002', 'ctrl-det-001'],
uploadedBy: 'IT Security Team',
validityDays: 365,
fileSize: '2.1 MB',
},
{
id: 'ev-iso-cert',
name: 'ISO 27001 Zertifikat',
description: 'Zertifizierung des ISMS',
type: 'CERTIFICATE',
displayType: 'certificate',
format: 'pdf',
controlId: 'ctrl-tom-001',
linkedRequirements: ['req-iso-4.1', 'req-iso-5.1'],
linkedControls: [],
uploadedBy: 'QM Abteilung',
validityDays: 365,
fileSize: '156 KB',
},
{
id: 'ev-schulung-001',
name: 'Schulungsnachweis Datenschutz 2024',
description: 'Teilnehmerliste und Schulungsinhalt',
type: 'DOCUMENT',
displayType: 'document',
format: 'pdf',
controlId: 'ctrl-org-001',
linkedRequirements: ['req-gdpr-39'],
linkedControls: ['ctrl-org-001'],
uploadedBy: 'HR Team',
validityDays: 365,
fileSize: '890 KB',
},
{
id: 'ev-rbac-001',
name: 'Access Control Screenshot',
description: 'Nachweis der RBAC-Konfiguration',
type: 'SCREENSHOT',
displayType: 'screenshot',
format: 'image',
controlId: 'ctrl-tom-001',
linkedRequirements: ['req-gdpr-32'],
linkedControls: ['ctrl-tom-001'],
uploadedBy: 'Admin',
validityDays: 0,
fileSize: '1.2 MB',
},
{
id: 'ev-log-001',
name: 'Audit Log Export',
description: 'Monatlicher Audit-Log Export',
type: 'LOG',
displayType: 'log',
format: 'json',
controlId: 'ctrl-det-001',
linkedRequirements: ['req-gdpr-32'],
linkedControls: ['ctrl-det-001'],
uploadedBy: 'System',
validityDays: 90,
fileSize: '4.5 MB',
},
]

View File

@@ -0,0 +1,19 @@
'use client'
export function LoadingSkeleton() {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-gray-200 rounded-lg" />
<div className="flex-1">
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
<div className="h-4 w-full bg-gray-100 rounded" />
</div>
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,228 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useSDK, Evidence as SDKEvidence, EvidenceType } from '@/lib/sdk'
import {
DisplayEvidence,
mapEvidenceTypeToDisplay,
getEvidenceStatus,
evidenceTemplates,
} from '../_components/EvidenceTypes'
export function useEvidence() {
const { state, dispatch } = useSDK()
const [filter, setFilter] = useState<string>('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [total, setTotal] = useState(0)
useEffect(() => {
const fetchEvidence = async () => {
try {
setLoading(true)
const res = await fetch(`/api/sdk/v1/compliance/evidence?page=${page}&limit=${pageSize}`)
if (res.ok) {
const data = await res.json()
if (data.total !== undefined) setTotal(data.total)
const backendEvidence = data.evidence || data
if (Array.isArray(backendEvidence) && backendEvidence.length > 0) {
const mapped: SDKEvidence[] = backendEvidence.map((e: Record<string, unknown>) => ({
id: (e.id || '') as string,
controlId: (e.control_id || '') as string,
type: ((e.evidence_type || 'DOCUMENT') as string).toUpperCase() as EvidenceType,
name: (e.title || e.name || '') as string,
description: (e.description || '') as string,
fileUrl: (e.artifact_url || null) as string | null,
validFrom: e.valid_from ? new Date(e.valid_from as string) : new Date(),
validUntil: e.valid_until ? new Date(e.valid_until as string) : null,
uploadedBy: (e.uploaded_by || 'System') as string,
uploadedAt: e.created_at ? new Date(e.created_at as string) : new Date(),
}))
dispatch({ type: 'SET_STATE', payload: { evidence: mapped } })
setError(null)
return
}
}
loadFromTemplates()
} catch {
loadFromTemplates()
} finally {
setLoading(false)
}
}
const loadFromTemplates = () => {
if (state.evidence.length > 0) return
if (state.controls.length === 0) return
const relevantEvidence = evidenceTemplates.filter(e =>
state.controls.some(c => c.id === e.controlId || e.linkedControls.includes(c.id))
)
const now = new Date()
relevantEvidence.forEach(template => {
const validFrom = new Date(now)
validFrom.setMonth(validFrom.getMonth() - 1)
const validUntil = template.validityDays > 0
? new Date(validFrom.getTime() + template.validityDays * 24 * 60 * 60 * 1000)
: null
const sdkEvidence: SDKEvidence = {
id: template.id,
controlId: template.controlId,
type: template.type,
name: template.name,
description: template.description,
fileUrl: null,
validFrom,
validUntil,
uploadedBy: template.uploadedBy,
uploadedAt: validFrom,
}
dispatch({ type: 'ADD_EVIDENCE', payload: sdkEvidence })
})
}
fetchEvidence()
}, [page, pageSize]) // eslint-disable-line react-hooks/exhaustive-deps
const displayEvidence: DisplayEvidence[] = state.evidence.map(ev => {
const template = evidenceTemplates.find(t => t.id === ev.id)
return {
id: ev.id,
name: ev.name,
description: ev.description,
displayType: mapEvidenceTypeToDisplay(ev.type),
format: template?.format || 'pdf',
controlId: ev.controlId,
linkedRequirements: template?.linkedRequirements || [],
linkedControls: template?.linkedControls || [ev.controlId],
uploadedBy: ev.uploadedBy,
uploadedAt: ev.uploadedAt,
validFrom: ev.validFrom,
validUntil: ev.validUntil,
status: getEvidenceStatus(ev.validUntil),
fileSize: template?.fileSize || 'Unbekannt',
fileUrl: ev.fileUrl,
}
})
const filteredEvidence = filter === 'all'
? displayEvidence
: displayEvidence.filter(e => e.status === filter || e.displayType === filter)
const validCount = displayEvidence.filter(e => e.status === 'valid').length
const expiredCount = displayEvidence.filter(e => e.status === 'expired').length
const pendingCount = displayEvidence.filter(e => e.status === 'pending-review').length
const handleDelete = async (evidenceId: string) => {
if (!confirm('Moechten Sie diesen Nachweis wirklich loeschen?')) return
dispatch({ type: 'DELETE_EVIDENCE', payload: evidenceId })
try {
await fetch(`/api/sdk/v1/compliance/evidence/${evidenceId}`, { method: 'DELETE' })
} catch {
// Silently fail — SDK state is already updated
}
}
const handleUpload = async (file: File) => {
setUploading(true)
setError(null)
try {
const controlId = state.controls.length > 0 ? state.controls[0].id : 'GENERIC'
const params = new URLSearchParams({
control_id: controlId,
evidence_type: 'document',
title: file.name,
})
const formData = new FormData()
formData.append('file', file)
const res = await fetch(`/api/sdk/v1/compliance/evidence/upload?${params}`, {
method: 'POST',
body: formData,
})
if (!res.ok) {
const errData = await res.json().catch(() => ({ error: 'Upload fehlgeschlagen' }))
throw new Error(errData.error || errData.detail || 'Upload fehlgeschlagen')
}
const data = await res.json()
const newEvidence: SDKEvidence = {
id: data.id || `ev-${Date.now()}`,
controlId: controlId,
type: 'DOCUMENT',
name: file.name,
description: `Hochgeladen am ${new Date().toLocaleDateString('de-DE')}`,
fileUrl: data.artifact_url || null,
validFrom: new Date(),
validUntil: null,
uploadedBy: 'Aktueller Benutzer',
uploadedAt: new Date(),
}
dispatch({ type: 'ADD_EVIDENCE', payload: newEvidence })
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
} finally {
setUploading(false)
}
}
const handleView = (ev: DisplayEvidence) => {
if (ev.fileUrl) {
window.open(ev.fileUrl, '_blank')
} else {
alert('Keine Datei vorhanden')
}
}
const handleDownload = (ev: DisplayEvidence) => {
if (!ev.fileUrl) return
const a = document.createElement('a')
a.href = ev.fileUrl
a.download = ev.name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const handleUploadClick = () => {
fileInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
handleUpload(file)
e.target.value = ''
}
}
return {
state,
filter,
setFilter,
loading,
error,
setError,
uploading,
fileInputRef,
page,
setPage,
pageSize,
total,
displayEvidence,
filteredEvidence,
validCount,
expiredCount,
pendingCount,
handleDelete,
handleView,
handleDownload,
handleUploadClick,
handleFileChange,
}
}

View File

@@ -1,515 +1,36 @@
'use client'
import React, { useState, useEffect, useRef } from 'react'
import { useSDK, Evidence as SDKEvidence, EvidenceType } from '@/lib/sdk'
import React from 'react'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
// =============================================================================
// TYPES
// =============================================================================
type DisplayEvidenceType = 'document' | 'screenshot' | 'log' | 'audit-report' | 'certificate'
type DisplayFormat = 'pdf' | 'image' | 'text' | 'json'
type DisplayStatus = 'valid' | 'expired' | 'pending-review'
interface DisplayEvidence {
id: string
name: string
description: string
displayType: DisplayEvidenceType
format: DisplayFormat
controlId: string
linkedRequirements: string[]
linkedControls: string[]
uploadedBy: string
uploadedAt: Date
validFrom: Date
validUntil: Date | null
status: DisplayStatus
fileSize: string
fileUrl: string | null
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function mapEvidenceTypeToDisplay(type: EvidenceType): DisplayEvidenceType {
switch (type) {
case 'DOCUMENT': return 'document'
case 'SCREENSHOT': return 'screenshot'
case 'LOG': return 'log'
case 'CERTIFICATE': return 'certificate'
case 'AUDIT_REPORT': return 'audit-report'
default: return 'document'
}
}
function getEvidenceStatus(validUntil: Date | null): DisplayStatus {
if (!validUntil) return 'pending-review'
const now = new Date()
if (validUntil < now) return 'expired'
return 'valid'
}
// =============================================================================
// FALLBACK TEMPLATES
// =============================================================================
interface EvidenceTemplate {
id: string
name: string
description: string
type: EvidenceType
displayType: DisplayEvidenceType
format: DisplayFormat
controlId: string
linkedRequirements: string[]
linkedControls: string[]
uploadedBy: string
validityDays: number
fileSize: string
}
const evidenceTemplates: EvidenceTemplate[] = [
{
id: 'ev-dse-001',
name: 'Datenschutzerklaerung v2.3',
description: 'Aktuelle Datenschutzerklaerung fuer Website und App',
type: 'DOCUMENT',
displayType: 'document',
format: 'pdf',
controlId: 'ctrl-org-001',
linkedRequirements: ['req-gdpr-13', 'req-gdpr-14'],
linkedControls: ['ctrl-org-001'],
uploadedBy: 'DSB',
validityDays: 365,
fileSize: '245 KB',
},
{
id: 'ev-pentest-001',
name: 'Penetrationstest Report Q4/2024',
description: 'Externer Penetrationstest durch Security-Partner',
type: 'AUDIT_REPORT',
displayType: 'audit-report',
format: 'pdf',
controlId: 'ctrl-tom-001',
linkedRequirements: ['req-gdpr-32', 'req-iso-a12'],
linkedControls: ['ctrl-tom-001', 'ctrl-tom-002', 'ctrl-det-001'],
uploadedBy: 'IT Security Team',
validityDays: 365,
fileSize: '2.1 MB',
},
{
id: 'ev-iso-cert',
name: 'ISO 27001 Zertifikat',
description: 'Zertifizierung des ISMS',
type: 'CERTIFICATE',
displayType: 'certificate',
format: 'pdf',
controlId: 'ctrl-tom-001',
linkedRequirements: ['req-iso-4.1', 'req-iso-5.1'],
linkedControls: [],
uploadedBy: 'QM Abteilung',
validityDays: 365,
fileSize: '156 KB',
},
{
id: 'ev-schulung-001',
name: 'Schulungsnachweis Datenschutz 2024',
description: 'Teilnehmerliste und Schulungsinhalt',
type: 'DOCUMENT',
displayType: 'document',
format: 'pdf',
controlId: 'ctrl-org-001',
linkedRequirements: ['req-gdpr-39'],
linkedControls: ['ctrl-org-001'],
uploadedBy: 'HR Team',
validityDays: 365,
fileSize: '890 KB',
},
{
id: 'ev-rbac-001',
name: 'Access Control Screenshot',
description: 'Nachweis der RBAC-Konfiguration',
type: 'SCREENSHOT',
displayType: 'screenshot',
format: 'image',
controlId: 'ctrl-tom-001',
linkedRequirements: ['req-gdpr-32'],
linkedControls: ['ctrl-tom-001'],
uploadedBy: 'Admin',
validityDays: 0,
fileSize: '1.2 MB',
},
{
id: 'ev-log-001',
name: 'Audit Log Export',
description: 'Monatlicher Audit-Log Export',
type: 'LOG',
displayType: 'log',
format: 'json',
controlId: 'ctrl-det-001',
linkedRequirements: ['req-gdpr-32'],
linkedControls: ['ctrl-det-001'],
uploadedBy: 'System',
validityDays: 90,
fileSize: '4.5 MB',
},
]
// =============================================================================
// COMPONENTS
// =============================================================================
function EvidenceCard({ evidence, onDelete, onView, onDownload }: { evidence: DisplayEvidence; onDelete: () => void; onView: () => void; onDownload: () => void }) {
const typeIcons = {
document: (
<svg className="w-6 h-6" 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>
),
screenshot: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
),
log: (
<svg className="w-6 h-6" 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-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
),
'audit-report': (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 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>
),
certificate: (
<svg className="w-6 h-6" 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>
),
}
const statusColors = {
valid: 'bg-green-100 text-green-700 border-green-200',
expired: 'bg-red-100 text-red-700 border-red-200',
'pending-review': 'bg-yellow-100 text-yellow-700 border-yellow-200',
}
const statusLabels = {
valid: 'Gueltig',
expired: 'Abgelaufen',
'pending-review': 'Pruefung ausstehend',
}
return (
<div className={`bg-white rounded-xl border-2 p-6 ${
evidence.status === 'expired' ? 'border-red-200' :
evidence.status === 'pending-review' ? 'border-yellow-200' : 'border-gray-200'
}`}>
<div className="flex items-start gap-4">
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
evidence.displayType === 'certificate' ? 'bg-yellow-100 text-yellow-600' :
evidence.displayType === 'audit-report' ? 'bg-purple-100 text-purple-600' :
evidence.displayType === 'screenshot' ? 'bg-blue-100 text-blue-600' :
evidence.displayType === 'log' ? 'bg-green-100 text-green-600' :
'bg-gray-100 text-gray-600'
}`}>
{typeIcons[evidence.displayType]}
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">{evidence.name}</h3>
<span className={`px-3 py-1 text-xs rounded-full ${statusColors[evidence.status]}`}>
{statusLabels[evidence.status]}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">{evidence.description}</p>
<div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
<span>Hochgeladen: {evidence.uploadedAt.toLocaleDateString('de-DE')}</span>
{evidence.validUntil && (
<span className={evidence.status === 'expired' ? 'text-red-600' : ''}>
Gueltig bis: {evidence.validUntil.toLocaleDateString('de-DE')}
</span>
)}
<span>{evidence.fileSize}</span>
</div>
<div className="mt-3 flex items-center gap-2 flex-wrap">
{evidence.linkedRequirements.map(req => (
<span key={req} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
{req}
</span>
))}
{evidence.linkedControls.map(ctrl => (
<span key={ctrl} className="px-2 py-0.5 text-xs bg-green-50 text-green-600 rounded">
{ctrl}
</span>
))}
</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<span className="text-sm text-gray-500">Hochgeladen von: {evidence.uploadedBy}</span>
<div className="flex items-center gap-2">
<button
onClick={onView}
disabled={!evidence.fileUrl}
className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Anzeigen
</button>
<button
onClick={onDownload}
disabled={!evidence.fileUrl}
className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Herunterladen
</button>
<button
onClick={onDelete}
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
Loeschen
</button>
</div>
</div>
</div>
)
}
function LoadingSkeleton() {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-gray-200 rounded-lg" />
<div className="flex-1">
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
<div className="h-4 w-full bg-gray-100 rounded" />
</div>
</div>
</div>
))}
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
import { EvidenceCard } from './_components/EvidenceCard'
import { LoadingSkeleton } from './_components/LoadingSkeleton'
import { useEvidence } from './_hooks/useEvidence'
export default function EvidencePage() {
const { state, dispatch } = useSDK()
const [filter, setFilter] = useState<string>('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [total, setTotal] = useState(0)
// Fetch evidence from backend on mount and when page changes
useEffect(() => {
const fetchEvidence = async () => {
try {
setLoading(true)
const res = await fetch(`/api/sdk/v1/compliance/evidence?page=${page}&limit=${pageSize}`)
if (res.ok) {
const data = await res.json()
if (data.total !== undefined) setTotal(data.total)
const backendEvidence = data.evidence || data
if (Array.isArray(backendEvidence) && backendEvidence.length > 0) {
const mapped: SDKEvidence[] = backendEvidence.map((e: Record<string, unknown>) => ({
id: (e.id || '') as string,
controlId: (e.control_id || '') as string,
type: ((e.evidence_type || 'DOCUMENT') as string).toUpperCase() as EvidenceType,
name: (e.title || e.name || '') as string,
description: (e.description || '') as string,
fileUrl: (e.artifact_url || null) as string | null,
validFrom: e.valid_from ? new Date(e.valid_from as string) : new Date(),
validUntil: e.valid_until ? new Date(e.valid_until as string) : null,
uploadedBy: (e.uploaded_by || 'System') as string,
uploadedAt: e.created_at ? new Date(e.created_at as string) : new Date(),
}))
dispatch({ type: 'SET_STATE', payload: { evidence: mapped } })
setError(null)
return
}
}
loadFromTemplates()
} catch {
loadFromTemplates()
} finally {
setLoading(false)
}
}
const loadFromTemplates = () => {
if (state.evidence.length > 0) return
if (state.controls.length === 0) return
const relevantEvidence = evidenceTemplates.filter(e =>
state.controls.some(c => c.id === e.controlId || e.linkedControls.includes(c.id))
)
const now = new Date()
relevantEvidence.forEach(template => {
const validFrom = new Date(now)
validFrom.setMonth(validFrom.getMonth() - 1)
const validUntil = template.validityDays > 0
? new Date(validFrom.getTime() + template.validityDays * 24 * 60 * 60 * 1000)
: null
const sdkEvidence: SDKEvidence = {
id: template.id,
controlId: template.controlId,
type: template.type,
name: template.name,
description: template.description,
fileUrl: null,
validFrom,
validUntil,
uploadedBy: template.uploadedBy,
uploadedAt: validFrom,
}
dispatch({ type: 'ADD_EVIDENCE', payload: sdkEvidence })
})
}
fetchEvidence()
}, [page, pageSize]) // eslint-disable-line react-hooks/exhaustive-deps
// Convert SDK evidence to display evidence
const displayEvidence: DisplayEvidence[] = state.evidence.map(ev => {
const template = evidenceTemplates.find(t => t.id === ev.id)
return {
id: ev.id,
name: ev.name,
description: ev.description,
displayType: mapEvidenceTypeToDisplay(ev.type),
format: template?.format || 'pdf',
controlId: ev.controlId,
linkedRequirements: template?.linkedRequirements || [],
linkedControls: template?.linkedControls || [ev.controlId],
uploadedBy: ev.uploadedBy,
uploadedAt: ev.uploadedAt,
validFrom: ev.validFrom,
validUntil: ev.validUntil,
status: getEvidenceStatus(ev.validUntil),
fileSize: template?.fileSize || 'Unbekannt',
fileUrl: ev.fileUrl,
}
})
const filteredEvidence = filter === 'all'
? displayEvidence
: displayEvidence.filter(e => e.status === filter || e.displayType === filter)
const validCount = displayEvidence.filter(e => e.status === 'valid').length
const expiredCount = displayEvidence.filter(e => e.status === 'expired').length
const pendingCount = displayEvidence.filter(e => e.status === 'pending-review').length
const handleDelete = async (evidenceId: string) => {
if (!confirm('Moechten Sie diesen Nachweis wirklich loeschen?')) return
dispatch({ type: 'DELETE_EVIDENCE', payload: evidenceId })
try {
await fetch(`/api/sdk/v1/compliance/evidence/${evidenceId}`, {
method: 'DELETE',
})
} catch {
// Silently fail — SDK state is already updated
}
}
const handleUpload = async (file: File) => {
setUploading(true)
setError(null)
try {
// Use the first control as default, or a generic one
const controlId = state.controls.length > 0 ? state.controls[0].id : 'GENERIC'
const params = new URLSearchParams({
control_id: controlId,
evidence_type: 'document',
title: file.name,
})
const formData = new FormData()
formData.append('file', file)
const res = await fetch(`/api/sdk/v1/compliance/evidence/upload?${params}`, {
method: 'POST',
body: formData,
})
if (!res.ok) {
const errData = await res.json().catch(() => ({ error: 'Upload fehlgeschlagen' }))
throw new Error(errData.error || errData.detail || 'Upload fehlgeschlagen')
}
const data = await res.json()
// Add to SDK state
const newEvidence: SDKEvidence = {
id: data.id || `ev-${Date.now()}`,
controlId: controlId,
type: 'DOCUMENT',
name: file.name,
description: `Hochgeladen am ${new Date().toLocaleDateString('de-DE')}`,
fileUrl: data.artifact_url || null,
validFrom: new Date(),
validUntil: null,
uploadedBy: 'Aktueller Benutzer',
uploadedAt: new Date(),
}
dispatch({ type: 'ADD_EVIDENCE', payload: newEvidence })
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
} finally {
setUploading(false)
}
}
const handleView = (ev: DisplayEvidence) => {
if (ev.fileUrl) {
window.open(ev.fileUrl, '_blank')
} else {
alert('Keine Datei vorhanden')
}
}
const handleDownload = (ev: DisplayEvidence) => {
if (!ev.fileUrl) return
const a = document.createElement('a')
a.href = ev.fileUrl
a.download = ev.name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const handleUploadClick = () => {
fileInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
handleUpload(file)
e.target.value = '' // Reset input
}
}
const {
state,
filter,
setFilter,
loading,
error,
setError,
uploading,
fileInputRef,
page,
setPage,
pageSize,
total,
displayEvidence,
filteredEvidence,
validCount,
expiredCount,
pendingCount,
handleDelete,
handleView,
handleDownload,
handleUploadClick,
handleFileChange,
} = useEvidence()
const stepInfo = STEP_EXPLANATIONS['evidence']
@@ -609,9 +130,7 @@ export default function EvidencePage() {
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
filter === f
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
filter === f ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{f === 'all' ? 'Alle' :

View File

@@ -0,0 +1,42 @@
'use client'
import { GCIResult } from '@/lib/sdk/gci/types'
export function AuditTab({ gci }: { gci: GCIResult }) {
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">
Audit Trail - Berechnung GCI {gci.gci_score.toFixed(1)}
</h3>
<p className="text-sm text-gray-500 mb-4">
Jeder Schritt der GCI-Berechnung ist nachvollziehbar und prueffaehig dokumentiert.
</p>
<div className="space-y-2">
{gci.audit_trail.map((entry, i) => (
<div key={i} className="flex items-start gap-3 p-3 border border-gray-100 rounded-lg">
<div className={`flex-shrink-0 w-2 h-2 rounded-full mt-1.5 ${
entry.impact === 'positive' ? 'bg-green-500' :
entry.impact === 'negative' ? 'bg-red-500' :
'bg-gray-400'
}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900">{entry.factor}</span>
<span className={`text-sm font-mono ${
entry.impact === 'positive' ? 'text-green-600' :
entry.impact === 'negative' ? 'text-red-600' :
'text-gray-600'
}`}>
{entry.value > 0 ? '+' : ''}{entry.value.toFixed(2)}
</span>
</div>
<p className="text-xs text-gray-500 mt-0.5">{entry.description}</p>
</div>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,75 @@
'use client'
import { GCIBreakdown } from '@/lib/sdk/gci/types'
import { getScoreColor } from '@/lib/sdk/gci/types'
import { LoadingSpinner } from './GCIHelpers'
export function BreakdownTab({ breakdown }: { breakdown: GCIBreakdown | null; loading: boolean }) {
if (!breakdown) return <LoadingSpinner />
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Level 1: Modul-Scores</h3>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 pr-4 font-medium text-gray-600">Modul</th>
<th className="text-left py-2 pr-4 font-medium text-gray-600">Kategorie</th>
<th className="text-right py-2 pr-4 font-medium text-gray-600">Zugewiesen</th>
<th className="text-right py-2 pr-4 font-medium text-gray-600">Abgeschlossen</th>
<th className="text-right py-2 pr-4 font-medium text-gray-600">Raw Score</th>
<th className="text-right py-2 pr-4 font-medium text-gray-600">Validitaet</th>
<th className="text-right py-2 font-medium text-gray-600">Final</th>
</tr>
</thead>
<tbody>
{breakdown.level1_modules.map(m => (
<tr key={m.module_id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-2 pr-4 font-medium text-gray-900">{m.module_name}</td>
<td className="py-2 pr-4">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">
{m.category}
</span>
</td>
<td className="py-2 pr-4 text-right text-gray-600">{m.assigned}</td>
<td className="py-2 pr-4 text-right text-gray-600">{m.completed}</td>
<td className="py-2 pr-4 text-right text-gray-600">{(m.raw_score * 100).toFixed(1)}%</td>
<td className="py-2 pr-4 text-right text-gray-600">{(m.validity_factor * 100).toFixed(0)}%</td>
<td className={`py-2 text-right font-semibold ${getScoreColor(m.final_score * 100)}`}>
{(m.final_score * 100).toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Level 2: Regulierungsbereiche (risikogewichtet)</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{breakdown.level2_areas.map(area => (
<div key={area.area_id} className="border border-gray-200 rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<h4 className="font-medium text-gray-900">{area.area_name}</h4>
<span className={`text-lg font-bold ${getScoreColor(area.area_score)}`}>
{area.area_score.toFixed(1)}%
</span>
</div>
<div className="space-y-1">
{area.modules.map(m => (
<div key={m.module_id} className="flex justify-between text-xs text-gray-500">
<span>{m.module_name}</span>
<span>{(m.final_score * 100).toFixed(0)}% (w:{m.risk_weight.toFixed(1)})</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,116 @@
'use client'
import { MaturityLevel, MATURITY_INFO, getScoreColor, getScoreRingColor } from '@/lib/sdk/gci/types'
export type TabId = 'overview' | 'breakdown' | 'nis2' | 'iso' | 'matrix' | 'audit'
export interface Tab {
id: TabId
label: string
}
export const TABS: Tab[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'breakdown', label: 'Breakdown' },
{ id: 'nis2', label: 'NIS2' },
{ id: 'iso', label: 'ISO 27001' },
{ id: 'matrix', label: 'Matrix' },
{ id: 'audit', label: 'Audit Trail' },
]
export 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 overflow-x-auto" 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 whitespace-nowrap ${
activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
)
}
export function ScoreCircle({ score, size = 144, label }: { score: number; size?: number; label?: string }) {
const radius = (size / 2) - 12
const circumference = 2 * Math.PI * radius
const strokeDashoffset = circumference - (score / 100) * circumference
return (
<div className="relative flex flex-col items-center">
<svg className="-rotate-90" width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle cx={size/2} cy={size/2} r={radius} stroke="#e5e7eb" strokeWidth="8" fill="none" />
<circle
cx={size/2} cy={size/2} r={radius}
stroke={getScoreRingColor(score)}
strokeWidth="8" fill="none"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className="transition-all duration-1000"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-3xl font-bold ${getScoreColor(score)}`}>{score.toFixed(1)}</span>
{label && <span className="text-xs text-gray-500 mt-1">{label}</span>}
</div>
</div>
)
}
export function MaturityBadge({ level }: { level: MaturityLevel }) {
const info = MATURITY_INFO[level] || MATURITY_INFO.HIGH_RISK
return (
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${info.bgColor} ${info.color} border ${info.borderColor}`}>
{info.label}
</span>
)
}
export function AreaScoreBar({ name, score, weight }: { name: string; score: number; weight: number }) {
return (
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-medium text-gray-700">{name}</span>
<span className={`font-semibold ${getScoreColor(score)}`}>{score.toFixed(1)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="h-3 rounded-full transition-all duration-700"
style={{ width: `${Math.min(score, 100)}%`, backgroundColor: getScoreRingColor(score) }}
/>
</div>
<div className="text-xs text-gray-400">Gewichtung: {(weight * 100).toFixed(0)}%</div>
</div>
)
}
export function LoadingSpinner() {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
)
}
export function ErrorMessage({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<p>{message}</p>
{onRetry && (
<button onClick={onRetry} className="mt-2 text-sm underline hover:no-underline">
Erneut versuchen
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import { ISOGapAnalysis } from '@/lib/sdk/gci/types'
import { ScoreCircle, LoadingSpinner } from './GCIHelpers'
export function ISOTab({ iso }: { iso: ISOGapAnalysis | null }) {
if (!iso) return <LoadingSpinner />
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center gap-6">
<ScoreCircle score={iso.coverage_percent} size={120} label="Abdeckung" />
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">ISO 27001:2022 Gap-Analyse</h3>
<div className="grid grid-cols-3 gap-4 mt-3">
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{iso.covered_full}</div>
<div className="text-xs text-gray-500">Voll abgedeckt</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-yellow-600">{iso.covered_partial}</div>
<div className="text-xs text-gray-500">Teilweise</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-red-600">{iso.not_covered}</div>
<div className="text-xs text-gray-500">Nicht abgedeckt</div>
</div>
</div>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Kategorien</h3>
<div className="space-y-3">
{iso.category_summaries.map(cat => (
<div key={cat.category_id} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-medium text-gray-700">{cat.category_id}: {cat.category_name}</span>
<span className="text-gray-500">{cat.covered_full}/{cat.total_controls} Controls</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 flex overflow-hidden">
<div className="h-3 bg-green-500" style={{ width: `${(cat.covered_full / cat.total_controls) * 100}%` }} />
<div className="h-3 bg-yellow-500" style={{ width: `${(cat.covered_partial / cat.total_controls) * 100}%` }} />
</div>
</div>
))}
</div>
</div>
{iso.gaps && iso.gaps.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Offene Gaps ({iso.gaps.length})</h3>
<div className="space-y-2 max-h-96 overflow-y-auto">
{iso.gaps.map(gap => (
<div key={gap.control_id} className="flex items-start gap-3 p-3 border border-gray-100 rounded-lg hover:bg-gray-50">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
gap.priority === 'high' ? 'bg-red-100 text-red-700' :
gap.priority === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-700'
}`}>
{gap.priority}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900">{gap.control_id}: {gap.control_name}</div>
<div className="text-xs text-gray-500 mt-0.5">{gap.recommendation}</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,54 @@
'use client'
import { GCIMatrixResponse, getScoreColor } from '@/lib/sdk/gci/types'
import { LoadingSpinner } from './GCIHelpers'
export function MatrixTab({ matrix }: { matrix: GCIMatrixResponse | null }) {
if (!matrix || !matrix.matrix) return <LoadingSpinner />
const regulations = matrix.matrix.length > 0 ? Object.keys(matrix.matrix[0].regulations) : []
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Compliance-Matrix (Rollen x Regulierungen)</h3>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 pr-4 font-medium text-gray-600">Rolle</th>
{regulations.map(r => (
<th key={r} className="text-center py-2 px-3 font-medium text-gray-600 uppercase">{r}</th>
))}
<th className="text-center py-2 px-3 font-medium text-gray-600">Gesamt</th>
<th className="text-center py-2 px-3 font-medium text-gray-600">Module</th>
</tr>
</thead>
<tbody>
{matrix.matrix.map(entry => (
<tr key={entry.role} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-2 pr-4 font-medium text-gray-900">{entry.role_name}</td>
{regulations.map(r => (
<td key={r} className="py-2 px-3 text-center">
<span className={`font-semibold ${getScoreColor(entry.regulations[r])}`}>
{entry.regulations[r].toFixed(0)}%
</span>
</td>
))}
<td className="py-2 px-3 text-center">
<span className={`font-bold ${getScoreColor(entry.overall_score)}`}>
{entry.overall_score.toFixed(0)}%
</span>
</td>
<td className="py-2 px-3 text-center text-gray-500">
{entry.completed_modules}/{entry.required_modules}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import { NIS2Score, getScoreColor, getScoreRingColor } from '@/lib/sdk/gci/types'
import { ScoreCircle, AreaScoreBar, LoadingSpinner } from './GCIHelpers'
export function NIS2Tab({ nis2 }: { nis2: NIS2Score | null }) {
if (!nis2) return <LoadingSpinner />
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center gap-6">
<ScoreCircle score={nis2.overall_score} size={120} label="NIS2" />
<div>
<h3 className="text-lg font-semibold text-gray-900">NIS2 Compliance Score</h3>
<p className="text-sm text-gray-500 mt-1">Network and Information Security Directive 2 (EU 2022/2555)</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">NIS2 Bereiche</h3>
<div className="space-y-3">
{nis2.areas.map(area => (
<AreaScoreBar key={area.area_id} name={area.area_name} score={area.score} weight={area.weight} />
))}
</div>
</div>
{nis2.role_scores && nis2.role_scores.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Rollen-Compliance</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{nis2.role_scores.map(role => (
<div key={role.role_id} className="border border-gray-200 rounded-lg p-3">
<div className="font-medium text-gray-900 text-sm">{role.role_name}</div>
<div className="flex items-center justify-between mt-2">
<span className={`text-lg font-bold ${getScoreColor(role.completion_rate * 100)}`}>
{(role.completion_rate * 100).toFixed(0)}%
</span>
<span className="text-xs text-gray-500">{role.modules_completed}/{role.modules_required} Module</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-2">
<div
className="h-1.5 rounded-full"
style={{ width: `${Math.min(role.completion_rate * 100, 100)}%`, backgroundColor: getScoreRingColor(role.completion_rate * 100) }}
/>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,91 @@
'use client'
import { GCIResult, GCIHistoryResponse, WeightProfile, MATURITY_INFO, getScoreRingColor } from '@/lib/sdk/gci/types'
import { ScoreCircle, MaturityBadge, AreaScoreBar } from './GCIHelpers'
export function OverviewTab({ gci, history, profiles, selectedProfile, onProfileChange }: {
gci: GCIResult
history: GCIHistoryResponse | null
profiles: WeightProfile[]
selectedProfile: string
onProfileChange: (p: string) => void
}) {
return (
<div className="space-y-6">
{profiles.length > 0 && (
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-gray-700">Gewichtungsprofil:</label>
<select
value={selectedProfile}
onChange={e => onProfileChange(e.target.value)}
className="rounded-md border-gray-300 shadow-sm text-sm focus:border-purple-500 focus:ring-purple-500"
>
{profiles.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
)}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex flex-col md:flex-row items-center gap-8">
<ScoreCircle score={gci.gci_score} label="GCI Score" />
<div className="flex-1 space-y-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">Gesamt-Compliance-Index</h3>
<div className="flex items-center gap-3 mt-2">
<MaturityBadge level={gci.maturity_level} />
<span className="text-sm text-gray-500">
Berechnet: {new Date(gci.calculated_at).toLocaleString('de-DE')}
</span>
</div>
</div>
<p className="text-sm text-gray-600">{MATURITY_INFO[gci.maturity_level]?.description || ''}</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Regulierungsbereiche</h3>
<div className="space-y-4">
{gci.area_scores.map(area => (
<AreaScoreBar key={area.regulation_id} name={area.regulation_name} score={area.score} weight={area.weight} />
))}
</div>
</div>
{history && history.snapshots.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Verlauf</h3>
<div className="flex items-end gap-2 h-32">
{history.snapshots.map((snap, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<span className="text-xs text-gray-500">{snap.score.toFixed(0)}</span>
<div
className="w-full rounded-t transition-all duration-500"
style={{ height: `${(snap.score / 100) * 100}%`, backgroundColor: getScoreRingColor(snap.score), minHeight: '4px' }}
/>
<span className="text-[10px] text-gray-400">
{new Date(snap.calculated_at).toLocaleDateString('de-DE', { month: 'short' })}
</span>
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm text-gray-500">Kritikalitaets-Multiplikator</div>
<div className="text-2xl font-bold text-gray-900">{gci.criticality_multiplier.toFixed(2)}x</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm text-gray-500">Incident-Korrektur</div>
<div className={`text-2xl font-bold ${gci.incident_adjustment < 0 ? 'text-red-600' : 'text-green-600'}`}>
{gci.incident_adjustment > 0 ? '+' : ''}{gci.incident_adjustment.toFixed(1)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,97 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import {
GCIResult,
GCIBreakdown,
GCIHistoryResponse,
GCIMatrixResponse,
NIS2Score,
ISOGapAnalysis,
WeightProfile,
} from '@/lib/sdk/gci/types'
import {
getGCIScore,
getGCIBreakdown,
getGCIHistory,
getGCIMatrix,
getNIS2Score,
getISOGapAnalysis,
getWeightProfiles,
} from '@/lib/sdk/gci/api'
import { TabId } from '../_components/GCIHelpers'
export function useGCI() {
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [gci, setGCI] = useState<GCIResult | null>(null)
const [breakdown, setBreakdown] = useState<GCIBreakdown | null>(null)
const [history, setHistory] = useState<GCIHistoryResponse | null>(null)
const [matrix, setMatrix] = useState<GCIMatrixResponse | null>(null)
const [nis2, setNIS2] = useState<NIS2Score | null>(null)
const [iso, setISO] = useState<ISOGapAnalysis | null>(null)
const [profiles, setProfiles] = useState<WeightProfile[]>([])
const [selectedProfile, setSelectedProfile] = useState('default')
const loadData = useCallback(async (profile?: string) => {
setLoading(true)
setError(null)
try {
const [gciRes, historyRes, profilesRes] = await Promise.all([
getGCIScore(profile),
getGCIHistory(),
getWeightProfiles(),
])
setGCI(gciRes)
setHistory(historyRes)
setProfiles(profilesRes.profiles || [])
} catch (err: any) {
setError(err.message || 'Fehler beim Laden der GCI-Daten')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadData(selectedProfile)
}, [selectedProfile, loadData])
useEffect(() => {
if (activeTab === 'breakdown' && !breakdown && gci) {
getGCIBreakdown(selectedProfile).then(setBreakdown).catch(() => {})
}
if (activeTab === 'nis2' && !nis2) {
getNIS2Score().then(setNIS2).catch(() => {})
}
if (activeTab === 'iso' && !iso) {
getISOGapAnalysis().then(setISO).catch(() => {})
}
if (activeTab === 'matrix' && !matrix) {
getGCIMatrix().then(setMatrix).catch(() => {})
}
}, [activeTab, breakdown, nis2, iso, matrix, gci, selectedProfile])
const handleProfileChange = (profile: string) => {
setSelectedProfile(profile)
setBreakdown(null)
}
return {
activeTab,
setActiveTab,
loading,
error,
gci,
breakdown,
history,
matrix,
nis2,
iso,
profiles,
selectedProfile,
loadData,
handleProfileChange,
}
}

View File

@@ -1,655 +1,38 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import {
GCIResult,
GCIBreakdown,
GCIHistoryResponse,
GCIMatrixResponse,
NIS2Score,
ISOGapAnalysis,
WeightProfile,
MaturityLevel,
MATURITY_INFO,
getScoreColor,
getScoreRingColor,
} from '@/lib/sdk/gci/types'
import {
getGCIScore,
getGCIBreakdown,
getGCIHistory,
getGCIMatrix,
getNIS2Score,
getISOGapAnalysis,
getWeightProfiles,
} from '@/lib/sdk/gci/api'
// =============================================================================
// TYPES
// =============================================================================
type TabId = 'overview' | 'breakdown' | 'nis2' | 'iso' | 'matrix' | 'audit'
interface Tab {
id: TabId
label: string
}
const TABS: Tab[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'breakdown', label: 'Breakdown' },
{ id: 'nis2', label: 'NIS2' },
{ id: 'iso', label: 'ISO 27001' },
{ id: 'matrix', label: 'Matrix' },
{ id: 'audit', label: 'Audit Trail' },
]
// =============================================================================
// HELPER 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 overflow-x-auto" 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 whitespace-nowrap ${
activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
)
}
function ScoreCircle({ score, size = 144, label }: { score: number; size?: number; label?: string }) {
const radius = (size / 2) - 12
const circumference = 2 * Math.PI * radius
const strokeDashoffset = circumference - (score / 100) * circumference
return (
<div className="relative flex flex-col items-center">
<svg className="-rotate-90" width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle cx={size/2} cy={size/2} r={radius} stroke="#e5e7eb" strokeWidth="8" fill="none" />
<circle
cx={size/2} cy={size/2} r={radius}
stroke={getScoreRingColor(score)}
strokeWidth="8" fill="none"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className="transition-all duration-1000"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-3xl font-bold ${getScoreColor(score)}`}>{score.toFixed(1)}</span>
{label && <span className="text-xs text-gray-500 mt-1">{label}</span>}
</div>
</div>
)
}
function MaturityBadge({ level }: { level: MaturityLevel }) {
const info = MATURITY_INFO[level] || MATURITY_INFO.HIGH_RISK
return (
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${info.bgColor} ${info.color} border ${info.borderColor}`}>
{info.label}
</span>
)
}
function AreaScoreBar({ name, score, weight }: { name: string; score: number; weight: number }) {
return (
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-medium text-gray-700">{name}</span>
<span className={`font-semibold ${getScoreColor(score)}`}>{score.toFixed(1)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="h-3 rounded-full transition-all duration-700"
style={{ width: `${Math.min(score, 100)}%`, backgroundColor: getScoreRingColor(score) }}
/>
</div>
<div className="text-xs text-gray-400">Gewichtung: {(weight * 100).toFixed(0)}%</div>
</div>
)
}
function LoadingSpinner() {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
)
}
function ErrorMessage({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<p>{message}</p>
{onRetry && (
<button onClick={onRetry} className="mt-2 text-sm underline hover:no-underline">
Erneut versuchen
</button>
)}
</div>
)
}
// =============================================================================
// TAB: OVERVIEW
// =============================================================================
function OverviewTab({ gci, history, profiles, selectedProfile, onProfileChange }: {
gci: GCIResult
history: GCIHistoryResponse | null
profiles: WeightProfile[]
selectedProfile: string
onProfileChange: (p: string) => void
}) {
return (
<div className="space-y-6">
{/* Profile Selector */}
{profiles.length > 0 && (
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-gray-700">Gewichtungsprofil:</label>
<select
value={selectedProfile}
onChange={e => onProfileChange(e.target.value)}
className="rounded-md border-gray-300 shadow-sm text-sm focus:border-purple-500 focus:ring-purple-500"
>
{profiles.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
)}
{/* Main Score */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex flex-col md:flex-row items-center gap-8">
<ScoreCircle score={gci.gci_score} label="GCI Score" />
<div className="flex-1 space-y-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">Gesamt-Compliance-Index</h3>
<div className="flex items-center gap-3 mt-2">
<MaturityBadge level={gci.maturity_level} />
<span className="text-sm text-gray-500">
Berechnet: {new Date(gci.calculated_at).toLocaleString('de-DE')}
</span>
</div>
</div>
<p className="text-sm text-gray-600">
{MATURITY_INFO[gci.maturity_level]?.description || ''}
</p>
</div>
</div>
</div>
{/* Area Scores */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Regulierungsbereiche</h3>
<div className="space-y-4">
{gci.area_scores.map(area => (
<AreaScoreBar
key={area.regulation_id}
name={area.regulation_name}
score={area.score}
weight={area.weight}
/>
))}
</div>
</div>
{/* History Chart (simplified) */}
{history && history.snapshots.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Verlauf</h3>
<div className="flex items-end gap-2 h-32">
{history.snapshots.map((snap, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<span className="text-xs text-gray-500">{snap.score.toFixed(0)}</span>
<div
className="w-full rounded-t transition-all duration-500"
style={{
height: `${(snap.score / 100) * 100}%`,
backgroundColor: getScoreRingColor(snap.score),
minHeight: '4px',
}}
/>
<span className="text-[10px] text-gray-400">
{new Date(snap.calculated_at).toLocaleDateString('de-DE', { month: 'short' })}
</span>
</div>
))}
</div>
</div>
)}
{/* Adjustments */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm text-gray-500">Kritikalitaets-Multiplikator</div>
<div className="text-2xl font-bold text-gray-900">{gci.criticality_multiplier.toFixed(2)}x</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm text-gray-500">Incident-Korrektur</div>
<div className={`text-2xl font-bold ${gci.incident_adjustment < 0 ? 'text-red-600' : 'text-green-600'}`}>
{gci.incident_adjustment > 0 ? '+' : ''}{gci.incident_adjustment.toFixed(1)}
</div>
</div>
</div>
</div>
)
}
// =============================================================================
// TAB: BREAKDOWN
// =============================================================================
function BreakdownTab({ breakdown }: { breakdown: GCIBreakdown | null; loading: boolean }) {
if (!breakdown) return <LoadingSpinner />
return (
<div className="space-y-6">
{/* Level 1: Modules */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Level 1: Modul-Scores</h3>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 pr-4 font-medium text-gray-600">Modul</th>
<th className="text-left py-2 pr-4 font-medium text-gray-600">Kategorie</th>
<th className="text-right py-2 pr-4 font-medium text-gray-600">Zugewiesen</th>
<th className="text-right py-2 pr-4 font-medium text-gray-600">Abgeschlossen</th>
<th className="text-right py-2 pr-4 font-medium text-gray-600">Raw Score</th>
<th className="text-right py-2 pr-4 font-medium text-gray-600">Validitaet</th>
<th className="text-right py-2 font-medium text-gray-600">Final</th>
</tr>
</thead>
<tbody>
{breakdown.level1_modules.map(m => (
<tr key={m.module_id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-2 pr-4 font-medium text-gray-900">{m.module_name}</td>
<td className="py-2 pr-4">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">
{m.category}
</span>
</td>
<td className="py-2 pr-4 text-right text-gray-600">{m.assigned}</td>
<td className="py-2 pr-4 text-right text-gray-600">{m.completed}</td>
<td className="py-2 pr-4 text-right text-gray-600">{(m.raw_score * 100).toFixed(1)}%</td>
<td className="py-2 pr-4 text-right text-gray-600">{(m.validity_factor * 100).toFixed(0)}%</td>
<td className={`py-2 text-right font-semibold ${getScoreColor(m.final_score * 100)}`}>
{(m.final_score * 100).toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Level 2: Areas */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Level 2: Regulierungsbereiche (risikogewichtet)</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{breakdown.level2_areas.map(area => (
<div key={area.area_id} className="border border-gray-200 rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<h4 className="font-medium text-gray-900">{area.area_name}</h4>
<span className={`text-lg font-bold ${getScoreColor(area.area_score)}`}>
{area.area_score.toFixed(1)}%
</span>
</div>
<div className="space-y-1">
{area.modules.map(m => (
<div key={m.module_id} className="flex justify-between text-xs text-gray-500">
<span>{m.module_name}</span>
<span>{(m.final_score * 100).toFixed(0)}% (w:{m.risk_weight.toFixed(1)})</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}
// =============================================================================
// TAB: NIS2
// =============================================================================
function NIS2Tab({ nis2 }: { nis2: NIS2Score | null }) {
if (!nis2) return <LoadingSpinner />
return (
<div className="space-y-6">
{/* NIS2 Overall */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center gap-6">
<ScoreCircle score={nis2.overall_score} size={120} label="NIS2" />
<div>
<h3 className="text-lg font-semibold text-gray-900">NIS2 Compliance Score</h3>
<p className="text-sm text-gray-500 mt-1">
Network and Information Security Directive 2 (EU 2022/2555)
</p>
</div>
</div>
</div>
{/* NIS2 Areas */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">NIS2 Bereiche</h3>
<div className="space-y-3">
{nis2.areas.map(area => (
<AreaScoreBar key={area.area_id} name={area.area_name} score={area.score} weight={area.weight} />
))}
</div>
</div>
{/* NIS2 Roles */}
{nis2.role_scores && nis2.role_scores.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Rollen-Compliance</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{nis2.role_scores.map(role => (
<div key={role.role_id} className="border border-gray-200 rounded-lg p-3">
<div className="font-medium text-gray-900 text-sm">{role.role_name}</div>
<div className="flex items-center justify-between mt-2">
<span className={`text-lg font-bold ${getScoreColor(role.completion_rate * 100)}`}>
{(role.completion_rate * 100).toFixed(0)}%
</span>
<span className="text-xs text-gray-500">
{role.modules_completed}/{role.modules_required} Module
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-2">
<div
className="h-1.5 rounded-full"
style={{
width: `${Math.min(role.completion_rate * 100, 100)}%`,
backgroundColor: getScoreRingColor(role.completion_rate * 100),
}}
/>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
// =============================================================================
// TAB: ISO 27001
// =============================================================================
function ISOTab({ iso }: { iso: ISOGapAnalysis | null }) {
if (!iso) return <LoadingSpinner />
return (
<div className="space-y-6">
{/* Coverage Overview */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center gap-6">
<ScoreCircle score={iso.coverage_percent} size={120} label="Abdeckung" />
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">ISO 27001:2022 Gap-Analyse</h3>
<div className="grid grid-cols-3 gap-4 mt-3">
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{iso.covered_full}</div>
<div className="text-xs text-gray-500">Voll abgedeckt</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-yellow-600">{iso.covered_partial}</div>
<div className="text-xs text-gray-500">Teilweise</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-red-600">{iso.not_covered}</div>
<div className="text-xs text-gray-500">Nicht abgedeckt</div>
</div>
</div>
</div>
</div>
</div>
{/* Category Summaries */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Kategorien</h3>
<div className="space-y-3">
{iso.category_summaries.map(cat => {
const coveragePercent = cat.total_controls > 0
? ((cat.covered_full + cat.covered_partial * 0.5) / cat.total_controls) * 100
: 0
return (
<div key={cat.category_id} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-medium text-gray-700">{cat.category_id}: {cat.category_name}</span>
<span className="text-gray-500">
{cat.covered_full}/{cat.total_controls} Controls
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 flex overflow-hidden">
<div className="h-3 bg-green-500" style={{ width: `${(cat.covered_full / cat.total_controls) * 100}%` }} />
<div className="h-3 bg-yellow-500" style={{ width: `${(cat.covered_partial / cat.total_controls) * 100}%` }} />
</div>
</div>
)
})}
</div>
</div>
{/* Gaps */}
{iso.gaps && iso.gaps.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">
Offene Gaps ({iso.gaps.length})
</h3>
<div className="space-y-2 max-h-96 overflow-y-auto">
{iso.gaps.map(gap => (
<div key={gap.control_id} className="flex items-start gap-3 p-3 border border-gray-100 rounded-lg hover:bg-gray-50">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
gap.priority === 'high' ? 'bg-red-100 text-red-700' :
gap.priority === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-700'
}`}>
{gap.priority}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900">{gap.control_id}: {gap.control_name}</div>
<div className="text-xs text-gray-500 mt-0.5">{gap.recommendation}</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
// =============================================================================
// TAB: MATRIX
// =============================================================================
function MatrixTab({ matrix }: { matrix: GCIMatrixResponse | null }) {
if (!matrix || !matrix.matrix) return <LoadingSpinner />
const regulations = matrix.matrix.length > 0 ? Object.keys(matrix.matrix[0].regulations) : []
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Compliance-Matrix (Rollen x Regulierungen)</h3>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 pr-4 font-medium text-gray-600">Rolle</th>
{regulations.map(r => (
<th key={r} className="text-center py-2 px-3 font-medium text-gray-600 uppercase">{r}</th>
))}
<th className="text-center py-2 px-3 font-medium text-gray-600">Gesamt</th>
<th className="text-center py-2 px-3 font-medium text-gray-600">Module</th>
</tr>
</thead>
<tbody>
{matrix.matrix.map(entry => (
<tr key={entry.role} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-2 pr-4 font-medium text-gray-900">{entry.role_name}</td>
{regulations.map(r => (
<td key={r} className="py-2 px-3 text-center">
<span className={`font-semibold ${getScoreColor(entry.regulations[r])}`}>
{entry.regulations[r].toFixed(0)}%
</span>
</td>
))}
<td className="py-2 px-3 text-center">
<span className={`font-bold ${getScoreColor(entry.overall_score)}`}>
{entry.overall_score.toFixed(0)}%
</span>
</td>
<td className="py-2 px-3 text-center text-gray-500">
{entry.completed_modules}/{entry.required_modules}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
// =============================================================================
// TAB: AUDIT TRAIL
// =============================================================================
function AuditTab({ gci }: { gci: GCIResult }) {
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">
Audit Trail - Berechnung GCI {gci.gci_score.toFixed(1)}
</h3>
<p className="text-sm text-gray-500 mb-4">
Jeder Schritt der GCI-Berechnung ist nachvollziehbar und prueffaehig dokumentiert.
</p>
<div className="space-y-2">
{gci.audit_trail.map((entry, i) => (
<div key={i} className="flex items-start gap-3 p-3 border border-gray-100 rounded-lg">
<div className={`flex-shrink-0 w-2 h-2 rounded-full mt-1.5 ${
entry.impact === 'positive' ? 'bg-green-500' :
entry.impact === 'negative' ? 'bg-red-500' :
'bg-gray-400'
}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900">{entry.factor}</span>
<span className={`text-sm font-mono ${
entry.impact === 'positive' ? 'text-green-600' :
entry.impact === 'negative' ? 'text-red-600' :
'text-gray-600'
}`}>
{entry.value > 0 ? '+' : ''}{entry.value.toFixed(2)}
</span>
</div>
<p className="text-xs text-gray-500 mt-0.5">{entry.description}</p>
</div>
</div>
))}
</div>
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
import { TABS, TabNavigation, LoadingSpinner, ErrorMessage } from './_components/GCIHelpers'
import { OverviewTab } from './_components/OverviewTab'
import { BreakdownTab } from './_components/BreakdownTab'
import { NIS2Tab } from './_components/NIS2Tab'
import { ISOTab } from './_components/ISOTab'
import { MatrixTab } from './_components/MatrixTab'
import { AuditTab } from './_components/AuditTab'
import { useGCI } from './_hooks/useGCI'
export default function GCIPage() {
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [gci, setGCI] = useState<GCIResult | null>(null)
const [breakdown, setBreakdown] = useState<GCIBreakdown | null>(null)
const [history, setHistory] = useState<GCIHistoryResponse | null>(null)
const [matrix, setMatrix] = useState<GCIMatrixResponse | null>(null)
const [nis2, setNIS2] = useState<NIS2Score | null>(null)
const [iso, setISO] = useState<ISOGapAnalysis | null>(null)
const [profiles, setProfiles] = useState<WeightProfile[]>([])
const [selectedProfile, setSelectedProfile] = useState('default')
const loadData = useCallback(async (profile?: string) => {
setLoading(true)
setError(null)
try {
const [gciRes, historyRes, profilesRes] = await Promise.all([
getGCIScore(profile),
getGCIHistory(),
getWeightProfiles(),
])
setGCI(gciRes)
setHistory(historyRes)
setProfiles(profilesRes.profiles || [])
} catch (err: any) {
setError(err.message || 'Fehler beim Laden der GCI-Daten')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadData(selectedProfile)
}, [selectedProfile, loadData])
// Lazy-load tab data
useEffect(() => {
if (activeTab === 'breakdown' && !breakdown && gci) {
getGCIBreakdown(selectedProfile).then(setBreakdown).catch(() => {})
}
if (activeTab === 'nis2' && !nis2) {
getNIS2Score().then(setNIS2).catch(() => {})
}
if (activeTab === 'iso' && !iso) {
getISOGapAnalysis().then(setISO).catch(() => {})
}
if (activeTab === 'matrix' && !matrix) {
getGCIMatrix().then(setMatrix).catch(() => {})
}
}, [activeTab, breakdown, nis2, iso, matrix, gci, selectedProfile])
const handleProfileChange = (profile: string) => {
setSelectedProfile(profile)
setBreakdown(null) // reset breakdown to reload
}
const {
activeTab,
setActiveTab,
loading,
error,
gci,
breakdown,
history,
matrix,
nis2,
iso,
profiles,
selectedProfile,
loadData,
handleProfileChange,
} = useGCI()
return (
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Gesamt-Compliance-Index (GCI)</h1>
<p className="text-sm text-gray-500 mt-1">
4-stufiges, mathematisch fundiertes Compliance-Scoring
</p>
<p className="text-sm text-gray-500 mt-1">4-stufiges, mathematisch fundiertes Compliance-Scoring</p>
</div>
<button
onClick={() => loadData(selectedProfile)}
@@ -660,10 +43,8 @@ export default function GCIPage() {
</button>
</div>
{/* Tabs */}
<TabNavigation tabs={TABS} activeTab={activeTab} onTabChange={setActiveTab} />
{/* Content */}
{error && <ErrorMessage message={error} onRetry={() => loadData(selectedProfile)} />}
{loading && !gci ? (

View File

@@ -0,0 +1,112 @@
'use client'
import type { ImportedDocumentType } from '@/lib/sdk/types'
export interface UploadedFile {
id: string
file: File
type: ImportedDocumentType
status: 'pending' | 'uploading' | 'analyzing' | 'complete' | 'error'
progress: number
error?: string
}
export const DOCUMENT_TYPES: { value: ImportedDocumentType; label: string; icon: string }[] = [
{ value: 'DSFA', label: 'Datenschutz-Folgenabschaetzung (DSFA)', icon: '📄' },
{ value: 'TOM', label: 'Technisch-organisatorische Massnahmen (TOMs)', icon: '🔒' },
{ value: 'VVT', label: 'Verarbeitungsverzeichnis (VVT)', icon: '📊' },
{ value: 'AGB', label: 'Allgemeine Geschaeftsbedingungen (AGB)', icon: '📜' },
{ value: 'PRIVACY_POLICY', label: 'Datenschutzerklaerung', icon: '🔐' },
{ value: 'COOKIE_POLICY', label: 'Cookie-Richtlinie', icon: '🍪' },
{ value: 'RISK_ASSESSMENT', label: 'Risikobewertung', icon: '⚠️' },
{ value: 'AUDIT_REPORT', label: 'Audit-Bericht', icon: '✅' },
{ value: 'OTHER', label: 'Sonstiges Dokument', icon: '📎' },
]
export function FileItem({
file,
onTypeChange,
onRemove,
}: {
file: UploadedFile
onTypeChange: (id: string, type: ImportedDocumentType) => void
onRemove: (id: string) => void
}) {
return (
<div className="flex items-center gap-4 p-4 bg-white rounded-xl border border-gray-200">
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-gray-500" 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>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">{file.file.name}</p>
<p className="text-sm text-gray-500">{(file.file.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
<select
value={file.type}
onChange={e => onTypeChange(file.id, e.target.value as ImportedDocumentType)}
disabled={file.status !== 'pending'}
className="px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500 disabled:opacity-50"
>
{DOCUMENT_TYPES.map(dt => (
<option key={dt.value} value={dt.value}>
{dt.icon} {dt.label}
</option>
))}
</select>
{file.status === 'pending' && (
<button onClick={() => onRemove(file.id)} className="p-2 text-gray-400 hover:text-red-500 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{file.status === 'uploading' && (
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-gray-200 rounded-full overflow-hidden">
<div className="h-full bg-purple-600 rounded-full transition-all" style={{ width: `${file.progress}%` }} />
</div>
<span className="text-sm text-gray-500">{file.progress}%</span>
</div>
)}
{file.status === 'analyzing' && (
<div className="flex items-center gap-2 text-purple-600">
<svg className="w-5 h-5 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>
<span className="text-sm">Analysiere...</span>
</div>
)}
{file.status === 'complete' && file.error === 'offline' && (
<div className="flex items-center gap-1 text-amber-600">
<svg className="w-5 h-5" 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>
<span className="text-sm">Offline nicht analysiert</span>
</div>
)}
{file.status === 'complete' && file.error !== 'offline' && (
<div className="flex items-center gap-1 text-green-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm">Fertig</span>
</div>
)}
{file.status === 'error' && (
<div className="flex items-center gap-1 text-red-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="text-sm">{file.error || 'Fehler'}</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import type { GapAnalysis, GapItem } from '@/lib/sdk/types'
export function GapAnalysisPreview({ analysis }: { analysis: GapAnalysis }) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center">
<span className="text-2xl">📊</span>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">Gap-Analyse Ergebnis</h3>
<p className="text-sm text-gray-500">
{analysis.totalGaps} Luecken in {analysis.gaps.length} Kategorien gefunden
</p>
</div>
</div>
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="text-center p-4 bg-red-50 rounded-xl">
<div className="text-3xl font-bold text-red-600">{analysis.criticalGaps}</div>
<div className="text-sm text-red-600 font-medium">Kritisch</div>
</div>
<div className="text-center p-4 bg-orange-50 rounded-xl">
<div className="text-3xl font-bold text-orange-600">{analysis.highGaps}</div>
<div className="text-sm text-orange-600 font-medium">Hoch</div>
</div>
<div className="text-center p-4 bg-yellow-50 rounded-xl">
<div className="text-3xl font-bold text-yellow-600">{analysis.mediumGaps}</div>
<div className="text-sm text-yellow-600 font-medium">Mittel</div>
</div>
<div className="text-center p-4 bg-green-50 rounded-xl">
<div className="text-3xl font-bold text-green-600">{analysis.lowGaps}</div>
<div className="text-sm text-green-600 font-medium">Niedrig</div>
</div>
</div>
<div className="space-y-3">
{analysis.gaps.slice(0, 5).map((gap: GapItem) => (
<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' :
gap.severity === 'MEDIUM' ? 'bg-yellow-50 border-yellow-500' :
'bg-green-50 border-green-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' :
gap.severity === 'MEDIUM' ? 'bg-yellow-100 text-yellow-700' :
'bg-green-100 text-green-700'
}`}>
{gap.severity}
</span>
</div>
<div className="mt-2 text-xs text-gray-500">
Regulierung: {gap.regulation} | Aktion: {gap.requiredAction}
</div>
</div>
))}
{analysis.gaps.length > 5 && (
<p className="text-sm text-gray-500 text-center py-2">
+ {analysis.gaps.length - 5} weitere Luecken
</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
export function ImportHistory({
importHistory,
historyLoading,
onDelete,
}: {
importHistory: any[]
historyLoading: boolean
onDelete: (id: string) => void
}) {
if (historyLoading) {
return <div className="text-center py-4 text-sm text-gray-500">Import-Verlauf wird geladen...</div>
}
if (importHistory.length === 0) return null
return (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="font-semibold text-gray-900">Import-Verlauf</h3>
<p className="text-sm text-gray-500">{importHistory.length} fruehere Imports</p>
</div>
<div className="divide-y divide-gray-100">
{importHistory.map((item: any, idx: number) => (
<div key={item.id || idx} className="px-6 py-4 flex items-center justify-between hover:bg-gray-50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-gray-500" 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>
</div>
<div>
<p className="text-sm font-medium text-gray-900">{item.name || item.filename || `Import #${idx + 1}`}</p>
<p className="text-xs text-gray-500">
{item.document_type || item.type || 'Unbekannt'} {item.uploaded_at ? new Date(item.uploaded_at).toLocaleString('de-DE') : 'Unbekannt'}
</p>
</div>
</div>
<button
onClick={() => onDelete(item.id)}
className="p-2 text-gray-400 hover:text-red-500 transition-colors"
title="Import loeschen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,100 @@
'use client'
import { useState, useCallback } from 'react'
export function UploadZone({
onFilesAdded,
isDisabled,
}: {
onFilesAdded: (files: File[]) => void
isDisabled: boolean
}) {
const [isDragging, setIsDragging] = useState(false)
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
if (!isDisabled) setIsDragging(true)
}, [isDisabled])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (isDisabled) return
const files = Array.from(e.dataTransfer.files).filter(
f => f.type === 'application/pdf' || f.type.startsWith('image/')
)
if (files.length > 0) onFilesAdded(files)
},
[onFilesAdded, isDisabled]
)
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && !isDisabled) {
onFilesAdded(Array.from(e.target.files))
}
},
[onFilesAdded, isDisabled]
)
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`relative border-2 border-dashed rounded-xl p-12 text-center transition-all ${
isDisabled
? 'border-gray-200 bg-gray-50 cursor-not-allowed'
: isDragging
? 'border-purple-500 bg-purple-50'
: 'border-gray-300 hover:border-purple-400 hover:bg-purple-50/50 cursor-pointer'
}`}
>
<input
type="file"
accept=".pdf,image/*"
multiple
onChange={handleFileSelect}
disabled={isDisabled}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/>
<div className="flex flex-col items-center gap-4">
<div className={`w-16 h-16 rounded-full flex items-center justify-center ${isDragging ? 'bg-purple-100' : 'bg-gray-100'}`}>
<svg
className={`w-8 h-8 ${isDragging ? 'text-purple-600' : 'text-gray-400'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
<div>
<p className="text-lg font-medium text-gray-900">
{isDragging ? 'Dateien hier ablegen' : 'Dokumente hochladen'}
</p>
<p className="mt-1 text-sm text-gray-500">
Ziehen Sie PDF-Dateien hierher oder klicken Sie zum Auswaehlen
</p>
</div>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span>Unterstuetzte Formate:</span>
<span className="px-2 py-0.5 bg-gray-100 rounded">PDF</span>
<span className="px-2 py-0.5 bg-gray-100 rounded">JPG</span>
<span className="px-2 py-0.5 bg-gray-100 rounded">PNG</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,184 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
import type { ImportedDocument, ImportedDocumentType, GapAnalysis, GapItem } from '@/lib/sdk/types'
import type { UploadedFile } from '../_components/FileItem'
export function useImport() {
const { state, addImportedDocument, setGapAnalysis, dispatch } = useSDK()
const [files, setFiles] = useState<UploadedFile[]>([])
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [analysisResult, setAnalysisResult] = useState<GapAnalysis | null>(null)
const [importHistory, setImportHistory] = useState<any[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [objectUrls, setObjectUrls] = useState<string[]>([])
useEffect(() => {
const loadHistory = async () => {
setHistoryLoading(true)
try {
const response = await fetch('/api/sdk/v1/import?tenant_id=default')
if (response.ok) {
const data = await response.json()
setImportHistory(Array.isArray(data) ? data : data.items || [])
}
} catch (err) {
console.error('Failed to load import history:', err)
} finally {
setHistoryLoading(false)
}
}
loadHistory()
}, [analysisResult])
useEffect(() => {
return () => {
objectUrls.forEach(url => URL.revokeObjectURL(url))
}
}, [objectUrls])
const createTrackedObjectURL = useCallback((file: File) => {
const url = URL.createObjectURL(file)
setObjectUrls(prev => [...prev, url])
return url
}, [])
const handleFilesAdded = useCallback((newFiles: File[]) => {
const uploadedFiles: UploadedFile[] = newFiles.map(file => ({
id: `file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
file,
type: 'OTHER' as ImportedDocumentType,
status: 'pending' as const,
progress: 0,
}))
setFiles(prev => [...prev, ...uploadedFiles])
}, [])
const handleTypeChange = useCallback((id: string, type: ImportedDocumentType) => {
setFiles(prev => prev.map(f => (f.id === id ? { ...f, type } : f)))
}, [])
const handleRemove = useCallback((id: string) => {
setFiles(prev => prev.filter(f => f.id !== id))
}, [])
const handleDeleteHistory = async (id: string) => {
try {
const res = await fetch(`/api/sdk/v1/import/${id}`, { method: 'DELETE' })
if (res.ok) {
setImportHistory(prev => prev.filter(h => h.id !== id))
}
} catch (err) {
console.error('Failed to delete import:', err)
}
}
const handleAnalyze = async () => {
if (files.length === 0) return
setIsAnalyzing(true)
const allGaps: GapItem[] = []
for (let i = 0; i < files.length; i++) {
const file = files[i]
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, status: 'uploading' as const } : f)))
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 30 } : f)))
const formData = new FormData()
formData.append('file', file.file)
formData.append('document_type', file.type)
formData.append('tenant_id', 'default')
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 60, status: 'analyzing' as const } : f)))
try {
const response = await fetch('/api/sdk/v1/import/analyze', { method: 'POST', body: formData })
if (response.ok) {
const result = await response.json()
const doc: ImportedDocument = {
id: result.document_id || file.id,
name: file.file.name,
type: result.detected_type || file.type,
fileUrl: createTrackedObjectURL(file.file),
uploadedAt: new Date(),
analyzedAt: new Date(),
analysisResult: {
detectedType: result.detected_type || file.type,
confidence: result.confidence || 0.85,
extractedEntities: result.extracted_entities || [],
gaps: result.gap_analysis?.gaps || [],
recommendations: result.recommendations || [],
},
}
addImportedDocument(doc)
if (result.gap_analysis?.gaps) {
for (const gap of result.gap_analysis.gaps) {
allGaps.push({
id: gap.id,
category: gap.category,
description: gap.description,
severity: gap.severity,
regulation: gap.regulation,
requiredAction: gap.required_action,
relatedStepId: gap.related_step_id || '',
})
}
}
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 100, status: 'complete' as const } : f)))
} else {
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, status: 'error' as const, error: 'Analyse fehlgeschlagen' } : f)))
}
} catch {
const doc: ImportedDocument = {
id: file.id,
name: file.file.name,
type: file.type,
fileUrl: createTrackedObjectURL(file.file),
uploadedAt: new Date(),
analyzedAt: new Date(),
analysisResult: {
detectedType: file.type,
confidence: 0.5,
extractedEntities: [],
gaps: [],
recommendations: ['Offline-Modus — Backend nicht erreichbar, manuelle Pruefung empfohlen'],
},
}
addImportedDocument(doc)
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 100, status: 'complete' as const, error: 'offline' } : f)))
}
}
const gapAnalysis: GapAnalysis = {
id: `analysis-${Date.now()}`,
createdAt: new Date(),
totalGaps: allGaps.length,
criticalGaps: allGaps.filter(g => g.severity === 'CRITICAL').length,
highGaps: allGaps.filter(g => g.severity === 'HIGH').length,
mediumGaps: allGaps.filter(g => g.severity === 'MEDIUM').length,
lowGaps: allGaps.filter(g => g.severity === 'LOW').length,
gaps: allGaps,
recommendedPackages: allGaps.length > 0 ? ['analyse', 'dokumentation'] : [],
}
setAnalysisResult(gapAnalysis)
setGapAnalysis(gapAnalysis)
setIsAnalyzing(false)
dispatch({ type: 'COMPLETE_STEP', payload: 'import' })
}
return {
state,
files,
setFiles,
isAnalyzing,
analysisResult,
importHistory,
historyLoading,
handleFilesAdded,
handleTypeChange,
handleRemove,
handleAnalyze,
handleDeleteHistory,
}
}

View File

@@ -1,521 +1,29 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useSDK } from '@/lib/sdk'
import type { ImportedDocument, ImportedDocumentType, GapAnalysis, GapItem } from '@/lib/sdk/types'
// =============================================================================
// DOCUMENT TYPE OPTIONS
// =============================================================================
const DOCUMENT_TYPES: { value: ImportedDocumentType; label: string; icon: string }[] = [
{ value: 'DSFA', label: 'Datenschutz-Folgenabschaetzung (DSFA)', icon: '📄' },
{ value: 'TOM', label: 'Technisch-organisatorische Massnahmen (TOMs)', icon: '🔒' },
{ value: 'VVT', label: 'Verarbeitungsverzeichnis (VVT)', icon: '📊' },
{ value: 'AGB', label: 'Allgemeine Geschaeftsbedingungen (AGB)', icon: '📜' },
{ value: 'PRIVACY_POLICY', label: 'Datenschutzerklaerung', icon: '🔐' },
{ value: 'COOKIE_POLICY', label: 'Cookie-Richtlinie', icon: '🍪' },
{ value: 'RISK_ASSESSMENT', label: 'Risikobewertung', icon: '⚠️' },
{ value: 'AUDIT_REPORT', label: 'Audit-Bericht', icon: '✅' },
{ value: 'OTHER', label: 'Sonstiges Dokument', icon: '📎' },
]
// =============================================================================
// UPLOAD ZONE
// =============================================================================
interface UploadedFile {
id: string
file: File
type: ImportedDocumentType
status: 'pending' | 'uploading' | 'analyzing' | 'complete' | 'error'
progress: number
error?: string
}
function UploadZone({
onFilesAdded,
isDisabled,
}: {
onFilesAdded: (files: File[]) => void
isDisabled: boolean
}) {
const [isDragging, setIsDragging] = useState(false)
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
if (!isDisabled) setIsDragging(true)
}, [isDisabled])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (isDisabled) return
const files = Array.from(e.dataTransfer.files).filter(
f => f.type === 'application/pdf' || f.type.startsWith('image/')
)
if (files.length > 0) {
onFilesAdded(files)
}
},
[onFilesAdded, isDisabled]
)
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && !isDisabled) {
const files = Array.from(e.target.files)
onFilesAdded(files)
}
},
[onFilesAdded, isDisabled]
)
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`relative border-2 border-dashed rounded-xl p-12 text-center transition-all ${
isDisabled
? 'border-gray-200 bg-gray-50 cursor-not-allowed'
: isDragging
? 'border-purple-500 bg-purple-50'
: 'border-gray-300 hover:border-purple-400 hover:bg-purple-50/50 cursor-pointer'
}`}
>
<input
type="file"
accept=".pdf,image/*"
multiple
onChange={handleFileSelect}
disabled={isDisabled}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/>
<div className="flex flex-col items-center gap-4">
<div className={`w-16 h-16 rounded-full flex items-center justify-center ${isDragging ? 'bg-purple-100' : 'bg-gray-100'}`}>
<svg
className={`w-8 h-8 ${isDragging ? 'text-purple-600' : 'text-gray-400'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
<div>
<p className="text-lg font-medium text-gray-900">
{isDragging ? 'Dateien hier ablegen' : 'Dokumente hochladen'}
</p>
<p className="mt-1 text-sm text-gray-500">
Ziehen Sie PDF-Dateien hierher oder klicken Sie zum Auswaehlen
</p>
</div>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span>Unterstuetzte Formate:</span>
<span className="px-2 py-0.5 bg-gray-100 rounded">PDF</span>
<span className="px-2 py-0.5 bg-gray-100 rounded">JPG</span>
<span className="px-2 py-0.5 bg-gray-100 rounded">PNG</span>
</div>
</div>
</div>
)
}
// =============================================================================
// FILE LIST
// =============================================================================
function FileItem({
file,
onTypeChange,
onRemove,
}: {
file: UploadedFile
onTypeChange: (id: string, type: ImportedDocumentType) => void
onRemove: (id: string) => void
}) {
return (
<div className="flex items-center gap-4 p-4 bg-white rounded-xl border border-gray-200">
{/* File Icon */}
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-gray-500" 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>
</div>
{/* File Info */}
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">{file.file.name}</p>
<p className="text-sm text-gray-500">{(file.file.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
{/* Type Selector */}
<select
value={file.type}
onChange={e => onTypeChange(file.id, e.target.value as ImportedDocumentType)}
disabled={file.status !== 'pending'}
className="px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500 disabled:opacity-50"
>
{DOCUMENT_TYPES.map(dt => (
<option key={dt.value} value={dt.value}>
{dt.icon} {dt.label}
</option>
))}
</select>
{/* Status / Actions */}
{file.status === 'pending' && (
<button
onClick={() => onRemove(file.id)}
className="p-2 text-gray-400 hover:text-red-500 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{file.status === 'uploading' && (
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 rounded-full transition-all"
style={{ width: `${file.progress}%` }}
/>
</div>
<span className="text-sm text-gray-500">{file.progress}%</span>
</div>
)}
{file.status === 'analyzing' && (
<div className="flex items-center gap-2 text-purple-600">
<svg className="w-5 h-5 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>
<span className="text-sm">Analysiere...</span>
</div>
)}
{file.status === 'complete' && file.error === 'offline' && (
<div className="flex items-center gap-1 text-amber-600">
<svg className="w-5 h-5" 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>
<span className="text-sm">Offline nicht analysiert</span>
</div>
)}
{file.status === 'complete' && file.error !== 'offline' && (
<div className="flex items-center gap-1 text-green-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm">Fertig</span>
</div>
)}
{file.status === 'error' && (
<div className="flex items-center gap-1 text-red-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="text-sm">{file.error || 'Fehler'}</span>
</div>
)}
</div>
)
}
// =============================================================================
// GAP ANALYSIS PREVIEW
// =============================================================================
function GapAnalysisPreview({ analysis }: { analysis: GapAnalysis }) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center">
<span className="text-2xl">📊</span>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">Gap-Analyse Ergebnis</h3>
<p className="text-sm text-gray-500">
{analysis.totalGaps} Luecken in {analysis.gaps.length} Kategorien gefunden
</p>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="text-center p-4 bg-red-50 rounded-xl">
<div className="text-3xl font-bold text-red-600">{analysis.criticalGaps}</div>
<div className="text-sm text-red-600 font-medium">Kritisch</div>
</div>
<div className="text-center p-4 bg-orange-50 rounded-xl">
<div className="text-3xl font-bold text-orange-600">{analysis.highGaps}</div>
<div className="text-sm text-orange-600 font-medium">Hoch</div>
</div>
<div className="text-center p-4 bg-yellow-50 rounded-xl">
<div className="text-3xl font-bold text-yellow-600">{analysis.mediumGaps}</div>
<div className="text-sm text-yellow-600 font-medium">Mittel</div>
</div>
<div className="text-center p-4 bg-green-50 rounded-xl">
<div className="text-3xl font-bold text-green-600">{analysis.lowGaps}</div>
<div className="text-sm text-green-600 font-medium">Niedrig</div>
</div>
</div>
{/* Gap List */}
<div className="space-y-3">
{analysis.gaps.slice(0, 5).map((gap: GapItem) => (
<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'
: gap.severity === 'MEDIUM'
? 'bg-yellow-50 border-yellow-500'
: 'bg-green-50 border-green-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'
: gap.severity === 'MEDIUM'
? 'bg-yellow-100 text-yellow-700'
: 'bg-green-100 text-green-700'
}`}
>
{gap.severity}
</span>
</div>
<div className="mt-2 text-xs text-gray-500">
Regulierung: {gap.regulation} | Aktion: {gap.requiredAction}
</div>
</div>
))}
{analysis.gaps.length > 5 && (
<p className="text-sm text-gray-500 text-center py-2">
+ {analysis.gaps.length - 5} weitere Luecken
</p>
)}
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
import { UploadZone } from './_components/UploadZone'
import { FileItem } from './_components/FileItem'
import { GapAnalysisPreview } from './_components/GapAnalysisPreview'
import { ImportHistory } from './_components/ImportHistory'
import { useImport } from './_hooks/useImport'
export default function ImportPage() {
const router = useRouter()
const { state, addImportedDocument, setGapAnalysis, dispatch } = useSDK()
const [files, setFiles] = useState<UploadedFile[]>([])
const [isAnalyzing, setIsAnalyzing] = useState(false)
const [analysisResult, setAnalysisResult] = useState<GapAnalysis | null>(null)
const [importHistory, setImportHistory] = useState<any[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [objectUrls, setObjectUrls] = useState<string[]>([])
const {
state,
files,
setFiles,
isAnalyzing,
analysisResult,
importHistory,
historyLoading,
handleFilesAdded,
handleTypeChange,
handleRemove,
handleAnalyze,
handleDeleteHistory,
} = useImport()
// 4.1: Load import history
useEffect(() => {
const loadHistory = async () => {
setHistoryLoading(true)
try {
const response = await fetch('/api/sdk/v1/import?tenant_id=default')
if (response.ok) {
const data = await response.json()
setImportHistory(Array.isArray(data) ? data : data.items || [])
}
} catch (err) {
console.error('Failed to load import history:', err)
} finally {
setHistoryLoading(false)
}
}
loadHistory()
}, [analysisResult])
// 4.4: Cleanup ObjectURLs on unmount
useEffect(() => {
return () => {
objectUrls.forEach(url => URL.revokeObjectURL(url))
}
}, [objectUrls])
// Helper to create and track ObjectURLs
const createTrackedObjectURL = useCallback((file: File) => {
const url = URL.createObjectURL(file)
setObjectUrls(prev => [...prev, url])
return url
}, [])
const handleFilesAdded = useCallback((newFiles: File[]) => {
const uploadedFiles: UploadedFile[] = newFiles.map(file => ({
id: `file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
file,
type: 'OTHER' as ImportedDocumentType,
status: 'pending' as const,
progress: 0,
}))
setFiles(prev => [...prev, ...uploadedFiles])
}, [])
const handleTypeChange = useCallback((id: string, type: ImportedDocumentType) => {
setFiles(prev => prev.map(f => (f.id === id ? { ...f, type } : f)))
}, [])
const handleRemove = useCallback((id: string) => {
setFiles(prev => prev.filter(f => f.id !== id))
}, [])
const handleAnalyze = async () => {
if (files.length === 0) return
setIsAnalyzing(true)
const allGaps: GapItem[] = []
for (let i = 0; i < files.length; i++) {
const file = files[i]
// Update to uploading
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, status: 'uploading' as const } : f)))
// Upload progress
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 30 } : f)))
// Prepare form data for backend
const formData = new FormData()
formData.append('file', file.file)
formData.append('document_type', file.type)
formData.append('tenant_id', 'default')
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 60, status: 'analyzing' as const } : f)))
try {
const response = await fetch('/api/sdk/v1/import/analyze', {
method: 'POST',
body: formData,
})
if (response.ok) {
const result = await response.json()
// Create imported document from backend response
const doc: ImportedDocument = {
id: result.document_id || file.id,
name: file.file.name,
type: result.detected_type || file.type,
fileUrl: createTrackedObjectURL(file.file),
uploadedAt: new Date(),
analyzedAt: new Date(),
analysisResult: {
detectedType: result.detected_type || file.type,
confidence: result.confidence || 0.85,
extractedEntities: result.extracted_entities || [],
gaps: result.gap_analysis?.gaps || [],
recommendations: result.recommendations || [],
},
}
addImportedDocument(doc)
// Collect gaps
if (result.gap_analysis?.gaps) {
for (const gap of result.gap_analysis.gaps) {
allGaps.push({
id: gap.id,
category: gap.category,
description: gap.description,
severity: gap.severity,
regulation: gap.regulation,
requiredAction: gap.required_action,
relatedStepId: gap.related_step_id || '',
})
}
}
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 100, status: 'complete' as const } : f)))
} else {
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, status: 'error' as const, error: 'Analyse fehlgeschlagen' } : f)))
}
} catch {
// Offline-Modus: create basic document without backend analysis
const doc: ImportedDocument = {
id: file.id,
name: file.file.name,
type: file.type,
fileUrl: createTrackedObjectURL(file.file),
uploadedAt: new Date(),
analyzedAt: new Date(),
analysisResult: {
detectedType: file.type,
confidence: 0.5,
extractedEntities: [],
gaps: [],
recommendations: ['Offline-Modus — Backend nicht erreichbar, manuelle Pruefung empfohlen'],
},
}
addImportedDocument(doc)
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 100, status: 'complete' as const, error: 'offline' } : f)))
}
}
// Build gap analysis summary
const gapAnalysis: GapAnalysis = {
id: `analysis-${Date.now()}`,
createdAt: new Date(),
totalGaps: allGaps.length,
criticalGaps: allGaps.filter(g => g.severity === 'CRITICAL').length,
highGaps: allGaps.filter(g => g.severity === 'HIGH').length,
mediumGaps: allGaps.filter(g => g.severity === 'MEDIUM').length,
lowGaps: allGaps.filter(g => g.severity === 'LOW').length,
gaps: allGaps,
recommendedPackages: allGaps.length > 0 ? ['analyse', 'dokumentation'] : [],
}
setAnalysisResult(gapAnalysis)
setGapAnalysis(gapAnalysis)
setIsAnalyzing(false)
// Mark step as complete
dispatch({ type: 'COMPLETE_STEP', payload: 'import' })
}
const handleContinue = () => {
router.push('/sdk/screening')
}
// Redirect if not existing customer
if (state.customerType === 'new') {
router.push('/sdk')
return null
@@ -540,22 +48,14 @@ export default function ImportPage() {
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-900">{files.length} Dokument(e)</h2>
{!isAnalyzing && !analysisResult && (
<button
onClick={() => setFiles([])}
className="text-sm text-gray-500 hover:text-red-500"
>
<button onClick={() => setFiles([])} className="text-sm text-gray-500 hover:text-red-500">
Alle entfernen
</button>
)}
</div>
<div className="space-y-3">
{files.map(file => (
<FileItem
key={file.id}
file={file}
onTypeChange={handleTypeChange}
onRemove={handleRemove}
/>
<FileItem key={file.id} file={file} onTypeChange={handleTypeChange} onRemove={handleRemove} />
))}
</div>
</div>
@@ -599,7 +99,7 @@ export default function ImportPage() {
Die Gap-Analyse wurde gespeichert. Sie koennen jetzt mit dem Compliance-Assessment fortfahren.
</p>
<button
onClick={handleContinue}
onClick={() => router.push('/sdk/screening')}
className="px-6 py-2.5 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2"
>
Weiter zum Screening
@@ -610,55 +110,12 @@ export default function ImportPage() {
</div>
)}
{/* Import-Verlauf (4.1) */}
{importHistory.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="font-semibold text-gray-900">Import-Verlauf</h3>
<p className="text-sm text-gray-500">{importHistory.length} fruehere Imports</p>
</div>
<div className="divide-y divide-gray-100">
{importHistory.map((item: any, idx: number) => (
<div key={item.id || idx} className="px-6 py-4 flex items-center justify-between hover:bg-gray-50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-gray-500" 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>
</div>
<div>
<p className="text-sm font-medium text-gray-900">{item.name || item.filename || `Import #${idx + 1}`}</p>
<p className="text-xs text-gray-500">
{item.document_type || item.type || 'Unbekannt'} {item.uploaded_at ? new Date(item.uploaded_at).toLocaleString('de-DE') : 'Unbekannt'}
</p>
</div>
</div>
<button
onClick={async () => {
try {
const res = await fetch(`/api/sdk/v1/import/${item.id}`, { method: 'DELETE' })
if (res.ok) {
setImportHistory(prev => prev.filter(h => h.id !== item.id))
}
} catch (err) {
console.error('Failed to delete import:', err)
}
}}
className="p-2 text-gray-400 hover:text-red-500 transition-colors"
title="Import loeschen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
</div>
)}
{historyLoading && (
<div className="text-center py-4 text-sm text-gray-500">Import-Verlauf wird geladen...</div>
)}
{/* Import History */}
<ImportHistory
importHistory={importHistory}
historyLoading={historyLoading}
onDelete={handleDeleteHistory}
/>
</div>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import { useState } from 'react'
import { api } from './PortfolioTypes'
export function CreatePortfolioModal({ onClose, onCreated }: {
onClose: () => void
onCreated: () => void
}) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [department, setDepartment] = useState('')
const [owner, setOwner] = useState('')
const [saving, setSaving] = useState(false)
const handleCreate = async () => {
if (!name.trim()) return
setSaving(true)
try {
await api('', {
method: 'POST',
body: JSON.stringify({
name: name.trim(),
description: description.trim(),
department: department.trim(),
owner: owner.trim(),
}),
})
onCreated()
} catch (err) {
console.error('Create portfolio error:', err)
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-2xl p-6 w-full max-w-lg" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold text-gray-900 mb-4">Neues Portfolio</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input type="text" value={name} onChange={e => setName(e.target.value)}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="z.B. KI-Portfolio Q1 2026" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea value={description} onChange={e => setDescription(e.target.value)}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
rows={3} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Abteilung</label>
<input type="text" value={department} onChange={e => setDepartment(e.target.value)}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlicher</label>
<input type="text" value={owner} onChange={e => setOwner(e.target.value)}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" />
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
<button onClick={handleCreate} disabled={!name.trim() || saving}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
{saving ? 'Erstelle...' : 'Erstellen'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,67 @@
'use client'
import { Portfolio, statusColors, statusLabels } from './PortfolioTypes'
export function PortfolioCard({ portfolio, onSelect, onDelete }: {
portfolio: Portfolio
onSelect: (p: Portfolio) => void
onDelete: (id: string) => void
}) {
const totalItems = portfolio.total_assessments + portfolio.total_roadmaps + portfolio.total_workshops
return (
<div
className="bg-white rounded-xl border-2 border-gray-200 p-6 hover:border-purple-300 transition-colors cursor-pointer"
onClick={() => onSelect(portfolio)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-gray-900 truncate">{portfolio.name}</h4>
{portfolio.department && <span className="text-xs text-gray-500">{portfolio.department}</span>}
</div>
<span className={`px-2 py-1 text-xs rounded-full ml-2 ${statusColors[portfolio.status] || 'bg-gray-100 text-gray-700'}`}>
{statusLabels[portfolio.status] || portfolio.status}
</span>
</div>
{portfolio.description && (
<p className="text-sm text-gray-600 mb-3 line-clamp-2">{portfolio.description}</p>
)}
<div className="grid grid-cols-3 gap-2 mb-4">
<div className="bg-gray-50 rounded-lg p-2 text-center">
<div className="text-lg font-bold text-purple-600">{portfolio.compliance_score}%</div>
<div className="text-xs text-gray-500">Compliance</div>
</div>
<div className="bg-gray-50 rounded-lg p-2 text-center">
<div className="text-lg font-bold text-gray-900">{portfolio.avg_risk_score.toFixed(1)}</div>
<div className="text-xs text-gray-500">Risiko</div>
</div>
<div className="bg-gray-50 rounded-lg p-2 text-center">
<div className="text-lg font-bold text-gray-900">{totalItems}</div>
<div className="text-xs text-gray-500">Items</div>
</div>
</div>
{portfolio.high_risk_count > 0 && (
<div className="flex items-center gap-1 text-xs text-red-600 mb-3">
<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 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
{portfolio.high_risk_count} Hoch-Risiko
</div>
)}
<div className="flex justify-between items-center">
<span className="text-xs text-gray-400">{portfolio.owner || 'Kein Owner'}</span>
<button
onClick={(e) => { e.stopPropagation(); onDelete(portfolio.id) }}
className="text-xs text-red-500 hover:text-red-700 hover:bg-red-50 px-2 py-1 rounded"
>
Loeschen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,263 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import {
Portfolio, PortfolioItem, PortfolioStats, ActivityEntry, CompareResult,
api, statusColors, statusLabels,
} from './PortfolioTypes'
const typeLabels: Record<string, string> = {
ASSESSMENT: 'Assessment', ROADMAP: 'Roadmap', WORKSHOP: 'Workshop', DOCUMENT: 'Dokument',
}
const typeColors: Record<string, string> = {
ASSESSMENT: 'bg-blue-100 text-blue-700', ROADMAP: 'bg-green-100 text-green-700',
WORKSHOP: 'bg-purple-100 text-purple-700', DOCUMENT: 'bg-orange-100 text-orange-700',
}
export function PortfolioDetailView({ portfolio, onBack, onRefresh }: {
portfolio: Portfolio
onBack: () => void
onRefresh: () => void
}) {
const [items, setItems] = useState<PortfolioItem[]>([])
const [activity, setActivity] = useState<ActivityEntry[]>([])
const [stats, setStats] = useState<PortfolioStats | null>(null)
const [activeTab, setActiveTab] = useState<'items' | 'activity' | 'compare'>('items')
const [loading, setLoading] = useState(true)
const [compareIds, setCompareIds] = useState('')
const [compareResult, setCompareResult] = useState<CompareResult | null>(null)
const loadDetails = useCallback(async () => {
setLoading(true)
try {
const [i, a, s] = await Promise.all([
api<PortfolioItem[]>(`/${portfolio.id}/items`).catch(() => []),
api<ActivityEntry[]>(`/${portfolio.id}/activity`).catch(() => []),
api<PortfolioStats>(`/${portfolio.id}/stats`).catch(() => null),
])
setItems(Array.isArray(i) ? i : [])
setActivity(Array.isArray(a) ? a : [])
setStats(s)
} finally {
setLoading(false)
}
}, [portfolio.id])
useEffect(() => { loadDetails() }, [loadDetails])
const handleSubmitReview = async () => {
try { await api(`/${portfolio.id}/submit-review`, { method: 'POST' }); onRefresh() }
catch (err) { console.error('Submit review error:', err) }
}
const handleApprove = async () => {
try { await api(`/${portfolio.id}/approve`, { method: 'POST' }); onRefresh() }
catch (err) { console.error('Approve error:', err) }
}
const handleRecalculate = async () => {
try { await api(`/${portfolio.id}/recalculate`, { method: 'POST' }); loadDetails(); onRefresh() }
catch (err) { console.error('Recalculate error:', err) }
}
const handleCompare = async () => {
const ids = compareIds.split(',').map(s => s.trim()).filter(Boolean)
if (ids.length < 1) return
try {
const result = await api<CompareResult>('/compare', {
method: 'POST',
body: JSON.stringify({ portfolio_ids: [portfolio.id, ...ids] }),
})
setCompareResult(result)
} catch (err) { console.error('Compare error:', err) }
}
const handleRemoveItem = async (itemId: string) => {
try {
await api(`/${portfolio.id}/items/${itemId}`, { method: 'DELETE' })
setItems(prev => prev.filter(i => i.id !== itemId))
} catch (err) { console.error('Remove item error:', err) }
}
return (
<div>
<button onClick={onBack} className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 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="bg-white rounded-xl border-2 border-gray-200 p-6 mb-6">
<div className="flex items-start justify-between mb-4">
<div>
<h2 className="text-xl font-bold text-gray-900">{portfolio.name}</h2>
<p className="text-sm text-gray-500 mt-1">{portfolio.description}</p>
</div>
<span className={`px-3 py-1 text-sm rounded-full ${statusColors[portfolio.status]}`}>
{statusLabels[portfolio.status]}
</span>
</div>
{stats && (
<div className="grid grid-cols-4 gap-4 mb-4">
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-purple-600">{stats.compliance_score}%</div>
<div className="text-xs text-gray-500">Compliance</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-gray-900">{stats.avg_risk_score.toFixed(1)}</div>
<div className="text-xs text-gray-500">Risiko-Score</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-gray-900">{stats.total_items}</div>
<div className="text-xs text-gray-500">Items</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-red-600">{portfolio.high_risk_count}</div>
<div className="text-xs text-gray-500">Hoch-Risiko</div>
</div>
</div>
)}
<div className="flex gap-2">
{portfolio.status === 'ACTIVE' && (
<button onClick={handleSubmitReview} className="px-3 py-1.5 text-sm bg-yellow-600 text-white rounded-lg hover:bg-yellow-700">
Zur Pruefung einreichen
</button>
)}
{portfolio.status === 'REVIEW' && (
<button onClick={handleApprove} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">
Genehmigen
</button>
)}
<button onClick={handleRecalculate} className="px-3 py-1.5 text-sm border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">
Metriken neu berechnen
</button>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-4 bg-gray-100 p-1 rounded-lg">
{(['items', 'activity', 'compare'] as const).map(tab => (
<button key={tab} onClick={() => setActiveTab(tab)}
className={`flex-1 px-4 py-2 text-sm rounded-md transition-colors ${activeTab === tab ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'}`}>
{tab === 'items' ? `Items (${items.length})` : tab === 'activity' ? 'Aktivitaet' : 'Vergleich'}
</button>
))}
</div>
{loading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : (
<>
{activeTab === 'items' && (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
{['Titel', 'Typ', 'Status', 'Risiko', 'Aktion'].map(h => (
<th key={h} className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{items.map(item => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div className="font-medium text-gray-900">{item.title}</div>
{item.notes && <div className="text-xs text-gray-500 truncate max-w-xs">{item.notes}</div>}
</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 text-xs rounded-full ${typeColors[item.item_type] || 'bg-gray-100 text-gray-700'}`}>
{typeLabels[item.item_type] || item.item_type}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{item.status}</td>
<td className="px-4 py-3">
<span className={`text-sm font-medium ${
item.risk_score >= 7 ? 'text-red-600' : item.risk_score >= 4 ? 'text-yellow-600' : 'text-green-600'
}`}>{item.risk_score.toFixed(1)}</span>
</td>
<td className="px-4 py-3">
<button onClick={() => handleRemoveItem(item.id)} className="text-xs text-red-500 hover:text-red-700">Entfernen</button>
</td>
</tr>
))}
{items.length === 0 && (
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-500">Keine Items</td></tr>
)}
</tbody>
</table>
</div>
)}
{activeTab === 'activity' && (
<div className="space-y-3">
{activity.map((a, i) => (
<div key={i} className="bg-white rounded-lg border border-gray-200 p-4 flex items-center gap-4">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs ${
a.action === 'added' ? 'bg-green-500' : a.action === 'removed' ? 'bg-red-500' : 'bg-blue-500'
}`}>
{a.action === 'added' ? '+' : a.action === 'removed' ? '-' : '~'}
</div>
<div className="flex-1">
<div className="text-sm text-gray-900">
<span className="font-medium">{a.item_title || a.item_id}</span> {a.action}
</div>
<div className="text-xs text-gray-500">{a.item_type}</div>
</div>
<div className="text-xs text-gray-400">{new Date(a.timestamp).toLocaleString('de-DE')}</div>
</div>
))}
{activity.length === 0 && <div className="text-center py-8 text-gray-500">Keine Aktivitaet</div>}
</div>
)}
{activeTab === 'compare' && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Portfolio-Vergleich</h3>
<div className="flex gap-3 mb-4">
<input type="text" value={compareIds} onChange={e => setCompareIds(e.target.value)}
className="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="Portfolio-IDs (kommagetrennt)" />
<button onClick={handleCompare}
className="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700">
Vergleichen
</button>
</div>
{compareResult && (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
{['Portfolio', 'Risiko-Score', 'Compliance', 'Items'].map(h => (
<th key={h} className="px-4 py-3 text-left text-xs font-medium text-gray-500">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{compareResult.portfolios?.map(p => (
<tr key={p.id}>
<td className="px-4 py-3 font-medium text-gray-900">{p.name}</td>
<td className="px-4 py-3 text-center">{compareResult.risk_scores?.[p.id]?.toFixed(1) ?? '-'}</td>
<td className="px-4 py-3 text-center">{compareResult.compliance_scores?.[p.id] ?? '-'}%</td>
<td className="px-4 py-3 text-center">{compareResult.item_counts?.[p.id] ?? '-'}</td>
</tr>
))}
</tbody>
</table>
{compareResult.common_items?.length > 0 && (
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
<div className="text-xs font-medium text-gray-500 mb-1">Gemeinsame Items: {compareResult.common_items.length}</div>
</div>
)}
</div>
)}
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,94 @@
export interface Portfolio {
id: string
name: string
description: string
status: 'DRAFT' | 'ACTIVE' | 'REVIEW' | 'APPROVED' | 'ARCHIVED'
department: string
business_unit: string
owner: string
owner_email: string
total_assessments: number
total_roadmaps: number
total_workshops: number
avg_risk_score: number
high_risk_count: number
compliance_score: number
auto_update_metrics: boolean
require_approval: boolean
created_at: string
updated_at: string
approved_at: string | null
approved_by: string | null
}
export interface PortfolioItem {
id: string
portfolio_id: string
item_type: 'ASSESSMENT' | 'ROADMAP' | 'WORKSHOP' | 'DOCUMENT'
item_id: string
title: string
status: string
risk_level: string
risk_score: number
feasibility: string
sort_order: number
tags: string[]
notes: string
created_at: string
}
export interface PortfolioStats {
total_items: number
items_by_type: Record<string, number>
risk_distribution: Record<string, number>
avg_risk_score: number
compliance_score: number
}
export interface ActivityEntry {
timestamp: string
action: string
item_type: string
item_id: string
item_title: string
user_id: string
}
export interface CompareResult {
portfolios: Portfolio[]
risk_scores: Record<string, number>
compliance_scores: Record<string, number>
item_counts: Record<string, number>
common_items: string[]
unique_items: Record<string, string[]>
}
export const API_BASE = '/api/sdk/v1/portfolio'
export async function api<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || err.message || `HTTP ${res.status}`)
}
return res.json()
}
export const statusColors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700',
ACTIVE: 'bg-green-100 text-green-700',
REVIEW: 'bg-yellow-100 text-yellow-700',
APPROVED: 'bg-purple-100 text-purple-700',
ARCHIVED: 'bg-red-100 text-red-700',
}
export const statusLabels: Record<string, string> = {
DRAFT: 'Entwurf',
ACTIVE: 'Aktiv',
REVIEW: 'In Pruefung',
APPROVED: 'Genehmigt',
ARCHIVED: 'Archiviert',
}

View File

@@ -1,530 +1,10 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
// =============================================================================
// TYPES
// =============================================================================
interface Portfolio {
id: string
name: string
description: string
status: 'DRAFT' | 'ACTIVE' | 'REVIEW' | 'APPROVED' | 'ARCHIVED'
department: string
business_unit: string
owner: string
owner_email: string
total_assessments: number
total_roadmaps: number
total_workshops: number
avg_risk_score: number
high_risk_count: number
compliance_score: number
auto_update_metrics: boolean
require_approval: boolean
created_at: string
updated_at: string
approved_at: string | null
approved_by: string | null
}
interface PortfolioItem {
id: string
portfolio_id: string
item_type: 'ASSESSMENT' | 'ROADMAP' | 'WORKSHOP' | 'DOCUMENT'
item_id: string
title: string
status: string
risk_level: string
risk_score: number
feasibility: string
sort_order: number
tags: string[]
notes: string
created_at: string
}
interface PortfolioStats {
total_items: number
items_by_type: Record<string, number>
risk_distribution: Record<string, number>
avg_risk_score: number
compliance_score: number
}
interface ActivityEntry {
timestamp: string
action: string
item_type: string
item_id: string
item_title: string
user_id: string
}
interface CompareResult {
portfolios: Portfolio[]
risk_scores: Record<string, number>
compliance_scores: Record<string, number>
item_counts: Record<string, number>
common_items: string[]
unique_items: Record<string, string[]>
}
// =============================================================================
// API
// =============================================================================
const API_BASE = '/api/sdk/v1/portfolio'
async function api<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || err.message || `HTTP ${res.status}`)
}
return res.json()
}
// =============================================================================
// COMPONENTS
// =============================================================================
const statusColors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700',
ACTIVE: 'bg-green-100 text-green-700',
REVIEW: 'bg-yellow-100 text-yellow-700',
APPROVED: 'bg-purple-100 text-purple-700',
ARCHIVED: 'bg-red-100 text-red-700',
}
const statusLabels: Record<string, string> = {
DRAFT: 'Entwurf',
ACTIVE: 'Aktiv',
REVIEW: 'In Pruefung',
APPROVED: 'Genehmigt',
ARCHIVED: 'Archiviert',
}
function PortfolioCard({ portfolio, onSelect, onDelete }: {
portfolio: Portfolio
onSelect: (p: Portfolio) => void
onDelete: (id: string) => void
}) {
const totalItems = portfolio.total_assessments + portfolio.total_roadmaps + portfolio.total_workshops
return (
<div className="bg-white rounded-xl border-2 border-gray-200 p-6 hover:border-purple-300 transition-colors cursor-pointer"
onClick={() => onSelect(portfolio)}>
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-gray-900 truncate">{portfolio.name}</h4>
{portfolio.department && <span className="text-xs text-gray-500">{portfolio.department}</span>}
</div>
<span className={`px-2 py-1 text-xs rounded-full ml-2 ${statusColors[portfolio.status] || 'bg-gray-100 text-gray-700'}`}>
{statusLabels[portfolio.status] || portfolio.status}
</span>
</div>
{portfolio.description && (
<p className="text-sm text-gray-600 mb-3 line-clamp-2">{portfolio.description}</p>
)}
<div className="grid grid-cols-3 gap-2 mb-4">
<div className="bg-gray-50 rounded-lg p-2 text-center">
<div className="text-lg font-bold text-purple-600">{portfolio.compliance_score}%</div>
<div className="text-xs text-gray-500">Compliance</div>
</div>
<div className="bg-gray-50 rounded-lg p-2 text-center">
<div className="text-lg font-bold text-gray-900">{portfolio.avg_risk_score.toFixed(1)}</div>
<div className="text-xs text-gray-500">Risiko</div>
</div>
<div className="bg-gray-50 rounded-lg p-2 text-center">
<div className="text-lg font-bold text-gray-900">{totalItems}</div>
<div className="text-xs text-gray-500">Items</div>
</div>
</div>
{portfolio.high_risk_count > 0 && (
<div className="flex items-center gap-1 text-xs text-red-600 mb-3">
<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 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
{portfolio.high_risk_count} Hoch-Risiko
</div>
)}
<div className="flex justify-between items-center">
<span className="text-xs text-gray-400">{portfolio.owner || 'Kein Owner'}</span>
<button onClick={(e) => { e.stopPropagation(); onDelete(portfolio.id) }}
className="text-xs text-red-500 hover:text-red-700 hover:bg-red-50 px-2 py-1 rounded">
Loeschen
</button>
</div>
</div>
)
}
function CreatePortfolioModal({ onClose, onCreated }: {
onClose: () => void
onCreated: () => void
}) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [department, setDepartment] = useState('')
const [owner, setOwner] = useState('')
const [saving, setSaving] = useState(false)
const handleCreate = async () => {
if (!name.trim()) return
setSaving(true)
try {
await api('', {
method: 'POST',
body: JSON.stringify({
name: name.trim(),
description: description.trim(),
department: department.trim(),
owner: owner.trim(),
}),
})
onCreated()
} catch (err) {
console.error('Create portfolio error:', err)
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-2xl p-6 w-full max-w-lg" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold text-gray-900 mb-4">Neues Portfolio</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input type="text" value={name} onChange={e => setName(e.target.value)}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="z.B. KI-Portfolio Q1 2026" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea value={description} onChange={e => setDescription(e.target.value)}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
rows={3} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Abteilung</label>
<input type="text" value={department} onChange={e => setDepartment(e.target.value)}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlicher</label>
<input type="text" value={owner} onChange={e => setOwner(e.target.value)}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" />
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
<button onClick={handleCreate} disabled={!name.trim() || saving}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
{saving ? 'Erstelle...' : 'Erstellen'}
</button>
</div>
</div>
</div>
)
}
function PortfolioDetailView({ portfolio, onBack, onRefresh }: {
portfolio: Portfolio
onBack: () => void
onRefresh: () => void
}) {
const [items, setItems] = useState<PortfolioItem[]>([])
const [activity, setActivity] = useState<ActivityEntry[]>([])
const [stats, setStats] = useState<PortfolioStats | null>(null)
const [activeTab, setActiveTab] = useState<'items' | 'activity' | 'compare'>('items')
const [loading, setLoading] = useState(true)
const [compareIds, setCompareIds] = useState('')
const [compareResult, setCompareResult] = useState<CompareResult | null>(null)
const loadDetails = useCallback(async () => {
setLoading(true)
try {
const [i, a, s] = await Promise.all([
api<PortfolioItem[]>(`/${portfolio.id}/items`).catch(() => []),
api<ActivityEntry[]>(`/${portfolio.id}/activity`).catch(() => []),
api<PortfolioStats>(`/${portfolio.id}/stats`).catch(() => null),
])
setItems(Array.isArray(i) ? i : [])
setActivity(Array.isArray(a) ? a : [])
setStats(s)
} finally {
setLoading(false)
}
}, [portfolio.id])
useEffect(() => { loadDetails() }, [loadDetails])
const handleSubmitReview = async () => {
try {
await api(`/${portfolio.id}/submit-review`, { method: 'POST' })
onRefresh()
} catch (err) {
console.error('Submit review error:', err)
}
}
const handleApprove = async () => {
try {
await api(`/${portfolio.id}/approve`, { method: 'POST' })
onRefresh()
} catch (err) {
console.error('Approve error:', err)
}
}
const handleRecalculate = async () => {
try {
await api(`/${portfolio.id}/recalculate`, { method: 'POST' })
loadDetails()
onRefresh()
} catch (err) {
console.error('Recalculate error:', err)
}
}
const handleCompare = async () => {
const ids = compareIds.split(',').map(s => s.trim()).filter(Boolean)
if (ids.length < 1) return
try {
const result = await api<CompareResult>('/compare', {
method: 'POST',
body: JSON.stringify({ portfolio_ids: [portfolio.id, ...ids] }),
})
setCompareResult(result)
} catch (err) {
console.error('Compare error:', err)
}
}
const handleRemoveItem = async (itemId: string) => {
try {
await api(`/${portfolio.id}/items/${itemId}`, { method: 'DELETE' })
setItems(prev => prev.filter(i => i.id !== itemId))
} catch (err) {
console.error('Remove item error:', err)
}
}
const typeLabels: Record<string, string> = {
ASSESSMENT: 'Assessment', ROADMAP: 'Roadmap', WORKSHOP: 'Workshop', DOCUMENT: 'Dokument',
}
const typeColors: Record<string, string> = {
ASSESSMENT: 'bg-blue-100 text-blue-700', ROADMAP: 'bg-green-100 text-green-700',
WORKSHOP: 'bg-purple-100 text-purple-700', DOCUMENT: 'bg-orange-100 text-orange-700',
}
return (
<div>
<button onClick={onBack} className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 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="bg-white rounded-xl border-2 border-gray-200 p-6 mb-6">
<div className="flex items-start justify-between mb-4">
<div>
<h2 className="text-xl font-bold text-gray-900">{portfolio.name}</h2>
<p className="text-sm text-gray-500 mt-1">{portfolio.description}</p>
</div>
<span className={`px-3 py-1 text-sm rounded-full ${statusColors[portfolio.status]}`}>
{statusLabels[portfolio.status]}
</span>
</div>
{stats && (
<div className="grid grid-cols-4 gap-4 mb-4">
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-purple-600">{stats.compliance_score}%</div>
<div className="text-xs text-gray-500">Compliance</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-gray-900">{stats.avg_risk_score.toFixed(1)}</div>
<div className="text-xs text-gray-500">Risiko-Score</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-gray-900">{stats.total_items}</div>
<div className="text-xs text-gray-500">Items</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-red-600">{portfolio.high_risk_count}</div>
<div className="text-xs text-gray-500">Hoch-Risiko</div>
</div>
</div>
)}
<div className="flex gap-2">
{portfolio.status === 'ACTIVE' && (
<button onClick={handleSubmitReview} className="px-3 py-1.5 text-sm bg-yellow-600 text-white rounded-lg hover:bg-yellow-700">
Zur Pruefung einreichen
</button>
)}
{portfolio.status === 'REVIEW' && (
<button onClick={handleApprove} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">
Genehmigen
</button>
)}
<button onClick={handleRecalculate} className="px-3 py-1.5 text-sm border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">
Metriken neu berechnen
</button>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-4 bg-gray-100 p-1 rounded-lg">
{(['items', 'activity', 'compare'] as const).map(tab => (
<button key={tab} onClick={() => setActiveTab(tab)}
className={`flex-1 px-4 py-2 text-sm rounded-md transition-colors ${activeTab === tab ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'}`}>
{tab === 'items' ? `Items (${items.length})` : tab === 'activity' ? 'Aktivitaet' : 'Vergleich'}
</button>
))}
</div>
{loading ? (
<div className="text-center py-8 text-gray-500">Laden...</div>
) : (
<>
{activeTab === 'items' && (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Titel</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Typ</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Risiko</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{items.map(item => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div className="font-medium text-gray-900">{item.title}</div>
{item.notes && <div className="text-xs text-gray-500 truncate max-w-xs">{item.notes}</div>}
</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 text-xs rounded-full ${typeColors[item.item_type] || 'bg-gray-100 text-gray-700'}`}>
{typeLabels[item.item_type] || item.item_type}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{item.status}</td>
<td className="px-4 py-3">
<span className={`text-sm font-medium ${
item.risk_score >= 7 ? 'text-red-600' : item.risk_score >= 4 ? 'text-yellow-600' : 'text-green-600'
}`}>{item.risk_score.toFixed(1)}</span>
</td>
<td className="px-4 py-3">
<button onClick={() => handleRemoveItem(item.id)}
className="text-xs text-red-500 hover:text-red-700">Entfernen</button>
</td>
</tr>
))}
{items.length === 0 && (
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-500">Keine Items</td></tr>
)}
</tbody>
</table>
</div>
)}
{activeTab === 'activity' && (
<div className="space-y-3">
{activity.map((a, i) => (
<div key={i} className="bg-white rounded-lg border border-gray-200 p-4 flex items-center gap-4">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs ${
a.action === 'added' ? 'bg-green-500' : a.action === 'removed' ? 'bg-red-500' : 'bg-blue-500'
}`}>
{a.action === 'added' ? '+' : a.action === 'removed' ? '-' : '~'}
</div>
<div className="flex-1">
<div className="text-sm text-gray-900">
<span className="font-medium">{a.item_title || a.item_id}</span> {a.action}
</div>
<div className="text-xs text-gray-500">{a.item_type}</div>
</div>
<div className="text-xs text-gray-400">{new Date(a.timestamp).toLocaleString('de-DE')}</div>
</div>
))}
{activity.length === 0 && (
<div className="text-center py-8 text-gray-500">Keine Aktivitaet</div>
)}
</div>
)}
{activeTab === 'compare' && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Portfolio-Vergleich</h3>
<div className="flex gap-3 mb-4">
<input
type="text" value={compareIds} onChange={e => setCompareIds(e.target.value)}
className="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="Portfolio-IDs (kommagetrennt)"
/>
<button onClick={handleCompare}
className="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700">
Vergleichen
</button>
</div>
{compareResult && (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500">Portfolio</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500">Risiko-Score</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500">Compliance</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500">Items</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{compareResult.portfolios?.map(p => (
<tr key={p.id}>
<td className="px-4 py-3 font-medium text-gray-900">{p.name}</td>
<td className="px-4 py-3 text-center">{compareResult.risk_scores?.[p.id]?.toFixed(1) ?? '-'}</td>
<td className="px-4 py-3 text-center">{compareResult.compliance_scores?.[p.id] ?? '-'}%</td>
<td className="px-4 py-3 text-center">{compareResult.item_counts?.[p.id] ?? '-'}</td>
</tr>
))}
</tbody>
</table>
{compareResult.common_items?.length > 0 && (
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
<div className="text-xs font-medium text-gray-500 mb-1">Gemeinsame Items: {compareResult.common_items.length}</div>
</div>
)}
</div>
)}
</div>
)}
</>
)}
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
import { Portfolio, api, statusLabels } from './_components/PortfolioTypes'
import { PortfolioCard } from './_components/PortfolioCard'
import { CreatePortfolioModal } from './_components/CreatePortfolioModal'
import { PortfolioDetailView } from './_components/PortfolioDetailView'
export default function PortfolioPage() {
const [portfolios, setPortfolios] = useState<Portfolio[]>([])
@@ -584,12 +64,12 @@ export default function PortfolioPage() {
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">KI-Portfolios</h1>
<p className="text-sm text-gray-500 mt-1">
Verwaltung und Vergleich von Compliance-Portfolios
</p>
<p className="text-sm text-gray-500 mt-1">Verwaltung und Vergleich von Compliance-Portfolios</p>
</div>
<button onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 flex items-center gap-2">
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
@@ -628,7 +108,8 @@ export default function PortfolioPage() {
<div className="text-center py-12">
<div className="text-gray-400 mb-2">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="text-gray-500">Keine Portfolios gefunden</p>
@@ -645,7 +126,10 @@ export default function PortfolioPage() {
)}
{showCreate && (
<CreatePortfolioModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); loadPortfolios() }} />
<CreatePortfolioModal
onClose={() => setShowCreate(false)}
onCreated={() => { setShowCreate(false); loadPortfolios() }}
/>
)}
</div>
)

View File

@@ -0,0 +1,17 @@
export function LoadingSkeleton() {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
<div className="h-4 w-full bg-gray-100 rounded mb-4" />
<div className="grid grid-cols-3 gap-4">
<div className="h-4 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded" />
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,152 @@
'use client'
import { useState } from 'react'
import { Risk, RiskStatus } from '@/lib/sdk'
export function RiskCard({
risk,
onEdit,
onDelete,
onStatusChange,
}: {
risk: Risk
onEdit: () => void
onDelete: () => void
onStatusChange: (status: RiskStatus) => void
}) {
const [showMitigations, setShowMitigations] = useState(false)
const severityColors = {
CRITICAL: 'border-red-200 bg-red-50',
HIGH: 'border-orange-200 bg-orange-50',
MEDIUM: 'border-yellow-200 bg-yellow-50',
LOW: 'border-green-200 bg-green-50',
}
return (
<div className={`bg-white rounded-xl border-2 p-6 ${severityColors[risk.severity]}`}>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<h4 className="font-semibold text-gray-900">{risk.title}</h4>
<span
className={`px-2 py-0.5 text-xs rounded-full ${
risk.severity === 'CRITICAL'
? 'bg-red-100 text-red-700'
: risk.severity === 'HIGH'
? 'bg-orange-100 text-orange-700'
: risk.severity === 'MEDIUM'
? 'bg-yellow-100 text-yellow-700'
: 'bg-green-100 text-green-700'
}`}
>
{risk.severity}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">{risk.description}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={onEdit}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onClick={onDelete}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500">Wahrscheinlichkeit:</span>
<span className="ml-2 font-medium">{risk.likelihood}/5</span>
</div>
<div>
<span className="text-gray-500">Auswirkung:</span>
<span className="ml-2 font-medium">{risk.impact}/5</span>
</div>
<div>
<span className="text-gray-500">Inherent:</span>
<span className="ml-2 font-medium">{risk.inherentRiskScore}</span>
</div>
<div>
<span className="text-gray-500">Residual:</span>
<span className={`ml-2 font-medium ${
risk.residualRiskScore < risk.inherentRiskScore ? 'text-green-600' : ''
}`}>
{risk.residualRiskScore}
</span>
{risk.residualRiskScore < risk.inherentRiskScore && (
<span className="ml-1 text-xs text-green-600">
({risk.inherentRiskScore} &rarr; {risk.residualRiskScore})
</span>
)}
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-200 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Status:</span>
<select
value={risk.status}
onChange={(e) => onStatusChange(e.target.value as RiskStatus)}
className="px-2 py-1 text-sm border border-gray-300 rounded-lg"
>
<option value="IDENTIFIED">Identifiziert</option>
<option value="ASSESSED">Bewertet</option>
<option value="MITIGATED">Mitigiert</option>
<option value="ACCEPTED">Akzeptiert</option>
<option value="CLOSED">Geschlossen</option>
</select>
</div>
{risk.mitigation.length > 0 && (
<button
onClick={() => setShowMitigations(!showMitigations)}
className="text-sm text-purple-600 hover:text-purple-700"
>
{showMitigations ? 'Mitigationen ausblenden' : `${risk.mitigation.length} Mitigation(en) anzeigen`}
</button>
)}
</div>
{showMitigations && risk.mitigation.length > 0 && (
<div className="mt-3 space-y-2">
{risk.mitigation.map((m, idx) => (
<div key={idx} className="p-3 bg-gray-50 rounded-lg text-sm">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-700">{m.controlId || `Mitigation ${idx + 1}`}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${
m.status === 'IMPLEMENTED' ? 'bg-green-100 text-green-700' :
m.status === 'IN_PROGRESS' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-500'
}`}>
{m.status === 'IMPLEMENTED' ? 'Implementiert' :
m.status === 'IN_PROGRESS' ? 'In Bearbeitung' : m.status || 'Geplant'}
</span>
</div>
{m.description && <p className="text-gray-500 mt-1">{m.description}</p>}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,167 @@
'use client'
import { useState } from 'react'
import { RiskLikelihood, RiskImpact, calculateRiskScore, getRiskSeverityFromScore } from '@/lib/sdk'
export interface RiskFormData {
title: string
description: string
category: string
likelihood: RiskLikelihood
impact: RiskImpact
}
export function RiskForm({
onSubmit,
onCancel,
initialData,
}: {
onSubmit: (data: RiskFormData) => void
onCancel: () => void
initialData?: Partial<RiskFormData>
}) {
const [formData, setFormData] = useState<RiskFormData>({
title: initialData?.title || '',
description: initialData?.description || '',
category: initialData?.category || 'technical',
likelihood: initialData?.likelihood || 3,
impact: initialData?.impact || 3,
})
const score = calculateRiskScore(formData.likelihood, formData.impact)
const severity = getRiskSeverityFromScore(score)
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{initialData ? 'Risiko bearbeiten' : 'Neues Risiko'}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
<input
type="text"
value={formData.title}
onChange={e => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. Datenverlust durch Systemausfall"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
placeholder="Beschreiben Sie das Risiko..."
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
<select
value={formData.category}
onChange={e => setFormData({ ...formData, category: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="technical">Technisch</option>
<option value="organizational">Organisatorisch</option>
<option value="legal">Rechtlich</option>
<option value="operational">Operativ</option>
<option value="strategic">Strategisch</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Wahrscheinlichkeit (1-5)
</label>
<input
type="range"
min={1}
max={5}
value={formData.likelihood}
onChange={e => setFormData({ ...formData, likelihood: Number(e.target.value) as RiskLikelihood })}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>Sehr unwahrscheinlich</span>
<span className="font-bold">{formData.likelihood}</span>
<span>Sehr wahrscheinlich</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Auswirkung (1-5)</label>
<input
type="range"
min={1}
max={5}
value={formData.impact}
onChange={e => setFormData({ ...formData, impact: Number(e.target.value) as RiskImpact })}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>Gering</span>
<span className="font-bold">{formData.impact}</span>
<span>Katastrophal</span>
</div>
</div>
</div>
<div
className={`p-4 rounded-lg ${
severity === 'CRITICAL'
? 'bg-red-50 border border-red-200'
: severity === 'HIGH'
? 'bg-orange-50 border border-orange-200'
: severity === 'MEDIUM'
? 'bg-yellow-50 border border-yellow-200'
: 'bg-green-50 border border-green-200'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Berechneter Risikoscore:</span>
<span
className={`px-3 py-1 rounded-full text-sm font-bold ${
severity === 'CRITICAL'
? 'bg-red-100 text-red-700'
: severity === 'HIGH'
? 'bg-orange-100 text-orange-700'
: severity === 'MEDIUM'
? 'bg-yellow-100 text-yellow-700'
: 'bg-green-100 text-green-700'
}`}
>
{score} ({severity})
</span>
</div>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
onClick={() => onSubmit(formData)}
disabled={!formData.title}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.title
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Speichern
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,87 @@
'use client'
import React from 'react'
import { Risk } from '@/lib/sdk'
export function RiskMatrix({ risks, onCellClick }: { risks: Risk[]; onCellClick: (l: number, i: number) => void }) {
const matrix: Record<string, Risk[]> = {}
risks.forEach(risk => {
const key = `${risk.likelihood}-${risk.impact}`
if (!matrix[key]) matrix[key] = []
matrix[key].push(risk)
})
const getCellColor = (likelihood: number, impact: number): string => {
const score = likelihood * impact
if (score >= 20) return 'bg-red-500'
if (score >= 15) return 'bg-red-400'
if (score >= 12) return 'bg-orange-400'
if (score >= 8) return 'bg-yellow-400'
if (score >= 4) return 'bg-yellow-300'
return 'bg-green-400'
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">5x5 Risikomatrix</h3>
<div className="flex">
<div className="flex flex-col justify-center pr-2">
<div className="transform -rotate-90 whitespace-nowrap text-sm text-gray-500 font-medium">
Wahrscheinlichkeit
</div>
</div>
<div className="flex-1">
<div className="grid grid-cols-5 gap-1">
{[5, 4, 3, 2, 1].map(likelihood => (
<React.Fragment key={likelihood}>
{[1, 2, 3, 4, 5].map(impact => {
const key = `${likelihood}-${impact}`
const cellRisks = matrix[key] || []
return (
<button
key={key}
onClick={() => onCellClick(likelihood, impact)}
className={`aspect-square rounded-lg ${getCellColor(
likelihood,
impact
)} hover:opacity-80 transition-opacity relative`}
>
{cellRisks.length > 0 && (
<span className="absolute inset-0 flex items-center justify-center text-white font-bold text-lg">
{cellRisks.length}
</span>
)}
</button>
)
})}
</React.Fragment>
))}
</div>
<div className="mt-2 text-center text-sm text-gray-500 font-medium">Auswirkung</div>
</div>
</div>
<div className="mt-6 flex items-center justify-center gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-green-400" />
<span>Niedrig</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-yellow-400" />
<span>Mittel</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-orange-400" />
<span>Hoch</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-red-500" />
<span>Kritisch</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,184 @@
'use client'
import { useState, useEffect } from 'react'
import { useSDK, Risk, RiskLikelihood, RiskImpact, RiskSeverity, RiskStatus, RiskMitigation, calculateRiskScore, getRiskSeverityFromScore } from '@/lib/sdk'
import { RiskFormData } from '../_components/RiskForm'
export function useRisks() {
const { state, dispatch, addRisk } = useSDK()
const [showForm, setShowForm] = useState(false)
const [editingRisk, setEditingRisk] = useState<Risk | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [matrixFilter, setMatrixFilter] = useState<{ likelihood: number; impact: number } | null>(null)
useEffect(() => {
const fetchRisks = async () => {
try {
setLoading(true)
const res = await fetch('/api/sdk/v1/compliance/risks')
if (res.ok) {
const data = await res.json()
const backendRisks = data.risks || data
if (Array.isArray(backendRisks) && backendRisks.length > 0) {
const mapped: Risk[] = backendRisks.map((r: Record<string, unknown>) => ({
id: (r.risk_id || r.id || '') as string,
title: (r.title || '') as string,
description: (r.description || '') as string,
category: (r.category || 'technical') as string,
likelihood: (r.likelihood || 3) as RiskLikelihood,
impact: (r.impact || 3) as RiskImpact,
severity: ((r.inherent_risk || r.severity || 'MEDIUM') as string).toUpperCase() as RiskSeverity,
inherentRiskScore: (r.likelihood as number || 3) * (r.impact as number || 3),
residualRiskScore: (r.residual_likelihood as number || r.likelihood as number || 3) * (r.residual_impact as number || r.impact as number || 3),
status: (r.status || 'IDENTIFIED') as RiskStatus,
mitigation: (Array.isArray(r.mitigating_controls) ? (r.mitigating_controls as RiskMitigation[]) : []) as RiskMitigation[],
owner: (r.owner || null) as string | null,
relatedControls: [] as string[],
relatedRequirements: [] as string[],
}))
dispatch({ type: 'SET_STATE', payload: { risks: mapped } })
setError(null)
}
}
} catch {
// Backend unavailable — use SDK state as-is
} finally {
setLoading(false)
}
}
fetchRisks()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const handleSubmit = async (data: RiskFormData) => {
const score = calculateRiskScore(data.likelihood, data.impact)
const severity = getRiskSeverityFromScore(score)
if (editingRisk) {
dispatch({
type: 'UPDATE_RISK',
payload: {
id: editingRisk.id,
data: { ...data, severity, inherentRiskScore: score, residualRiskScore: score },
},
})
try {
await fetch(`/api/sdk/v1/compliance/risks/${editingRisk.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: data.title,
description: data.description,
category: data.category,
likelihood: data.likelihood,
impact: data.impact,
}),
})
} catch {
// Silently fail
}
} else {
const riskId = `risk-${Date.now()}`
const newRisk: Risk = {
id: riskId,
...data,
severity,
inherentRiskScore: score,
residualRiskScore: score,
status: 'IDENTIFIED',
mitigation: [],
owner: null,
relatedControls: [],
relatedRequirements: [],
}
addRisk(newRisk)
try {
await fetch('/api/sdk/v1/compliance/risks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
risk_id: riskId,
title: data.title,
description: data.description,
category: data.category,
likelihood: data.likelihood,
impact: data.impact,
}),
})
} catch {
// Silently fail
}
}
setShowForm(false)
setEditingRisk(null)
}
const handleDelete = async (id: string) => {
if (!confirm('Moechten Sie dieses Risiko wirklich loeschen?')) return
dispatch({ type: 'DELETE_RISK', payload: id })
try {
await fetch(`/api/sdk/v1/compliance/risks/${id}`, { method: 'DELETE' })
} catch {
// Silently fail
}
}
const handleStatusChange = async (riskId: string, status: RiskStatus) => {
dispatch({ type: 'UPDATE_RISK', payload: { id: riskId, data: { status } } })
try {
await fetch(`/api/sdk/v1/compliance/risks/${riskId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
})
} catch {
// Silently fail
}
}
const handleEdit = (risk: Risk) => {
setEditingRisk(risk)
setShowForm(true)
}
const handleMatrixCellClick = (l: number, i: number) => {
if (matrixFilter && matrixFilter.likelihood === l && matrixFilter.impact === i) {
setMatrixFilter(null)
} else {
setMatrixFilter({ likelihood: l, impact: i })
}
}
const stats = {
totalRisks: state.risks.length,
criticalRisks: state.risks.filter(r => r.severity === 'CRITICAL').length,
highRisks: state.risks.filter(r => r.severity === 'HIGH').length,
mitigatedRisks: state.risks.filter(r => r.mitigation.length > 0).length,
}
const filteredRisks = state.risks
.filter(risk => !matrixFilter || (risk.likelihood === matrixFilter.likelihood && risk.impact === matrixFilter.impact))
.sort((a, b) => b.inherentRiskScore - a.inherentRiskScore)
return {
state,
showForm,
setShowForm,
editingRisk,
setEditingRisk,
loading,
error,
setError,
matrixFilter,
setMatrixFilter,
handleSubmit,
handleDelete,
handleStatusChange,
handleEdit,
handleMatrixCellClick,
stats,
filteredRisks,
}
}

View File

@@ -1,612 +1,37 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useSDK, Risk, RiskLikelihood, RiskImpact, RiskSeverity, RiskStatus, RiskMitigation, calculateRiskScore, getRiskSeverityFromScore } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
// =============================================================================
// RISK MATRIX
// =============================================================================
function RiskMatrix({ risks, onCellClick }: { risks: Risk[]; onCellClick: (l: number, i: number) => void }) {
const matrix: Record<string, Risk[]> = {}
risks.forEach(risk => {
const key = `${risk.likelihood}-${risk.impact}`
if (!matrix[key]) matrix[key] = []
matrix[key].push(risk)
})
const getCellColor = (likelihood: number, impact: number): string => {
const score = likelihood * impact
if (score >= 20) return 'bg-red-500'
if (score >= 15) return 'bg-red-400'
if (score >= 12) return 'bg-orange-400'
if (score >= 8) return 'bg-yellow-400'
if (score >= 4) return 'bg-yellow-300'
return 'bg-green-400'
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">5x5 Risikomatrix</h3>
<div className="flex">
{/* Y-Axis Label */}
<div className="flex flex-col justify-center pr-2">
<div className="transform -rotate-90 whitespace-nowrap text-sm text-gray-500 font-medium">
Wahrscheinlichkeit
</div>
</div>
<div className="flex-1">
{/* Matrix Grid */}
<div className="grid grid-cols-5 gap-1">
{[5, 4, 3, 2, 1].map(likelihood => (
<React.Fragment key={likelihood}>
{[1, 2, 3, 4, 5].map(impact => {
const key = `${likelihood}-${impact}`
const cellRisks = matrix[key] || []
return (
<button
key={key}
onClick={() => onCellClick(likelihood, impact)}
className={`aspect-square rounded-lg ${getCellColor(
likelihood,
impact
)} hover:opacity-80 transition-opacity relative`}
>
{cellRisks.length > 0 && (
<span className="absolute inset-0 flex items-center justify-center text-white font-bold text-lg">
{cellRisks.length}
</span>
)}
</button>
)
})}
</React.Fragment>
))}
</div>
{/* X-Axis Label */}
<div className="mt-2 text-center text-sm text-gray-500 font-medium">Auswirkung</div>
</div>
</div>
{/* Legend */}
<div className="mt-6 flex items-center justify-center gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-green-400" />
<span>Niedrig</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-yellow-400" />
<span>Mittel</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-orange-400" />
<span>Hoch</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-red-500" />
<span>Kritisch</span>
</div>
</div>
</div>
)
}
// =============================================================================
// RISK FORM
// =============================================================================
interface RiskFormData {
title: string
description: string
category: string
likelihood: RiskLikelihood
impact: RiskImpact
}
function RiskForm({
onSubmit,
onCancel,
initialData,
}: {
onSubmit: (data: RiskFormData) => void
onCancel: () => void
initialData?: Partial<RiskFormData>
}) {
const [formData, setFormData] = useState<RiskFormData>({
title: initialData?.title || '',
description: initialData?.description || '',
category: initialData?.category || 'technical',
likelihood: initialData?.likelihood || 3,
impact: initialData?.impact || 3,
})
const score = calculateRiskScore(formData.likelihood, formData.impact)
const severity = getRiskSeverityFromScore(score)
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{initialData ? 'Risiko bearbeiten' : 'Neues Risiko'}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
<input
type="text"
value={formData.title}
onChange={e => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. Datenverlust durch Systemausfall"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
placeholder="Beschreiben Sie das Risiko..."
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
<select
value={formData.category}
onChange={e => setFormData({ ...formData, category: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="technical">Technisch</option>
<option value="organizational">Organisatorisch</option>
<option value="legal">Rechtlich</option>
<option value="operational">Operativ</option>
<option value="strategic">Strategisch</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Wahrscheinlichkeit (1-5)
</label>
<input
type="range"
min={1}
max={5}
value={formData.likelihood}
onChange={e => setFormData({ ...formData, likelihood: Number(e.target.value) as RiskLikelihood })}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>Sehr unwahrscheinlich</span>
<span className="font-bold">{formData.likelihood}</span>
<span>Sehr wahrscheinlich</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Auswirkung (1-5)</label>
<input
type="range"
min={1}
max={5}
value={formData.impact}
onChange={e => setFormData({ ...formData, impact: Number(e.target.value) as RiskImpact })}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>Gering</span>
<span className="font-bold">{formData.impact}</span>
<span>Katastrophal</span>
</div>
</div>
</div>
{/* Risk Score Preview */}
<div
className={`p-4 rounded-lg ${
severity === 'CRITICAL'
? 'bg-red-50 border border-red-200'
: severity === 'HIGH'
? 'bg-orange-50 border border-orange-200'
: severity === 'MEDIUM'
? 'bg-yellow-50 border border-yellow-200'
: 'bg-green-50 border border-green-200'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Berechneter Risikoscore:</span>
<span
className={`px-3 py-1 rounded-full text-sm font-bold ${
severity === 'CRITICAL'
? 'bg-red-100 text-red-700'
: severity === 'HIGH'
? 'bg-orange-100 text-orange-700'
: severity === 'MEDIUM'
? 'bg-yellow-100 text-yellow-700'
: 'bg-green-100 text-green-700'
}`}
>
{score} ({severity})
</span>
</div>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
onClick={() => onSubmit(formData)}
disabled={!formData.title}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.title
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Speichern
</button>
</div>
</div>
)
}
// =============================================================================
// RISK CARD
// =============================================================================
function RiskCard({
risk,
onEdit,
onDelete,
onStatusChange,
}: {
risk: Risk
onEdit: () => void
onDelete: () => void
onStatusChange: (status: RiskStatus) => void
}) {
const [showMitigations, setShowMitigations] = useState(false)
const severityColors = {
CRITICAL: 'border-red-200 bg-red-50',
HIGH: 'border-orange-200 bg-orange-50',
MEDIUM: 'border-yellow-200 bg-yellow-50',
LOW: 'border-green-200 bg-green-50',
}
return (
<div className={`bg-white rounded-xl border-2 p-6 ${severityColors[risk.severity]}`}>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<h4 className="font-semibold text-gray-900">{risk.title}</h4>
<span
className={`px-2 py-0.5 text-xs rounded-full ${
risk.severity === 'CRITICAL'
? 'bg-red-100 text-red-700'
: risk.severity === 'HIGH'
? 'bg-orange-100 text-orange-700'
: risk.severity === 'MEDIUM'
? 'bg-yellow-100 text-yellow-700'
: 'bg-green-100 text-green-700'
}`}
>
{risk.severity}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">{risk.description}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={onEdit}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onClick={onDelete}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500">Wahrscheinlichkeit:</span>
<span className="ml-2 font-medium">{risk.likelihood}/5</span>
</div>
<div>
<span className="text-gray-500">Auswirkung:</span>
<span className="ml-2 font-medium">{risk.impact}/5</span>
</div>
<div>
<span className="text-gray-500">Inherent:</span>
<span className="ml-2 font-medium">{risk.inherentRiskScore}</span>
</div>
<div>
<span className="text-gray-500">Residual:</span>
<span className={`ml-2 font-medium ${
risk.residualRiskScore < risk.inherentRiskScore ? 'text-green-600' : ''
}`}>
{risk.residualRiskScore}
</span>
{risk.residualRiskScore < risk.inherentRiskScore && (
<span className="ml-1 text-xs text-green-600">
({risk.inherentRiskScore} &rarr; {risk.residualRiskScore})
</span>
)}
</div>
</div>
{/* Status Workflow */}
<div className="mt-4 pt-4 border-t border-gray-200 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Status:</span>
<select
value={risk.status}
onChange={(e) => onStatusChange(e.target.value as RiskStatus)}
className="px-2 py-1 text-sm border border-gray-300 rounded-lg"
>
<option value="IDENTIFIED">Identifiziert</option>
<option value="ASSESSED">Bewertet</option>
<option value="MITIGATED">Mitigiert</option>
<option value="ACCEPTED">Akzeptiert</option>
<option value="CLOSED">Geschlossen</option>
</select>
</div>
{risk.mitigation.length > 0 && (
<button
onClick={() => setShowMitigations(!showMitigations)}
className="text-sm text-purple-600 hover:text-purple-700"
>
{showMitigations ? 'Mitigationen ausblenden' : `${risk.mitigation.length} Mitigation(en) anzeigen`}
</button>
)}
</div>
{/* Expanded Mitigations */}
{showMitigations && risk.mitigation.length > 0 && (
<div className="mt-3 space-y-2">
{risk.mitigation.map((m, idx) => (
<div key={idx} className="p-3 bg-gray-50 rounded-lg text-sm">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-700">{m.controlId || `Mitigation ${idx + 1}`}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${
m.status === 'IMPLEMENTED' ? 'bg-green-100 text-green-700' :
m.status === 'IN_PROGRESS' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-500'
}`}>
{m.status === 'IMPLEMENTED' ? 'Implementiert' :
m.status === 'IN_PROGRESS' ? 'In Bearbeitung' : m.status || 'Geplant'}
</span>
</div>
{m.description && <p className="text-gray-500 mt-1">{m.description}</p>}
</div>
))}
</div>
)}
</div>
)
}
function LoadingSkeleton() {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
<div className="h-4 w-full bg-gray-100 rounded mb-4" />
<div className="grid grid-cols-3 gap-4">
<div className="h-4 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded" />
</div>
</div>
))}
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
import { RiskMatrix } from './_components/RiskMatrix'
import { RiskForm } from './_components/RiskForm'
import { RiskCard } from './_components/RiskCard'
import { LoadingSkeleton } from './_components/LoadingSkeleton'
import { useRisks } from './_hooks/useRisks'
export default function RisksPage() {
const { state, dispatch, addRisk } = useSDK()
const [showForm, setShowForm] = useState(false)
const [editingRisk, setEditingRisk] = useState<Risk | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [matrixFilter, setMatrixFilter] = useState<{ likelihood: number; impact: number } | null>(null)
// Fetch risks from backend on mount
useEffect(() => {
const fetchRisks = async () => {
try {
setLoading(true)
const res = await fetch('/api/sdk/v1/compliance/risks')
if (res.ok) {
const data = await res.json()
const backendRisks = data.risks || data
if (Array.isArray(backendRisks) && backendRisks.length > 0) {
const mapped: Risk[] = backendRisks.map((r: Record<string, unknown>) => ({
id: (r.risk_id || r.id || '') as string,
title: (r.title || '') as string,
description: (r.description || '') as string,
category: (r.category || 'technical') as string,
likelihood: (r.likelihood || 3) as RiskLikelihood,
impact: (r.impact || 3) as RiskImpact,
severity: ((r.inherent_risk || r.severity || 'MEDIUM') as string).toUpperCase() as RiskSeverity,
inherentRiskScore: (r.likelihood as number || 3) * (r.impact as number || 3),
residualRiskScore: (r.residual_likelihood as number || r.likelihood as number || 3) * (r.residual_impact as number || r.impact as number || 3),
status: (r.status || 'IDENTIFIED') as RiskStatus,
mitigation: (Array.isArray(r.mitigating_controls) ? (r.mitigating_controls as RiskMitigation[]) : []) as RiskMitigation[],
owner: (r.owner || null) as string | null,
relatedControls: [] as string[],
relatedRequirements: [] as string[],
}))
dispatch({ type: 'SET_STATE', payload: { risks: mapped } })
setError(null)
}
}
} catch {
// Backend unavailable — use SDK state as-is
} finally {
setLoading(false)
}
}
fetchRisks()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const handleSubmit = async (data: { title: string; description: string; category: string; likelihood: RiskLikelihood; impact: RiskImpact }) => {
const score = calculateRiskScore(data.likelihood, data.impact)
const severity = getRiskSeverityFromScore(score)
if (editingRisk) {
dispatch({
type: 'UPDATE_RISK',
payload: {
id: editingRisk.id,
data: {
...data,
severity,
inherentRiskScore: score,
residualRiskScore: score,
},
},
})
// Persist to backend
try {
await fetch(`/api/sdk/v1/compliance/risks/${editingRisk.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: data.title,
description: data.description,
category: data.category,
likelihood: data.likelihood,
impact: data.impact,
}),
})
} catch {
// Silently fail
}
} else {
const riskId = `risk-${Date.now()}`
const newRisk: Risk = {
id: riskId,
...data,
severity,
inherentRiskScore: score,
residualRiskScore: score,
status: 'IDENTIFIED',
mitigation: [],
owner: null,
relatedControls: [],
relatedRequirements: [],
}
addRisk(newRisk)
// Persist to backend
try {
await fetch('/api/sdk/v1/compliance/risks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
risk_id: riskId,
title: data.title,
description: data.description,
category: data.category,
likelihood: data.likelihood,
impact: data.impact,
}),
})
} catch {
// Silently fail
}
}
setShowForm(false)
setEditingRisk(null)
}
const handleDelete = async (id: string) => {
if (!confirm('Moechten Sie dieses Risiko wirklich loeschen?')) return
dispatch({ type: 'DELETE_RISK', payload: id })
try {
await fetch(`/api/sdk/v1/compliance/risks/${id}`, {
method: 'DELETE',
})
} catch {
// Silently fail
}
}
const handleStatusChange = async (riskId: string, status: RiskStatus) => {
dispatch({
type: 'UPDATE_RISK',
payload: { id: riskId, data: { status } },
})
try {
await fetch(`/api/sdk/v1/compliance/risks/${riskId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
})
} catch {
// Silently fail
}
}
const handleEdit = (risk: Risk) => {
setEditingRisk(risk)
setShowForm(true)
}
// Stats
const totalRisks = state.risks.length
const criticalRisks = state.risks.filter(r => r.severity === 'CRITICAL').length
const highRisks = state.risks.filter(r => r.severity === 'HIGH').length
const mitigatedRisks = state.risks.filter(r => r.mitigation.length > 0).length
const {
state,
showForm,
setShowForm,
editingRisk,
setEditingRisk,
loading,
error,
setError,
matrixFilter,
setMatrixFilter,
handleSubmit,
handleDelete,
handleStatusChange,
handleEdit,
handleMatrixCellClick,
stats,
filteredRisks,
} = useRisks()
const stepInfo = STEP_EXPLANATIONS['risks']
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader
stepId="risks"
title={stepInfo.title}
@@ -627,7 +52,6 @@ export default function RisksPage() {
)}
</StepHeader>
{/* Error Banner */}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
@@ -635,56 +59,39 @@ export default function RisksPage() {
</div>
)}
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Gesamt</div>
<div className="text-3xl font-bold text-gray-900">{totalRisks}</div>
<div className="text-3xl font-bold text-gray-900">{stats.totalRisks}</div>
</div>
<div className="bg-white rounded-xl border border-red-200 p-6">
<div className="text-sm text-red-600">Kritisch</div>
<div className="text-3xl font-bold text-red-600">{criticalRisks}</div>
<div className="text-3xl font-bold text-red-600">{stats.criticalRisks}</div>
</div>
<div className="bg-white rounded-xl border border-orange-200 p-6">
<div className="text-sm text-orange-600">Hoch</div>
<div className="text-3xl font-bold text-orange-600">{highRisks}</div>
<div className="text-3xl font-bold text-orange-600">{stats.highRisks}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-6">
<div className="text-sm text-green-600">Mit Mitigation</div>
<div className="text-3xl font-bold text-green-600">{mitigatedRisks}</div>
<div className="text-3xl font-bold text-green-600">{stats.mitigatedRisks}</div>
</div>
</div>
{/* Form */}
{showForm && (
<RiskForm
onSubmit={handleSubmit}
onCancel={() => {
setShowForm(false)
setEditingRisk(null)
}}
onCancel={() => { setShowForm(false); setEditingRisk(null) }}
initialData={editingRisk || undefined}
/>
)}
{/* Loading */}
{loading && <LoadingSkeleton />}
{/* Matrix */}
{!loading && (
<RiskMatrix
risks={state.risks}
onCellClick={(l, i) => {
if (matrixFilter && matrixFilter.likelihood === l && matrixFilter.impact === i) {
setMatrixFilter(null)
} else {
setMatrixFilter({ likelihood: l, impact: i })
}
}}
/>
<RiskMatrix risks={state.risks} onCellClick={handleMatrixCellClick} />
)}
{/* Matrix Filter Badge */}
{matrixFilter && (
<div className="flex items-center gap-2">
<span className="px-3 py-1 text-sm bg-purple-100 text-purple-700 rounded-full flex items-center gap-2">
@@ -699,30 +106,25 @@ export default function RisksPage() {
</div>
)}
{/* Risk List */}
{!loading && state.risks.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{matrixFilter ? `Risiken (L=${matrixFilter.likelihood}, I=${matrixFilter.impact})` : 'Alle Risiken'}
</h3>
<div className="space-y-4">
{state.risks
.filter(risk => !matrixFilter || (risk.likelihood === matrixFilter.likelihood && risk.impact === matrixFilter.impact))
.sort((a, b) => b.inherentRiskScore - a.inherentRiskScore)
.map(risk => (
<RiskCard
key={risk.id}
risk={risk}
onEdit={() => handleEdit(risk)}
onDelete={() => handleDelete(risk.id)}
onStatusChange={(status) => handleStatusChange(risk.id, status)}
/>
))}
{filteredRisks.map(risk => (
<RiskCard
key={risk.id}
risk={risk}
onEdit={() => handleEdit(risk)}
onDelete={() => handleDelete(risk.id)}
onStatusChange={(status) => handleStatusChange(risk.id, status)}
/>
))}
</div>
</div>
)}
{/* Empty State */}
{!loading && state.risks.length === 0 && !showForm && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-orange-100 rounded-full flex items-center justify-center mb-4">

View File

@@ -0,0 +1,97 @@
'use client'
import { ReportType, ExportFormat } from '@/lib/sdk/vendor-compliance'
import { REPORT_TYPE_META } from './ReportTypeGrid'
const FORMAT_META: Record<ExportFormat, { label: string; icon: string }> = {
PDF: { label: 'PDF', icon: '📄' },
DOCX: { label: 'Word (DOCX)', icon: '📝' },
XLSX: { label: 'Excel (XLSX)', icon: '📊' },
JSON: { label: 'JSON', icon: '🔧' },
}
export function ExportPanel({
selectedReportType,
selectedFormat,
selectedVendors,
selectedActivities,
isGenerating,
onFormatChange,
onExport,
}: {
selectedReportType: ReportType
selectedFormat: ExportFormat
selectedVendors: string[]
selectedActivities: string[]
isGenerating: boolean
onFormatChange: (format: ExportFormat) => void
onExport: () => void
}) {
const reportMeta = REPORT_TYPE_META[selectedReportType]
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Export</h2>
</div>
<div className="p-4 space-y-4">
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">{reportMeta.icon}</span>
<span className="font-medium text-gray-900 dark:text-white">{reportMeta.title}</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">{reportMeta.description}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Format</label>
<div className="flex flex-wrap gap-2">
{reportMeta.formats.map((format) => (
<button
key={format}
onClick={() => onFormatChange(format)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedFormat === format
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{FORMAT_META[format].icon} {FORMAT_META[format].label}
</button>
))}
</div>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
<p>
{selectedReportType === 'VENDOR_AUDIT'
? `${selectedVendors.length || 'Alle'} Vendor(s) ausgewählt`
: selectedReportType === 'MANAGEMENT_SUMMARY'
? 'Gesamtübersicht'
: `${selectedActivities.length || 'Alle'} Verarbeitung(en) ausgewählt`}
</p>
</div>
<button
onClick={onExport}
disabled={isGenerating}
className={`w-full py-3 px-4 rounded-lg font-medium text-white transition-colors ${
isGenerating ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'
}`}
>
{isGenerating ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" 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 generiert...
</span>
) : (
`${reportMeta.title} exportieren`
)}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,32 @@
export function HelpPanel() {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Hilfe</h2>
</div>
<div className="p-4 space-y-3 text-sm">
<div className="flex gap-2">
<span>📋</span>
<div>
<p className="font-medium text-gray-900 dark:text-white">VVT Export</p>
<p className="text-gray-500 dark:text-gray-400">Art. 30 DSGVO konformes Verzeichnis aller Verarbeitungstätigkeiten</p>
</div>
</div>
<div className="flex gap-2">
<span>🔍</span>
<div>
<p className="font-medium text-gray-900 dark:text-white">Vendor Audit</p>
<p className="text-gray-500 dark:text-gray-400">Komplette Dokumentation für Due Diligence und Audits</p>
</div>
</div>
<div className="flex gap-2">
<span>📊</span>
<div>
<p className="font-medium text-gray-900 dark:text-white">Management Summary</p>
<p className="text-gray-500 dark:text-gray-400">Übersicht für Geschäftsführung und DSB</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,63 @@
'use client'
export function IncludeOptions({
includeFindings,
includeControls,
includeRiskAssessment,
onFindingsChange,
onControlsChange,
onRiskAssessmentChange,
}: {
includeFindings: boolean
includeControls: boolean
includeRiskAssessment: boolean
onFindingsChange: (v: boolean) => void
onControlsChange: (v: boolean) => void
onRiskAssessmentChange: (v: boolean) => void
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Optionen</h2>
</div>
<div className="p-4 space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={includeFindings}
onChange={(e) => onFindingsChange(e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">Findings einbeziehen</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Offene und behobene Vertragsprüfungs-Findings</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={includeControls}
onChange={(e) => onControlsChange(e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">Control-Status einbeziehen</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Übersicht aller Kontrollen und deren Erfüllungsstatus</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={includeRiskAssessment}
onChange={(e) => onRiskAssessmentChange(e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">Risikobewertung einbeziehen</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Inhärentes und Restrisiko mit Begründung</p>
</div>
</label>
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
'use client'
import { ReportType, ExportFormat } from '@/lib/sdk/vendor-compliance'
import { REPORT_TYPE_META } from './ReportTypeGrid'
export interface GeneratedReport {
id: string
type: ReportType
format: ExportFormat
generatedAt: Date
filename: string
}
export function RecentReports({ reports }: { reports: GeneratedReport[] }) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Letzte Reports</h2>
</div>
<div className="p-4">
{reports.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">Noch keine Reports generiert</p>
) : (
<div className="space-y-3">
{reports.slice(0, 5).map((report) => (
<div key={report.id} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex items-center gap-3">
<span className="text-lg">{REPORT_TYPE_META[report.type].icon}</span>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{report.filename}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{report.generatedAt.toLocaleString('de-DE')}</p>
</div>
</div>
<button className="text-blue-600 hover:text-blue-800 dark:text-blue-400">
<svg className="h-5 w-5" 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>
</button>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,85 @@
'use client'
import { ReportType, ExportFormat } from '@/lib/sdk/vendor-compliance'
const REPORT_TYPE_META: Record<
ReportType,
{ title: string; description: string; icon: string; formats: ExportFormat[]; defaultFormat: ExportFormat }
> = {
VVT_EXPORT: {
title: 'Verarbeitungsverzeichnis (VVT)',
description: 'Vollständiges Verarbeitungsverzeichnis gemäß Art. 30 DSGVO mit allen Pflichtangaben',
icon: '📋',
formats: ['PDF', 'DOCX', 'XLSX'],
defaultFormat: 'DOCX',
},
ROPA: {
title: 'Records of Processing Activities (RoPA)',
description: 'Processor-Perspektive: Alle Verarbeitungen als Auftragsverarbeiter',
icon: '📝',
formats: ['PDF', 'DOCX', 'XLSX'],
defaultFormat: 'DOCX',
},
VENDOR_AUDIT: {
title: 'Vendor Audit Pack',
description: 'Vollständige Dokumentation eines Vendors inkl. Verträge, Findings und Risikobewertung',
icon: '🔍',
formats: ['PDF', 'DOCX'],
defaultFormat: 'PDF',
},
MANAGEMENT_SUMMARY: {
title: 'Management Summary',
description: 'Übersicht für die Geschäftsführung: Risiken, offene Findings, Compliance-Status',
icon: '📊',
formats: ['PDF', 'DOCX', 'XLSX'],
defaultFormat: 'PDF',
},
DPIA_INPUT: {
title: 'DSFA-Input',
description: 'Vorbereitete Daten für eine Datenschutz-Folgenabschätzung (DSFA/DPIA)',
icon: '⚠️',
formats: ['PDF', 'DOCX'],
defaultFormat: 'DOCX',
},
}
export { REPORT_TYPE_META }
export function ReportTypeGrid({
selectedReportType,
onSelect,
}: {
selectedReportType: ReportType
onSelect: (type: ReportType) => void
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Report-Typ wählen</h2>
</div>
<div className="p-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
{(Object.entries(REPORT_TYPE_META) as [ReportType, typeof REPORT_TYPE_META[ReportType]][]).map(
([type, meta]) => (
<button
key={type}
onClick={() => onSelect(type)}
className={`p-4 rounded-lg border-2 text-left transition-all ${
selectedReportType === type
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className="flex items-center gap-3">
<span className="text-2xl">{meta.icon}</span>
<div>
<h3 className="font-medium text-gray-900 dark:text-white">{meta.title}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{meta.description}</p>
</div>
</div>
</button>
)
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,14 @@
export function RiskBadge({ score }: { score: number }) {
let colorClass = 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
if (score >= 70) {
colorClass = 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
} else if (score >= 50) {
colorClass = 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colorClass}`}>
{score}
</span>
)
}

View File

@@ -0,0 +1,101 @@
'use client'
import { ReportType, ProcessingActivity, Vendor } from '@/lib/sdk/vendor-compliance'
import { StatusBadge } from './StatusBadge'
import { RiskBadge } from './RiskBadge'
export function ScopePanel({
selectedReportType,
processingActivities,
vendors,
selectedVendors,
selectedActivities,
onToggleVendor,
onToggleActivity,
onSelectAllVendors,
onSelectAllActivities,
}: {
selectedReportType: ReportType
processingActivities: ProcessingActivity[]
vendors: Vendor[]
selectedVendors: string[]
selectedActivities: string[]
onToggleVendor: (id: string) => void
onToggleActivity: (id: string) => void
onSelectAllVendors: () => void
onSelectAllActivities: () => void
}) {
if (selectedReportType === 'VVT_EXPORT' || selectedReportType === 'ROPA' || selectedReportType === 'DPIA_INPUT') {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Verarbeitungen auswählen</h2>
<button onClick={onSelectAllActivities} className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400">
Alle auswählen
</button>
</div>
<div className="p-4 max-h-64 overflow-y-auto">
{processingActivities.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">Keine Verarbeitungen vorhanden</p>
) : (
<div className="space-y-2">
{processingActivities.map((activity) => (
<label key={activity.id} className="flex items-center gap-3 p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={selectedActivities.includes(activity.id)}
onChange={() => onToggleActivity(activity.id)}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">{activity.name.de}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{activity.vvtId} · {activity.status}</p>
</div>
<StatusBadge status={activity.status} />
</label>
))}
</div>
)}
</div>
</div>
)
}
if (selectedReportType === 'VENDOR_AUDIT') {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Vendor auswählen</h2>
<button onClick={onSelectAllVendors} className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400">
Alle auswählen
</button>
</div>
<div className="p-4 max-h-64 overflow-y-auto">
{vendors.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">Keine Vendors vorhanden</p>
) : (
<div className="space-y-2">
{vendors.map((vendor) => (
<label key={vendor.id} className="flex items-center gap-3 p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={selectedVendors.includes(vendor.id)}
onChange={() => onToggleVendor(vendor.id)}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">{vendor.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{vendor.country} · {vendor.serviceCategory}</p>
</div>
<RiskBadge score={vendor.inherentRiskScore} />
</label>
))}
</div>
)}
</div>
</div>
)
}
return null
}

View File

@@ -0,0 +1,27 @@
export function StatCard({
label,
value,
subtext,
color,
}: {
label: string
value: number
subtext: string
color: 'blue' | 'purple' | 'green' | 'yellow' | 'red'
}) {
const colors = {
blue: 'bg-blue-50 dark:bg-blue-900/20',
purple: 'bg-purple-50 dark:bg-purple-900/20',
green: 'bg-green-50 dark:bg-green-900/20',
yellow: 'bg-yellow-50 dark:bg-yellow-900/20',
red: 'bg-red-50 dark:bg-red-900/20',
}
return (
<div className={`${colors[color]} rounded-lg p-4`}>
<p className="text-sm text-gray-500 dark:text-gray-400">{label}</p>
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">{value}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{subtext}</p>
</div>
)
}

View File

@@ -0,0 +1,18 @@
export function StatusBadge({ status }: { status: string }) {
const statusStyles: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
REVIEW: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
APPROVED: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
ARCHIVED: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
}
return (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
statusStyles[status] || statusStyles.DRAFT
}`}
>
{status}
</span>
)
}

View File

@@ -0,0 +1,115 @@
'use client'
import { useState, useMemo } from 'react'
import { useVendorCompliance, ReportType, ExportFormat } from '@/lib/sdk/vendor-compliance'
import { REPORT_TYPE_META } from '../_components/ReportTypeGrid'
import { GeneratedReport } from '../_components/RecentReports'
export function useReportExport() {
const { processingActivities, vendors, contracts, findings, riskAssessments, isLoading } = useVendorCompliance()
const [selectedReportType, setSelectedReportType] = useState<ReportType>('VVT_EXPORT')
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('DOCX')
const [selectedVendors, setSelectedVendors] = useState<string[]>([])
const [selectedActivities, setSelectedActivities] = useState<string[]>([])
const [includeFindings, setIncludeFindings] = useState(true)
const [includeControls, setIncludeControls] = useState(true)
const [includeRiskAssessment, setIncludeRiskAssessment] = useState(true)
const [isGenerating, setIsGenerating] = useState(false)
const [generatedReports, setGeneratedReports] = useState<GeneratedReport[]>([])
const handleReportTypeChange = (type: ReportType) => {
setSelectedReportType(type)
setSelectedFormat(REPORT_TYPE_META[type].defaultFormat)
setSelectedVendors([])
setSelectedActivities([])
}
const stats = useMemo(() => {
const openFindings = findings.filter((f) => f.status === 'OPEN').length
const criticalFindings = findings.filter((f) => f.status === 'OPEN' && f.severity === 'CRITICAL').length
const highRiskVendors = vendors.filter((v) => v.inherentRiskScore >= 70).length
return {
totalActivities: processingActivities.length,
approvedActivities: processingActivities.filter((a) => a.status === 'APPROVED').length,
totalVendors: vendors.length,
activeVendors: vendors.filter((v) => v.status === 'ACTIVE').length,
totalContracts: contracts.length,
openFindings,
criticalFindings,
highRiskVendors,
}
}, [processingActivities, vendors, contracts, findings])
const handleExport = async () => {
setIsGenerating(true)
try {
const config = {
reportType: selectedReportType,
format: selectedFormat,
scope: {
vendorIds: selectedVendors,
processingActivityIds: selectedActivities,
includeFindings,
includeControls,
includeRiskAssessment,
},
}
const response = await fetch('/api/sdk/v1/vendor-compliance/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
if (!response.ok) throw new Error('Export fehlgeschlagen')
const result = await response.json()
setGeneratedReports((prev) => [
{ id: result.id, type: selectedReportType, format: selectedFormat, generatedAt: new Date(), filename: result.filename },
...prev,
])
if (result.downloadUrl) window.open(result.downloadUrl, '_blank')
} catch (error) {
console.error('Export error:', error)
} finally {
setIsGenerating(false)
}
}
const toggleVendor = (vendorId: string) =>
setSelectedVendors((prev) =>
prev.includes(vendorId) ? prev.filter((id) => id !== vendorId) : [...prev, vendorId]
)
const toggleActivity = (activityId: string) =>
setSelectedActivities((prev) =>
prev.includes(activityId) ? prev.filter((id) => id !== activityId) : [...prev, activityId]
)
const selectAllVendors = () => setSelectedVendors(vendors.map((v) => v.id))
const selectAllActivities = () => setSelectedActivities(processingActivities.map((a) => a.id))
return {
processingActivities,
vendors,
isLoading,
selectedReportType,
selectedFormat,
setSelectedFormat,
selectedVendors,
selectedActivities,
includeFindings,
setIncludeFindings,
includeControls,
setIncludeControls,
includeRiskAssessment,
setIncludeRiskAssessment,
isGenerating,
generatedReports,
stats,
handleReportTypeChange,
handleExport,
toggleVendor,
toggleActivity,
selectAllVendors,
selectAllActivities,
}
}

View File

@@ -1,223 +1,40 @@
'use client'
import { useState, useMemo } from 'react'
import {
useVendorCompliance,
ReportType,
ExportFormat,
ProcessingActivity,
Vendor,
} from '@/lib/sdk/vendor-compliance'
interface ExportConfig {
reportType: ReportType
format: ExportFormat
scope: {
vendorIds: string[]
processingActivityIds: string[]
includeFindings: boolean
includeControls: boolean
includeRiskAssessment: boolean
dateRange?: {
from: string
to: string
}
}
}
const REPORT_TYPE_META: Record<
ReportType,
{
title: string
description: string
icon: string
formats: ExportFormat[]
defaultFormat: ExportFormat
}
> = {
VVT_EXPORT: {
title: 'Verarbeitungsverzeichnis (VVT)',
description:
'Vollständiges Verarbeitungsverzeichnis gemäß Art. 30 DSGVO mit allen Pflichtangaben',
icon: '📋',
formats: ['PDF', 'DOCX', 'XLSX'],
defaultFormat: 'DOCX',
},
ROPA: {
title: 'Records of Processing Activities (RoPA)',
description:
'Processor-Perspektive: Alle Verarbeitungen als Auftragsverarbeiter',
icon: '📝',
formats: ['PDF', 'DOCX', 'XLSX'],
defaultFormat: 'DOCX',
},
VENDOR_AUDIT: {
title: 'Vendor Audit Pack',
description:
'Vollständige Dokumentation eines Vendors inkl. Verträge, Findings und Risikobewertung',
icon: '🔍',
formats: ['PDF', 'DOCX'],
defaultFormat: 'PDF',
},
MANAGEMENT_SUMMARY: {
title: 'Management Summary',
description:
'Übersicht für die Geschäftsführung: Risiken, offene Findings, Compliance-Status',
icon: '📊',
formats: ['PDF', 'DOCX', 'XLSX'],
defaultFormat: 'PDF',
},
DPIA_INPUT: {
title: 'DSFA-Input',
description:
'Vorbereitete Daten für eine Datenschutz-Folgenabschätzung (DSFA/DPIA)',
icon: '⚠️',
formats: ['PDF', 'DOCX'],
defaultFormat: 'DOCX',
},
}
const FORMAT_META: Record<ExportFormat, { label: string; icon: string }> = {
PDF: { label: 'PDF', icon: '📄' },
DOCX: { label: 'Word (DOCX)', icon: '📝' },
XLSX: { label: 'Excel (XLSX)', icon: '📊' },
JSON: { label: 'JSON', icon: '🔧' },
}
import { StatCard } from './_components/StatCard'
import { ReportTypeGrid } from './_components/ReportTypeGrid'
import { ScopePanel } from './_components/ScopePanel'
import { IncludeOptions } from './_components/IncludeOptions'
import { ExportPanel } from './_components/ExportPanel'
import { RecentReports } from './_components/RecentReports'
import { HelpPanel } from './_components/HelpPanel'
import { useReportExport } from './_hooks/useReportExport'
export default function ReportsPage() {
const {
processingActivities,
vendors,
contracts,
findings,
riskAssessments,
isLoading,
} = useVendorCompliance()
const [selectedReportType, setSelectedReportType] = useState<ReportType>('VVT_EXPORT')
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('DOCX')
const [selectedVendors, setSelectedVendors] = useState<string[]>([])
const [selectedActivities, setSelectedActivities] = useState<string[]>([])
const [includeFindings, setIncludeFindings] = useState(true)
const [includeControls, setIncludeControls] = useState(true)
const [includeRiskAssessment, setIncludeRiskAssessment] = useState(true)
const [isGenerating, setIsGenerating] = useState(false)
const [generatedReports, setGeneratedReports] = useState<
{ id: string; type: ReportType; format: ExportFormat; generatedAt: Date; filename: string }[]
>([])
const reportMeta = REPORT_TYPE_META[selectedReportType]
// Update format when report type changes
const handleReportTypeChange = (type: ReportType) => {
setSelectedReportType(type)
setSelectedFormat(REPORT_TYPE_META[type].defaultFormat)
// Reset selections
setSelectedVendors([])
setSelectedActivities([])
}
// Calculate statistics
const stats = useMemo(() => {
const openFindings = findings.filter((f) => f.status === 'OPEN').length
const criticalFindings = findings.filter(
(f) => f.status === 'OPEN' && f.severity === 'CRITICAL'
).length
const highRiskVendors = vendors.filter((v) => v.inherentRiskScore >= 70).length
return {
totalActivities: processingActivities.length,
approvedActivities: processingActivities.filter((a) => a.status === 'APPROVED').length,
totalVendors: vendors.length,
activeVendors: vendors.filter((v) => v.status === 'ACTIVE').length,
totalContracts: contracts.length,
openFindings,
criticalFindings,
highRiskVendors,
}
}, [processingActivities, vendors, contracts, findings])
// Handle export
const handleExport = async () => {
setIsGenerating(true)
try {
const config: ExportConfig = {
reportType: selectedReportType,
format: selectedFormat,
scope: {
vendorIds: selectedVendors,
processingActivityIds: selectedActivities,
includeFindings,
includeControls,
includeRiskAssessment,
},
}
// Call API to generate report
const response = await fetch('/api/sdk/v1/vendor-compliance/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
if (!response.ok) {
throw new Error('Export fehlgeschlagen')
}
const result = await response.json()
// Add to generated reports
setGeneratedReports((prev) => [
{
id: result.id,
type: selectedReportType,
format: selectedFormat,
generatedAt: new Date(),
filename: result.filename,
},
...prev,
])
// Download the file
if (result.downloadUrl) {
window.open(result.downloadUrl, '_blank')
}
} catch (error) {
console.error('Export error:', error)
// Show error notification
} finally {
setIsGenerating(false)
}
}
// Toggle vendor selection
const toggleVendor = (vendorId: string) => {
setSelectedVendors((prev) =>
prev.includes(vendorId)
? prev.filter((id) => id !== vendorId)
: [...prev, vendorId]
)
}
// Toggle activity selection
const toggleActivity = (activityId: string) => {
setSelectedActivities((prev) =>
prev.includes(activityId)
? prev.filter((id) => id !== activityId)
: [...prev, activityId]
)
}
// Select all vendors
const selectAllVendors = () => {
setSelectedVendors(vendors.map((v) => v.id))
}
// Select all activities
const selectAllActivities = () => {
setSelectedActivities(processingActivities.map((a) => a.id))
}
selectedReportType,
selectedFormat,
setSelectedFormat,
selectedVendors,
selectedActivities,
includeFindings,
setIncludeFindings,
includeControls,
setIncludeControls,
includeRiskAssessment,
setIncludeRiskAssessment,
isGenerating,
generatedReports,
stats,
handleReportTypeChange,
handleExport,
toggleVendor,
toggleActivity,
selectAllVendors,
selectAllActivities,
} = useReportExport()
if (isLoading) {
return (
@@ -229,505 +46,56 @@ export default function ReportsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Reports & Export
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Berichte erstellen und Daten exportieren
</p>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Reports & Export</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Berichte erstellen und Daten exportieren</p>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
label="Verarbeitungen"
value={stats.totalActivities}
subtext={`${stats.approvedActivities} freigegeben`}
color="blue"
/>
<StatCard
label="Vendors"
value={stats.totalVendors}
subtext={`${stats.highRiskVendors} hohes Risiko`}
color="purple"
/>
<StatCard
label="Offene Findings"
value={stats.openFindings}
subtext={`${stats.criticalFindings} kritisch`}
color={stats.criticalFindings > 0 ? 'red' : 'yellow'}
/>
<StatCard
label="Verträge"
value={stats.totalContracts}
subtext="dokumentiert"
color="green"
/>
<StatCard label="Verarbeitungen" value={stats.totalActivities} subtext={`${stats.approvedActivities} freigegeben`} color="blue" />
<StatCard label="Vendors" value={stats.totalVendors} subtext={`${stats.highRiskVendors} hohes Risiko`} color="purple" />
<StatCard label="Offene Findings" value={stats.openFindings} subtext={`${stats.criticalFindings} kritisch`} color={stats.criticalFindings > 0 ? 'red' : 'yellow'} />
<StatCard label="Verträge" value={stats.totalContracts} subtext="dokumentiert" color="green" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Report Type Selection */}
<div className="lg:col-span-2 space-y-6">
{/* Report Type Cards */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Report-Typ wählen
</h2>
</div>
<div className="p-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
{(Object.entries(REPORT_TYPE_META) as [ReportType, typeof REPORT_TYPE_META[ReportType]][]).map(
([type, meta]) => (
<button
key={type}
onClick={() => handleReportTypeChange(type)}
className={`p-4 rounded-lg border-2 text-left transition-all ${
selectedReportType === type
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className="flex items-center gap-3">
<span className="text-2xl">{meta.icon}</span>
<div>
<h3 className="font-medium text-gray-900 dark:text-white">
{meta.title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{meta.description}
</p>
</div>
</div>
</button>
)
)}
</div>
</div>
{/* Scope Selection */}
{(selectedReportType === 'VVT_EXPORT' || selectedReportType === 'ROPA' || selectedReportType === 'DPIA_INPUT') && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Verarbeitungen auswählen
</h2>
<button
onClick={selectAllActivities}
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
Alle auswählen
</button>
</div>
<div className="p-4 max-h-64 overflow-y-auto">
{processingActivities.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
Keine Verarbeitungen vorhanden
</p>
) : (
<div className="space-y-2">
{processingActivities.map((activity) => (
<label
key={activity.id}
className="flex items-center gap-3 p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedActivities.includes(activity.id)}
onChange={() => toggleActivity(activity.id)}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{activity.name.de}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{activity.vvtId} · {activity.status}
</p>
</div>
<StatusBadge status={activity.status} />
</label>
))}
</div>
)}
</div>
</div>
)}
{selectedReportType === 'VENDOR_AUDIT' && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Vendor auswählen
</h2>
<button
onClick={selectAllVendors}
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
Alle auswählen
</button>
</div>
<div className="p-4 max-h-64 overflow-y-auto">
{vendors.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
Keine Vendors vorhanden
</p>
) : (
<div className="space-y-2">
{vendors.map((vendor) => (
<label
key={vendor.id}
className="flex items-center gap-3 p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedVendors.includes(vendor.id)}
onChange={() => toggleVendor(vendor.id)}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{vendor.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{vendor.country} · {vendor.serviceCategory}
</p>
</div>
<RiskBadge score={vendor.inherentRiskScore} />
</label>
))}
</div>
)}
</div>
</div>
)}
{/* Include Options */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Optionen
</h2>
</div>
<div className="p-4 space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={includeFindings}
onChange={(e) => setIncludeFindings(e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
Findings einbeziehen
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Offene und behobene Vertragsprüfungs-Findings
</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={includeControls}
onChange={(e) => setIncludeControls(e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
Control-Status einbeziehen
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Übersicht aller Kontrollen und deren Erfüllungsstatus
</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={includeRiskAssessment}
onChange={(e) => setIncludeRiskAssessment(e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
Risikobewertung einbeziehen
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Inhärentes und Restrisiko mit Begründung
</p>
</div>
</label>
</div>
</div>
<ReportTypeGrid selectedReportType={selectedReportType} onSelect={handleReportTypeChange} />
<ScopePanel
selectedReportType={selectedReportType}
processingActivities={processingActivities}
vendors={vendors}
selectedVendors={selectedVendors}
selectedActivities={selectedActivities}
onToggleVendor={toggleVendor}
onToggleActivity={toggleActivity}
onSelectAllVendors={selectAllVendors}
onSelectAllActivities={selectAllActivities}
/>
<IncludeOptions
includeFindings={includeFindings}
includeControls={includeControls}
includeRiskAssessment={includeRiskAssessment}
onFindingsChange={setIncludeFindings}
onControlsChange={setIncludeControls}
onRiskAssessmentChange={setIncludeRiskAssessment}
/>
</div>
{/* Export Panel */}
<div className="space-y-6">
{/* Format & Export */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Export
</h2>
</div>
<div className="p-4 space-y-4">
{/* Selected Report Info */}
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">{reportMeta.icon}</span>
<span className="font-medium text-gray-900 dark:text-white">
{reportMeta.title}
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{reportMeta.description}
</p>
</div>
{/* Format Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Format
</label>
<div className="flex flex-wrap gap-2">
{reportMeta.formats.map((format) => (
<button
key={format}
onClick={() => setSelectedFormat(format)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedFormat === format
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{FORMAT_META[format].icon} {FORMAT_META[format].label}
</button>
))}
</div>
</div>
{/* Scope Summary */}
<div className="text-sm text-gray-500 dark:text-gray-400">
<p>
{selectedReportType === 'VENDOR_AUDIT'
? `${selectedVendors.length || 'Alle'} Vendor(s) ausgewählt`
: selectedReportType === 'MANAGEMENT_SUMMARY'
? 'Gesamtübersicht'
: `${selectedActivities.length || 'Alle'} Verarbeitung(en) ausgewählt`}
</p>
</div>
{/* Export Button */}
<button
onClick={handleExport}
disabled={isGenerating}
className={`w-full py-3 px-4 rounded-lg font-medium text-white transition-colors ${
isGenerating
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{isGenerating ? (
<span className="flex items-center justify-center gap-2">
<svg
className="animate-spin h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
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 generiert...
</span>
) : (
`${reportMeta.title} exportieren`
)}
</button>
</div>
</div>
{/* Recent Reports */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Letzte Reports
</h2>
</div>
<div className="p-4">
{generatedReports.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
Noch keine Reports generiert
</p>
) : (
<div className="space-y-3">
{generatedReports.slice(0, 5).map((report) => (
<div
key={report.id}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
>
<div className="flex items-center gap-3">
<span className="text-lg">
{REPORT_TYPE_META[report.type].icon}
</span>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{report.filename}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{report.generatedAt.toLocaleString('de-DE')}
</p>
</div>
</div>
<button className="text-blue-600 hover:text-blue-800 dark:text-blue-400">
<svg
className="h-5 w-5"
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>
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* Help / Templates */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Hilfe
</h2>
</div>
<div className="p-4 space-y-3 text-sm">
<div className="flex gap-2">
<span>📋</span>
<div>
<p className="font-medium text-gray-900 dark:text-white">
VVT Export
</p>
<p className="text-gray-500 dark:text-gray-400">
Art. 30 DSGVO konformes Verzeichnis aller
Verarbeitungstätigkeiten
</p>
</div>
</div>
<div className="flex gap-2">
<span>🔍</span>
<div>
<p className="font-medium text-gray-900 dark:text-white">
Vendor Audit
</p>
<p className="text-gray-500 dark:text-gray-400">
Komplette Dokumentation für Due Diligence und Audits
</p>
</div>
</div>
<div className="flex gap-2">
<span>📊</span>
<div>
<p className="font-medium text-gray-900 dark:text-white">
Management Summary
</p>
<p className="text-gray-500 dark:text-gray-400">
Übersicht für Geschäftsführung und DSB
</p>
</div>
</div>
</div>
</div>
<ExportPanel
selectedReportType={selectedReportType}
selectedFormat={selectedFormat}
selectedVendors={selectedVendors}
selectedActivities={selectedActivities}
isGenerating={isGenerating}
onFormatChange={setSelectedFormat}
onExport={handleExport}
/>
<RecentReports reports={generatedReports} />
<HelpPanel />
</div>
</div>
</div>
)
}
// Helper Components
function StatCard({
label,
value,
subtext,
color,
}: {
label: string
value: number
subtext: string
color: 'blue' | 'purple' | 'green' | 'yellow' | 'red'
}) {
const colors = {
blue: 'bg-blue-50 dark:bg-blue-900/20',
purple: 'bg-purple-50 dark:bg-purple-900/20',
green: 'bg-green-50 dark:bg-green-900/20',
yellow: 'bg-yellow-50 dark:bg-yellow-900/20',
red: 'bg-red-50 dark:bg-red-900/20',
}
return (
<div className={`${colors[color]} rounded-lg p-4`}>
<p className="text-sm text-gray-500 dark:text-gray-400">{label}</p>
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{value}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{subtext}</p>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const statusStyles: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
REVIEW: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
APPROVED: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
ARCHIVED: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
}
return (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
statusStyles[status] || statusStyles.DRAFT
}`}
>
{status}
</span>
)
}
function RiskBadge({ score }: { score: number }) {
let colorClass = 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
if (score >= 70) {
colorClass = 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
} else if (score >= 50) {
colorClass = 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
}
return (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colorClass}`}
>
{score}
</span>
)
}