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'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React from 'react'
|
||||||
import { useSDK, Evidence as SDKEvidence, EvidenceType } from '@/lib/sdk'
|
|
||||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||||
|
import { EvidenceCard } from './_components/EvidenceCard'
|
||||||
// =============================================================================
|
import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
||||||
// TYPES
|
import { useEvidence } from './_hooks/useEvidence'
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
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
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export default function EvidencePage() {
|
export default function EvidencePage() {
|
||||||
const { state, dispatch } = useSDK()
|
const {
|
||||||
const [filter, setFilter] = useState<string>('all')
|
state,
|
||||||
const [loading, setLoading] = useState(true)
|
filter,
|
||||||
const [error, setError] = useState<string | null>(null)
|
setFilter,
|
||||||
const [uploading, setUploading] = useState(false)
|
loading,
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
error,
|
||||||
const [page, setPage] = useState(1)
|
setError,
|
||||||
const [pageSize] = useState(20)
|
uploading,
|
||||||
const [total, setTotal] = useState(0)
|
fileInputRef,
|
||||||
|
page,
|
||||||
// Fetch evidence from backend on mount and when page changes
|
setPage,
|
||||||
useEffect(() => {
|
pageSize,
|
||||||
const fetchEvidence = async () => {
|
total,
|
||||||
try {
|
displayEvidence,
|
||||||
setLoading(true)
|
filteredEvidence,
|
||||||
const res = await fetch(`/api/sdk/v1/compliance/evidence?page=${page}&limit=${pageSize}`)
|
validCount,
|
||||||
if (res.ok) {
|
expiredCount,
|
||||||
const data = await res.json()
|
pendingCount,
|
||||||
if (data.total !== undefined) setTotal(data.total)
|
handleDelete,
|
||||||
const backendEvidence = data.evidence || data
|
handleView,
|
||||||
if (Array.isArray(backendEvidence) && backendEvidence.length > 0) {
|
handleDownload,
|
||||||
const mapped: SDKEvidence[] = backendEvidence.map((e: Record<string, unknown>) => ({
|
handleUploadClick,
|
||||||
id: (e.id || '') as string,
|
handleFileChange,
|
||||||
controlId: (e.control_id || '') as string,
|
} = useEvidence()
|
||||||
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 stepInfo = STEP_EXPLANATIONS['evidence']
|
const stepInfo = STEP_EXPLANATIONS['evidence']
|
||||||
|
|
||||||
@@ -609,9 +130,7 @@ export default function EvidencePage() {
|
|||||||
key={f}
|
key={f}
|
||||||
onClick={() => setFilter(f)}
|
onClick={() => setFilter(f)}
|
||||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||||
filter === f
|
filter === f ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
? 'bg-purple-600 text-white'
|
|
||||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{f === 'all' ? 'Alle' :
|
{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'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import { TABS, TabNavigation, LoadingSpinner, ErrorMessage } from './_components/GCIHelpers'
|
||||||
import {
|
import { OverviewTab } from './_components/OverviewTab'
|
||||||
GCIResult,
|
import { BreakdownTab } from './_components/BreakdownTab'
|
||||||
GCIBreakdown,
|
import { NIS2Tab } from './_components/NIS2Tab'
|
||||||
GCIHistoryResponse,
|
import { ISOTab } from './_components/ISOTab'
|
||||||
GCIMatrixResponse,
|
import { MatrixTab } from './_components/MatrixTab'
|
||||||
NIS2Score,
|
import { AuditTab } from './_components/AuditTab'
|
||||||
ISOGapAnalysis,
|
import { useGCI } from './_hooks/useGCI'
|
||||||
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
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export default function GCIPage() {
|
export default function GCIPage() {
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
const {
|
||||||
const [loading, setLoading] = useState(true)
|
activeTab,
|
||||||
const [error, setError] = useState<string | null>(null)
|
setActiveTab,
|
||||||
|
loading,
|
||||||
const [gci, setGCI] = useState<GCIResult | null>(null)
|
error,
|
||||||
const [breakdown, setBreakdown] = useState<GCIBreakdown | null>(null)
|
gci,
|
||||||
const [history, setHistory] = useState<GCIHistoryResponse | null>(null)
|
breakdown,
|
||||||
const [matrix, setMatrix] = useState<GCIMatrixResponse | null>(null)
|
history,
|
||||||
const [nis2, setNIS2] = useState<NIS2Score | null>(null)
|
matrix,
|
||||||
const [iso, setISO] = useState<ISOGapAnalysis | null>(null)
|
nis2,
|
||||||
const [profiles, setProfiles] = useState<WeightProfile[]>([])
|
iso,
|
||||||
const [selectedProfile, setSelectedProfile] = useState('default')
|
profiles,
|
||||||
|
selectedProfile,
|
||||||
const loadData = useCallback(async (profile?: string) => {
|
loadData,
|
||||||
setLoading(true)
|
handleProfileChange,
|
||||||
setError(null)
|
} = useGCI()
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Gesamt-Compliance-Index (GCI)</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Gesamt-Compliance-Index (GCI)</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">4-stufiges, mathematisch fundiertes Compliance-Scoring</p>
|
||||||
4-stufiges, mathematisch fundiertes Compliance-Scoring
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => loadData(selectedProfile)}
|
onClick={() => loadData(selectedProfile)}
|
||||||
@@ -660,10 +43,8 @@ export default function GCIPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<TabNavigation tabs={TABS} activeTab={activeTab} onTabChange={setActiveTab} />
|
<TabNavigation tabs={TABS} activeTab={activeTab} onTabChange={setActiveTab} />
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{error && <ErrorMessage message={error} onRetry={() => loadData(selectedProfile)} />}
|
{error && <ErrorMessage message={error} onRetry={() => loadData(selectedProfile)} />}
|
||||||
|
|
||||||
{loading && !gci ? (
|
{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'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react'
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useSDK } from '@/lib/sdk'
|
import { UploadZone } from './_components/UploadZone'
|
||||||
import type { ImportedDocument, ImportedDocumentType, GapAnalysis, GapItem } from '@/lib/sdk/types'
|
import { FileItem } from './_components/FileItem'
|
||||||
|
import { GapAnalysisPreview } from './_components/GapAnalysisPreview'
|
||||||
// =============================================================================
|
import { ImportHistory } from './_components/ImportHistory'
|
||||||
// DOCUMENT TYPE OPTIONS
|
import { useImport } from './_hooks/useImport'
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
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
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export default function ImportPage() {
|
export default function ImportPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { state, addImportedDocument, setGapAnalysis, dispatch } = useSDK()
|
const {
|
||||||
const [files, setFiles] = useState<UploadedFile[]>([])
|
state,
|
||||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
files,
|
||||||
const [analysisResult, setAnalysisResult] = useState<GapAnalysis | null>(null)
|
setFiles,
|
||||||
const [importHistory, setImportHistory] = useState<any[]>([])
|
isAnalyzing,
|
||||||
const [historyLoading, setHistoryLoading] = useState(false)
|
analysisResult,
|
||||||
const [objectUrls, setObjectUrls] = useState<string[]>([])
|
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') {
|
if (state.customerType === 'new') {
|
||||||
router.push('/sdk')
|
router.push('/sdk')
|
||||||
return null
|
return null
|
||||||
@@ -540,22 +48,14 @@ export default function ImportPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="font-semibold text-gray-900">{files.length} Dokument(e)</h2>
|
<h2 className="font-semibold text-gray-900">{files.length} Dokument(e)</h2>
|
||||||
{!isAnalyzing && !analysisResult && (
|
{!isAnalyzing && !analysisResult && (
|
||||||
<button
|
<button onClick={() => setFiles([])} className="text-sm text-gray-500 hover:text-red-500">
|
||||||
onClick={() => setFiles([])}
|
|
||||||
className="text-sm text-gray-500 hover:text-red-500"
|
|
||||||
>
|
|
||||||
Alle entfernen
|
Alle entfernen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{files.map(file => (
|
{files.map(file => (
|
||||||
<FileItem
|
<FileItem key={file.id} file={file} onTypeChange={handleTypeChange} onRemove={handleRemove} />
|
||||||
key={file.id}
|
|
||||||
file={file}
|
|
||||||
onTypeChange={handleTypeChange}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -599,7 +99,7 @@ export default function ImportPage() {
|
|||||||
Die Gap-Analyse wurde gespeichert. Sie koennen jetzt mit dem Compliance-Assessment fortfahren.
|
Die Gap-Analyse wurde gespeichert. Sie koennen jetzt mit dem Compliance-Assessment fortfahren.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<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"
|
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
|
Weiter zum Screening
|
||||||
@@ -610,55 +110,12 @@ export default function ImportPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Import-Verlauf (4.1) */}
|
{/* Import History */}
|
||||||
{importHistory.length > 0 && (
|
<ImportHistory
|
||||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
importHistory={importHistory}
|
||||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
historyLoading={historyLoading}
|
||||||
<h3 className="font-semibold text-gray-900">Import-Verlauf</h3>
|
onDelete={handleDeleteHistory}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</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'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Portfolio, api, statusLabels } from './_components/PortfolioTypes'
|
||||||
// =============================================================================
|
import { PortfolioCard } from './_components/PortfolioCard'
|
||||||
// TYPES
|
import { CreatePortfolioModal } from './_components/CreatePortfolioModal'
|
||||||
// =============================================================================
|
import { PortfolioDetailView } from './_components/PortfolioDetailView'
|
||||||
|
|
||||||
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
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export default function PortfolioPage() {
|
export default function PortfolioPage() {
|
||||||
const [portfolios, setPortfolios] = useState<Portfolio[]>([])
|
const [portfolios, setPortfolios] = useState<Portfolio[]>([])
|
||||||
@@ -584,12 +64,12 @@ export default function PortfolioPage() {
|
|||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">KI-Portfolios</h1>
|
<h1 className="text-2xl font-bold text-gray-900">KI-Portfolios</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">Verwaltung und Vergleich von Compliance-Portfolios</p>
|
||||||
Verwaltung und Vergleich von Compliance-Portfolios
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowCreate(true)}
|
<button
|
||||||
className="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 flex items-center gap-2">
|
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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -628,7 +108,8 @@ export default function PortfolioPage() {
|
|||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="text-gray-400 mb-2">
|
<div className="text-gray-400 mb-2">
|
||||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-500">Keine Portfolios gefunden</p>
|
<p className="text-gray-500">Keine Portfolios gefunden</p>
|
||||||
@@ -645,7 +126,10 @@ export default function PortfolioPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<CreatePortfolioModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); loadPortfolios() }} />
|
<CreatePortfolioModal
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onCreated={() => { setShowCreate(false); loadPortfolios() }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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'
|
'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'
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||||
|
import { RiskMatrix } from './_components/RiskMatrix'
|
||||||
// =============================================================================
|
import { RiskForm } from './_components/RiskForm'
|
||||||
// RISK MATRIX
|
import { RiskCard } from './_components/RiskCard'
|
||||||
// =============================================================================
|
import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
||||||
|
import { useRisks } from './_hooks/useRisks'
|
||||||
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
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export default function RisksPage() {
|
export default function RisksPage() {
|
||||||
const { state, dispatch, addRisk } = useSDK()
|
const {
|
||||||
const [showForm, setShowForm] = useState(false)
|
state,
|
||||||
const [editingRisk, setEditingRisk] = useState<Risk | null>(null)
|
showForm,
|
||||||
const [loading, setLoading] = useState(true)
|
setShowForm,
|
||||||
const [error, setError] = useState<string | null>(null)
|
editingRisk,
|
||||||
const [matrixFilter, setMatrixFilter] = useState<{ likelihood: number; impact: number } | null>(null)
|
setEditingRisk,
|
||||||
|
loading,
|
||||||
// Fetch risks from backend on mount
|
error,
|
||||||
useEffect(() => {
|
setError,
|
||||||
const fetchRisks = async () => {
|
matrixFilter,
|
||||||
try {
|
setMatrixFilter,
|
||||||
setLoading(true)
|
handleSubmit,
|
||||||
const res = await fetch('/api/sdk/v1/compliance/risks')
|
handleDelete,
|
||||||
if (res.ok) {
|
handleStatusChange,
|
||||||
const data = await res.json()
|
handleEdit,
|
||||||
const backendRisks = data.risks || data
|
handleMatrixCellClick,
|
||||||
if (Array.isArray(backendRisks) && backendRisks.length > 0) {
|
stats,
|
||||||
const mapped: Risk[] = backendRisks.map((r: Record<string, unknown>) => ({
|
filteredRisks,
|
||||||
id: (r.risk_id || r.id || '') as string,
|
} = useRisks()
|
||||||
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 stepInfo = STEP_EXPLANATIONS['risks']
|
const stepInfo = STEP_EXPLANATIONS['risks']
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Step Header */}
|
|
||||||
<StepHeader
|
<StepHeader
|
||||||
stepId="risks"
|
stepId="risks"
|
||||||
title={stepInfo.title}
|
title={stepInfo.title}
|
||||||
@@ -627,7 +52,6 @@ export default function RisksPage() {
|
|||||||
)}
|
)}
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
{/* Error Banner */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
@@ -635,56 +59,39 @@ export default function RisksPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<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="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<div className="text-sm text-gray-500">Gesamt</div>
|
<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>
|
||||||
<div className="bg-white rounded-xl border border-red-200 p-6">
|
<div className="bg-white rounded-xl border border-red-200 p-6">
|
||||||
<div className="text-sm text-red-600">Kritisch</div>
|
<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>
|
||||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||||
<div className="text-sm text-orange-600">Hoch</div>
|
<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>
|
||||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<RiskForm
|
<RiskForm
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => {
|
onCancel={() => { setShowForm(false); setEditingRisk(null) }}
|
||||||
setShowForm(false)
|
|
||||||
setEditingRisk(null)
|
|
||||||
}}
|
|
||||||
initialData={editingRisk || undefined}
|
initialData={editingRisk || undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading */}
|
|
||||||
{loading && <LoadingSkeleton />}
|
{loading && <LoadingSkeleton />}
|
||||||
|
|
||||||
{/* Matrix */}
|
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<RiskMatrix
|
<RiskMatrix risks={state.risks} onCellClick={handleMatrixCellClick} />
|
||||||
risks={state.risks}
|
|
||||||
onCellClick={(l, i) => {
|
|
||||||
if (matrixFilter && matrixFilter.likelihood === l && matrixFilter.impact === i) {
|
|
||||||
setMatrixFilter(null)
|
|
||||||
} else {
|
|
||||||
setMatrixFilter({ likelihood: l, impact: i })
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Matrix Filter Badge */}
|
|
||||||
{matrixFilter && (
|
{matrixFilter && (
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Risk List */}
|
|
||||||
{!loading && state.risks.length > 0 && (
|
{!loading && state.risks.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
{matrixFilter ? `Risiken (L=${matrixFilter.likelihood}, I=${matrixFilter.impact})` : 'Alle Risiken'}
|
{matrixFilter ? `Risiken (L=${matrixFilter.likelihood}, I=${matrixFilter.impact})` : 'Alle Risiken'}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{state.risks
|
{filteredRisks.map(risk => (
|
||||||
.filter(risk => !matrixFilter || (risk.likelihood === matrixFilter.likelihood && risk.impact === matrixFilter.impact))
|
<RiskCard
|
||||||
.sort((a, b) => b.inherentRiskScore - a.inherentRiskScore)
|
key={risk.id}
|
||||||
.map(risk => (
|
risk={risk}
|
||||||
<RiskCard
|
onEdit={() => handleEdit(risk)}
|
||||||
key={risk.id}
|
onDelete={() => handleDelete(risk.id)}
|
||||||
risk={risk}
|
onStatusChange={(status) => handleStatusChange(risk.id, status)}
|
||||||
onEdit={() => handleEdit(risk)}
|
/>
|
||||||
onDelete={() => handleDelete(risk.id)}
|
))}
|
||||||
onStatusChange={(status) => handleStatusChange(risk.id, status)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty State */}
|
|
||||||
{!loading && state.risks.length === 0 && !showForm && (
|
{!loading && state.risks.length === 0 && !showForm && (
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
<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">
|
<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'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
import { StatCard } from './_components/StatCard'
|
||||||
import {
|
import { ReportTypeGrid } from './_components/ReportTypeGrid'
|
||||||
useVendorCompliance,
|
import { ScopePanel } from './_components/ScopePanel'
|
||||||
ReportType,
|
import { IncludeOptions } from './_components/IncludeOptions'
|
||||||
ExportFormat,
|
import { ExportPanel } from './_components/ExportPanel'
|
||||||
ProcessingActivity,
|
import { RecentReports } from './_components/RecentReports'
|
||||||
Vendor,
|
import { HelpPanel } from './_components/HelpPanel'
|
||||||
} from '@/lib/sdk/vendor-compliance'
|
import { useReportExport } from './_hooks/useReportExport'
|
||||||
|
|
||||||
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: '🔧' },
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const {
|
const {
|
||||||
processingActivities,
|
processingActivities,
|
||||||
vendors,
|
vendors,
|
||||||
contracts,
|
|
||||||
findings,
|
|
||||||
riskAssessments,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
} = useVendorCompliance()
|
selectedReportType,
|
||||||
|
selectedFormat,
|
||||||
const [selectedReportType, setSelectedReportType] = useState<ReportType>('VVT_EXPORT')
|
setSelectedFormat,
|
||||||
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('DOCX')
|
selectedVendors,
|
||||||
const [selectedVendors, setSelectedVendors] = useState<string[]>([])
|
selectedActivities,
|
||||||
const [selectedActivities, setSelectedActivities] = useState<string[]>([])
|
includeFindings,
|
||||||
const [includeFindings, setIncludeFindings] = useState(true)
|
setIncludeFindings,
|
||||||
const [includeControls, setIncludeControls] = useState(true)
|
includeControls,
|
||||||
const [includeRiskAssessment, setIncludeRiskAssessment] = useState(true)
|
setIncludeControls,
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
includeRiskAssessment,
|
||||||
const [generatedReports, setGeneratedReports] = useState<
|
setIncludeRiskAssessment,
|
||||||
{ id: string; type: ReportType; format: ExportFormat; generatedAt: Date; filename: string }[]
|
isGenerating,
|
||||||
>([])
|
generatedReports,
|
||||||
|
stats,
|
||||||
const reportMeta = REPORT_TYPE_META[selectedReportType]
|
handleReportTypeChange,
|
||||||
|
handleExport,
|
||||||
// Update format when report type changes
|
toggleVendor,
|
||||||
const handleReportTypeChange = (type: ReportType) => {
|
toggleActivity,
|
||||||
setSelectedReportType(type)
|
selectAllVendors,
|
||||||
setSelectedFormat(REPORT_TYPE_META[type].defaultFormat)
|
selectAllActivities,
|
||||||
// Reset selections
|
} = useReportExport()
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -229,505 +46,56 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Reports & Export</h1>
|
||||||
Reports & Export
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Berichte erstellen und Daten exportieren</p>
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Berichte erstellen und Daten exportieren
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<StatCard
|
<StatCard label="Verarbeitungen" value={stats.totalActivities} subtext={`${stats.approvedActivities} freigegeben`} color="blue" />
|
||||||
label="Verarbeitungen"
|
<StatCard label="Vendors" value={stats.totalVendors} subtext={`${stats.highRiskVendors} hohes Risiko`} color="purple" />
|
||||||
value={stats.totalActivities}
|
<StatCard label="Offene Findings" value={stats.openFindings} subtext={`${stats.criticalFindings} kritisch`} color={stats.criticalFindings > 0 ? 'red' : 'yellow'} />
|
||||||
subtext={`${stats.approvedActivities} freigegeben`}
|
<StatCard label="Verträge" value={stats.totalContracts} subtext="dokumentiert" color="green" />
|
||||||
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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Report Type Selection */}
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
{/* Report Type Cards */}
|
<ReportTypeGrid selectedReportType={selectedReportType} onSelect={handleReportTypeChange} />
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
<ScopePanel
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
selectedReportType={selectedReportType}
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
processingActivities={processingActivities}
|
||||||
Report-Typ wählen
|
vendors={vendors}
|
||||||
</h2>
|
selectedVendors={selectedVendors}
|
||||||
</div>
|
selectedActivities={selectedActivities}
|
||||||
<div className="p-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
onToggleVendor={toggleVendor}
|
||||||
{(Object.entries(REPORT_TYPE_META) as [ReportType, typeof REPORT_TYPE_META[ReportType]][]).map(
|
onToggleActivity={toggleActivity}
|
||||||
([type, meta]) => (
|
onSelectAllVendors={selectAllVendors}
|
||||||
<button
|
onSelectAllActivities={selectAllActivities}
|
||||||
key={type}
|
/>
|
||||||
onClick={() => handleReportTypeChange(type)}
|
<IncludeOptions
|
||||||
className={`p-4 rounded-lg border-2 text-left transition-all ${
|
includeFindings={includeFindings}
|
||||||
selectedReportType === type
|
includeControls={includeControls}
|
||||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
includeRiskAssessment={includeRiskAssessment}
|
||||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
onFindingsChange={setIncludeFindings}
|
||||||
}`}
|
onControlsChange={setIncludeControls}
|
||||||
>
|
onRiskAssessmentChange={setIncludeRiskAssessment}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Export Panel */}
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Format & Export */}
|
<ExportPanel
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
selectedReportType={selectedReportType}
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
selectedFormat={selectedFormat}
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
selectedVendors={selectedVendors}
|
||||||
Export
|
selectedActivities={selectedActivities}
|
||||||
</h2>
|
isGenerating={isGenerating}
|
||||||
</div>
|
onFormatChange={setSelectedFormat}
|
||||||
<div className="p-4 space-y-4">
|
onExport={handleExport}
|
||||||
{/* Selected Report Info */}
|
/>
|
||||||
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
<RecentReports reports={generatedReports} />
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<HelpPanel />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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