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:
134
admin-compliance/app/sdk/evidence/_components/EvidenceCard.tsx
Normal file
134
admin-compliance/app/sdk/evidence/_components/EvidenceCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
143
admin-compliance/app/sdk/evidence/_components/EvidenceTypes.ts
Normal file
143
admin-compliance/app/sdk/evidence/_components/EvidenceTypes.ts
Normal 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',
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
228
admin-compliance/app/sdk/evidence/_hooks/useEvidence.ts
Normal file
228
admin-compliance/app/sdk/evidence/_hooks/useEvidence.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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' :
|
||||
|
||||
42
admin-compliance/app/sdk/gci/_components/AuditTab.tsx
Normal file
42
admin-compliance/app/sdk/gci/_components/AuditTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
admin-compliance/app/sdk/gci/_components/BreakdownTab.tsx
Normal file
75
admin-compliance/app/sdk/gci/_components/BreakdownTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
admin-compliance/app/sdk/gci/_components/GCIHelpers.tsx
Normal file
116
admin-compliance/app/sdk/gci/_components/GCIHelpers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
admin-compliance/app/sdk/gci/_components/ISOTab.tsx
Normal file
76
admin-compliance/app/sdk/gci/_components/ISOTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
admin-compliance/app/sdk/gci/_components/MatrixTab.tsx
Normal file
54
admin-compliance/app/sdk/gci/_components/MatrixTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
admin-compliance/app/sdk/gci/_components/NIS2Tab.tsx
Normal file
56
admin-compliance/app/sdk/gci/_components/NIS2Tab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
admin-compliance/app/sdk/gci/_components/OverviewTab.tsx
Normal file
91
admin-compliance/app/sdk/gci/_components/OverviewTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
admin-compliance/app/sdk/gci/_hooks/useGCI.ts
Normal file
97
admin-compliance/app/sdk/gci/_hooks/useGCI.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
112
admin-compliance/app/sdk/import/_components/FileItem.tsx
Normal file
112
admin-compliance/app/sdk/import/_components/FileItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
100
admin-compliance/app/sdk/import/_components/UploadZone.tsx
Normal file
100
admin-compliance/app/sdk/import/_components/UploadZone.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
184
admin-compliance/app/sdk/import/_hooks/useImport.ts
Normal file
184
admin-compliance/app/sdk/import/_hooks/useImport.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
152
admin-compliance/app/sdk/risks/_components/RiskCard.tsx
Normal file
152
admin-compliance/app/sdk/risks/_components/RiskCard.tsx
Normal 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} → {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>
|
||||
)
|
||||
}
|
||||
167
admin-compliance/app/sdk/risks/_components/RiskForm.tsx
Normal file
167
admin-compliance/app/sdk/risks/_components/RiskForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
admin-compliance/app/sdk/risks/_components/RiskMatrix.tsx
Normal file
87
admin-compliance/app/sdk/risks/_components/RiskMatrix.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
184
admin-compliance/app/sdk/risks/_hooks/useRisks.ts
Normal file
184
admin-compliance/app/sdk/risks/_hooks/useRisks.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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} → {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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user