diff --git a/admin-compliance/app/sdk/evidence/_components/EvidenceCard.tsx b/admin-compliance/app/sdk/evidence/_components/EvidenceCard.tsx new file mode 100644 index 0000000..a94bd48 --- /dev/null +++ b/admin-compliance/app/sdk/evidence/_components/EvidenceCard.tsx @@ -0,0 +1,134 @@ +'use client' + +import type { DisplayEvidence, DisplayEvidenceType } from './EvidenceTypes' + +const typeIcons: Record = { + document: ( + + + + ), + screenshot: ( + + + + ), + log: ( + + + + ), + 'audit-report': ( + + + + ), + certificate: ( + + + + ), +} + +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 = { + 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 ( +
+
+
+ {typeIcons[evidence.displayType]} +
+
+
+

{evidence.name}

+ + {statusLabels[evidence.status]} + +
+

{evidence.description}

+ +
+ Hochgeladen: {evidence.uploadedAt.toLocaleDateString('de-DE')} + {evidence.validUntil && ( + + Gueltig bis: {evidence.validUntil.toLocaleDateString('de-DE')} + + )} + {evidence.fileSize} +
+ +
+ {evidence.linkedRequirements.map(req => ( + + {req} + + ))} + {evidence.linkedControls.map(ctrl => ( + + {ctrl} + + ))} +
+
+
+ +
+ Hochgeladen von: {evidence.uploadedBy} +
+ + + +
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/evidence/_components/EvidenceTypes.ts b/admin-compliance/app/sdk/evidence/_components/EvidenceTypes.ts new file mode 100644 index 0000000..ce3ad6a --- /dev/null +++ b/admin-compliance/app/sdk/evidence/_components/EvidenceTypes.ts @@ -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', + }, +] diff --git a/admin-compliance/app/sdk/evidence/_components/LoadingSkeleton.tsx b/admin-compliance/app/sdk/evidence/_components/LoadingSkeleton.tsx new file mode 100644 index 0000000..4db7625 --- /dev/null +++ b/admin-compliance/app/sdk/evidence/_components/LoadingSkeleton.tsx @@ -0,0 +1,19 @@ +'use client' + +export function LoadingSkeleton() { + return ( +
+ {[1, 2, 3].map(i => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ) +} diff --git a/admin-compliance/app/sdk/evidence/_hooks/useEvidence.ts b/admin-compliance/app/sdk/evidence/_hooks/useEvidence.ts new file mode 100644 index 0000000..a32dd93 --- /dev/null +++ b/admin-compliance/app/sdk/evidence/_hooks/useEvidence.ts @@ -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('all') + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [uploading, setUploading] = useState(false) + const fileInputRef = useRef(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) => ({ + 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) => { + 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, + } +} diff --git a/admin-compliance/app/sdk/evidence/page.tsx b/admin-compliance/app/sdk/evidence/page.tsx index a66844f..b6b558e 100644 --- a/admin-compliance/app/sdk/evidence/page.tsx +++ b/admin-compliance/app/sdk/evidence/page.tsx @@ -1,515 +1,36 @@ 'use client' -import React, { useState, useEffect, useRef } from 'react' -import { useSDK, Evidence as SDKEvidence, EvidenceType } from '@/lib/sdk' +import React from 'react' import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader' - -// ============================================================================= -// TYPES -// ============================================================================= - -type DisplayEvidenceType = 'document' | 'screenshot' | 'log' | 'audit-report' | 'certificate' -type DisplayFormat = 'pdf' | 'image' | 'text' | 'json' -type DisplayStatus = 'valid' | 'expired' | 'pending-review' - -interface DisplayEvidence { - id: string - name: string - description: string - displayType: DisplayEvidenceType - format: DisplayFormat - controlId: string - linkedRequirements: string[] - linkedControls: string[] - uploadedBy: string - uploadedAt: Date - validFrom: Date - validUntil: Date | null - status: DisplayStatus - fileSize: string - fileUrl: string | null -} - -// ============================================================================= -// HELPER FUNCTIONS -// ============================================================================= - -function mapEvidenceTypeToDisplay(type: EvidenceType): DisplayEvidenceType { - switch (type) { - case 'DOCUMENT': return 'document' - case 'SCREENSHOT': return 'screenshot' - case 'LOG': return 'log' - case 'CERTIFICATE': return 'certificate' - case 'AUDIT_REPORT': return 'audit-report' - default: return 'document' - } -} - -function getEvidenceStatus(validUntil: Date | null): DisplayStatus { - if (!validUntil) return 'pending-review' - const now = new Date() - if (validUntil < now) return 'expired' - return 'valid' -} - -// ============================================================================= -// FALLBACK TEMPLATES -// ============================================================================= - -interface EvidenceTemplate { - id: string - name: string - description: string - type: EvidenceType - displayType: DisplayEvidenceType - format: DisplayFormat - controlId: string - linkedRequirements: string[] - linkedControls: string[] - uploadedBy: string - validityDays: number - fileSize: string -} - -const evidenceTemplates: EvidenceTemplate[] = [ - { - id: 'ev-dse-001', - name: 'Datenschutzerklaerung v2.3', - description: 'Aktuelle Datenschutzerklaerung fuer Website und App', - type: 'DOCUMENT', - displayType: 'document', - format: 'pdf', - controlId: 'ctrl-org-001', - linkedRequirements: ['req-gdpr-13', 'req-gdpr-14'], - linkedControls: ['ctrl-org-001'], - uploadedBy: 'DSB', - validityDays: 365, - fileSize: '245 KB', - }, - { - id: 'ev-pentest-001', - name: 'Penetrationstest Report Q4/2024', - description: 'Externer Penetrationstest durch Security-Partner', - type: 'AUDIT_REPORT', - displayType: 'audit-report', - format: 'pdf', - controlId: 'ctrl-tom-001', - linkedRequirements: ['req-gdpr-32', 'req-iso-a12'], - linkedControls: ['ctrl-tom-001', 'ctrl-tom-002', 'ctrl-det-001'], - uploadedBy: 'IT Security Team', - validityDays: 365, - fileSize: '2.1 MB', - }, - { - id: 'ev-iso-cert', - name: 'ISO 27001 Zertifikat', - description: 'Zertifizierung des ISMS', - type: 'CERTIFICATE', - displayType: 'certificate', - format: 'pdf', - controlId: 'ctrl-tom-001', - linkedRequirements: ['req-iso-4.1', 'req-iso-5.1'], - linkedControls: [], - uploadedBy: 'QM Abteilung', - validityDays: 365, - fileSize: '156 KB', - }, - { - id: 'ev-schulung-001', - name: 'Schulungsnachweis Datenschutz 2024', - description: 'Teilnehmerliste und Schulungsinhalt', - type: 'DOCUMENT', - displayType: 'document', - format: 'pdf', - controlId: 'ctrl-org-001', - linkedRequirements: ['req-gdpr-39'], - linkedControls: ['ctrl-org-001'], - uploadedBy: 'HR Team', - validityDays: 365, - fileSize: '890 KB', - }, - { - id: 'ev-rbac-001', - name: 'Access Control Screenshot', - description: 'Nachweis der RBAC-Konfiguration', - type: 'SCREENSHOT', - displayType: 'screenshot', - format: 'image', - controlId: 'ctrl-tom-001', - linkedRequirements: ['req-gdpr-32'], - linkedControls: ['ctrl-tom-001'], - uploadedBy: 'Admin', - validityDays: 0, - fileSize: '1.2 MB', - }, - { - id: 'ev-log-001', - name: 'Audit Log Export', - description: 'Monatlicher Audit-Log Export', - type: 'LOG', - displayType: 'log', - format: 'json', - controlId: 'ctrl-det-001', - linkedRequirements: ['req-gdpr-32'], - linkedControls: ['ctrl-det-001'], - uploadedBy: 'System', - validityDays: 90, - fileSize: '4.5 MB', - }, -] - -// ============================================================================= -// COMPONENTS -// ============================================================================= - -function EvidenceCard({ evidence, onDelete, onView, onDownload }: { evidence: DisplayEvidence; onDelete: () => void; onView: () => void; onDownload: () => void }) { - const typeIcons = { - document: ( - - - - ), - screenshot: ( - - - - ), - log: ( - - - - ), - 'audit-report': ( - - - - ), - certificate: ( - - - - ), - } - - 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 ( -
-
-
- {typeIcons[evidence.displayType]} -
-
-
-

{evidence.name}

- - {statusLabels[evidence.status]} - -
-

{evidence.description}

- -
- Hochgeladen: {evidence.uploadedAt.toLocaleDateString('de-DE')} - {evidence.validUntil && ( - - Gueltig bis: {evidence.validUntil.toLocaleDateString('de-DE')} - - )} - {evidence.fileSize} -
- -
- {evidence.linkedRequirements.map(req => ( - - {req} - - ))} - {evidence.linkedControls.map(ctrl => ( - - {ctrl} - - ))} -
-
-
- -
- Hochgeladen von: {evidence.uploadedBy} -
- - - -
-
-
- ) -} - -function LoadingSkeleton() { - return ( -
- {[1, 2, 3].map(i => ( -
-
-
-
-
-
-
-
-
- ))} -
- ) -} - -// ============================================================================= -// MAIN PAGE -// ============================================================================= +import { EvidenceCard } from './_components/EvidenceCard' +import { LoadingSkeleton } from './_components/LoadingSkeleton' +import { useEvidence } from './_hooks/useEvidence' export default function EvidencePage() { - const { state, dispatch } = useSDK() - const [filter, setFilter] = useState('all') - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [uploading, setUploading] = useState(false) - const fileInputRef = useRef(null) - const [page, setPage] = useState(1) - const [pageSize] = useState(20) - const [total, setTotal] = useState(0) - - // Fetch evidence from backend on mount and when page changes - useEffect(() => { - const fetchEvidence = async () => { - try { - setLoading(true) - const res = await fetch(`/api/sdk/v1/compliance/evidence?page=${page}&limit=${pageSize}`) - if (res.ok) { - const data = await res.json() - if (data.total !== undefined) setTotal(data.total) - const backendEvidence = data.evidence || data - if (Array.isArray(backendEvidence) && backendEvidence.length > 0) { - const mapped: SDKEvidence[] = backendEvidence.map((e: Record) => ({ - id: (e.id || '') as string, - controlId: (e.control_id || '') as string, - type: ((e.evidence_type || 'DOCUMENT') as string).toUpperCase() as EvidenceType, - name: (e.title || e.name || '') as string, - description: (e.description || '') as string, - fileUrl: (e.artifact_url || null) as string | null, - validFrom: e.valid_from ? new Date(e.valid_from as string) : new Date(), - validUntil: e.valid_until ? new Date(e.valid_until as string) : null, - uploadedBy: (e.uploaded_by || 'System') as string, - uploadedAt: e.created_at ? new Date(e.created_at as string) : new Date(), - })) - dispatch({ type: 'SET_STATE', payload: { evidence: mapped } }) - setError(null) - return - } - } - loadFromTemplates() - } catch { - loadFromTemplates() - } finally { - setLoading(false) - } - } - - const loadFromTemplates = () => { - if (state.evidence.length > 0) return - if (state.controls.length === 0) return - - const relevantEvidence = evidenceTemplates.filter(e => - state.controls.some(c => c.id === e.controlId || e.linkedControls.includes(c.id)) - ) - - const now = new Date() - relevantEvidence.forEach(template => { - const validFrom = new Date(now) - validFrom.setMonth(validFrom.getMonth() - 1) - - const validUntil = template.validityDays > 0 - ? new Date(validFrom.getTime() + template.validityDays * 24 * 60 * 60 * 1000) - : null - - const sdkEvidence: SDKEvidence = { - id: template.id, - controlId: template.controlId, - type: template.type, - name: template.name, - description: template.description, - fileUrl: null, - validFrom, - validUntil, - uploadedBy: template.uploadedBy, - uploadedAt: validFrom, - } - dispatch({ type: 'ADD_EVIDENCE', payload: sdkEvidence }) - }) - } - - fetchEvidence() - }, [page, pageSize]) // eslint-disable-line react-hooks/exhaustive-deps - - // Convert SDK evidence to display evidence - const displayEvidence: DisplayEvidence[] = state.evidence.map(ev => { - const template = evidenceTemplates.find(t => t.id === ev.id) - - return { - id: ev.id, - name: ev.name, - description: ev.description, - displayType: mapEvidenceTypeToDisplay(ev.type), - format: template?.format || 'pdf', - controlId: ev.controlId, - linkedRequirements: template?.linkedRequirements || [], - linkedControls: template?.linkedControls || [ev.controlId], - uploadedBy: ev.uploadedBy, - uploadedAt: ev.uploadedAt, - validFrom: ev.validFrom, - validUntil: ev.validUntil, - status: getEvidenceStatus(ev.validUntil), - fileSize: template?.fileSize || 'Unbekannt', - fileUrl: ev.fileUrl, - } - }) - - const filteredEvidence = filter === 'all' - ? displayEvidence - : displayEvidence.filter(e => e.status === filter || e.displayType === filter) - - const validCount = displayEvidence.filter(e => e.status === 'valid').length - const expiredCount = displayEvidence.filter(e => e.status === 'expired').length - const pendingCount = displayEvidence.filter(e => e.status === 'pending-review').length - - const handleDelete = async (evidenceId: string) => { - if (!confirm('Moechten Sie diesen Nachweis wirklich loeschen?')) return - - dispatch({ type: 'DELETE_EVIDENCE', payload: evidenceId }) - - try { - await fetch(`/api/sdk/v1/compliance/evidence/${evidenceId}`, { - method: 'DELETE', - }) - } catch { - // Silently fail — SDK state is already updated - } - } - - const handleUpload = async (file: File) => { - setUploading(true) - setError(null) - - try { - // Use the first control as default, or a generic one - const controlId = state.controls.length > 0 ? state.controls[0].id : 'GENERIC' - - const params = new URLSearchParams({ - control_id: controlId, - evidence_type: 'document', - title: file.name, - }) - - const formData = new FormData() - formData.append('file', file) - - const res = await fetch(`/api/sdk/v1/compliance/evidence/upload?${params}`, { - method: 'POST', - body: formData, - }) - - if (!res.ok) { - const errData = await res.json().catch(() => ({ error: 'Upload fehlgeschlagen' })) - throw new Error(errData.error || errData.detail || 'Upload fehlgeschlagen') - } - - const data = await res.json() - - // Add to SDK state - const newEvidence: SDKEvidence = { - id: data.id || `ev-${Date.now()}`, - controlId: controlId, - type: 'DOCUMENT', - name: file.name, - description: `Hochgeladen am ${new Date().toLocaleDateString('de-DE')}`, - fileUrl: data.artifact_url || null, - validFrom: new Date(), - validUntil: null, - uploadedBy: 'Aktueller Benutzer', - uploadedAt: new Date(), - } - dispatch({ type: 'ADD_EVIDENCE', payload: newEvidence }) - } catch (err) { - setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen') - } finally { - setUploading(false) - } - } - - const handleView = (ev: DisplayEvidence) => { - if (ev.fileUrl) { - window.open(ev.fileUrl, '_blank') - } else { - alert('Keine Datei vorhanden') - } - } - - const handleDownload = (ev: DisplayEvidence) => { - if (!ev.fileUrl) return - const a = document.createElement('a') - a.href = ev.fileUrl - a.download = ev.name - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - } - - const handleUploadClick = () => { - fileInputRef.current?.click() - } - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) { - handleUpload(file) - e.target.value = '' // Reset input - } - } + const { + state, + filter, + setFilter, + loading, + error, + setError, + uploading, + fileInputRef, + page, + setPage, + pageSize, + total, + displayEvidence, + filteredEvidence, + validCount, + expiredCount, + pendingCount, + handleDelete, + handleView, + handleDownload, + handleUploadClick, + handleFileChange, + } = useEvidence() const stepInfo = STEP_EXPLANATIONS['evidence'] @@ -609,9 +130,7 @@ export default function EvidencePage() { key={f} onClick={() => setFilter(f)} className={`px-3 py-1 text-sm rounded-full transition-colors ${ - filter === f - ? 'bg-purple-600 text-white' - : 'bg-gray-100 text-gray-600 hover:bg-gray-200' + filter === f ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200' }`} > {f === 'all' ? 'Alle' : diff --git a/admin-compliance/app/sdk/gci/_components/AuditTab.tsx b/admin-compliance/app/sdk/gci/_components/AuditTab.tsx new file mode 100644 index 0000000..adb85fb --- /dev/null +++ b/admin-compliance/app/sdk/gci/_components/AuditTab.tsx @@ -0,0 +1,42 @@ +'use client' + +import { GCIResult } from '@/lib/sdk/gci/types' + +export function AuditTab({ gci }: { gci: GCIResult }) { + return ( +
+
+

+ Audit Trail - Berechnung GCI {gci.gci_score.toFixed(1)} +

+

+ Jeder Schritt der GCI-Berechnung ist nachvollziehbar und prueffaehig dokumentiert. +

+
+ {gci.audit_trail.map((entry, i) => ( +
+
+
+
+ {entry.factor} + + {entry.value > 0 ? '+' : ''}{entry.value.toFixed(2)} + +
+

{entry.description}

+
+
+ ))} +
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/gci/_components/BreakdownTab.tsx b/admin-compliance/app/sdk/gci/_components/BreakdownTab.tsx new file mode 100644 index 0000000..0088d26 --- /dev/null +++ b/admin-compliance/app/sdk/gci/_components/BreakdownTab.tsx @@ -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 + + return ( +
+
+

Level 1: Modul-Scores

+
+ + + + + + + + + + + + + + {breakdown.level1_modules.map(m => ( + + + + + + + + + + ))} + +
ModulKategorieZugewiesenAbgeschlossenRaw ScoreValiditaetFinal
{m.module_name} + + {m.category} + + {m.assigned}{m.completed}{(m.raw_score * 100).toFixed(1)}%{(m.validity_factor * 100).toFixed(0)}% + {(m.final_score * 100).toFixed(1)}% +
+
+
+ +
+

Level 2: Regulierungsbereiche (risikogewichtet)

+
+ {breakdown.level2_areas.map(area => ( +
+
+

{area.area_name}

+ + {area.area_score.toFixed(1)}% + +
+
+ {area.modules.map(m => ( +
+ {m.module_name} + {(m.final_score * 100).toFixed(0)}% (w:{m.risk_weight.toFixed(1)}) +
+ ))} +
+
+ ))} +
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/gci/_components/GCIHelpers.tsx b/admin-compliance/app/sdk/gci/_components/GCIHelpers.tsx new file mode 100644 index 0000000..c3833b7 --- /dev/null +++ b/admin-compliance/app/sdk/gci/_components/GCIHelpers.tsx @@ -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 ( +
+ +
+ ) +} + +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 ( +
+ + + + +
+ {score.toFixed(1)} + {label && {label}} +
+
+ ) +} + +export function MaturityBadge({ level }: { level: MaturityLevel }) { + const info = MATURITY_INFO[level] || MATURITY_INFO.HIGH_RISK + return ( + + {info.label} + + ) +} + +export function AreaScoreBar({ name, score, weight }: { name: string; score: number; weight: number }) { + return ( +
+
+ {name} + {score.toFixed(1)}% +
+
+
+
+
Gewichtung: {(weight * 100).toFixed(0)}%
+
+ ) +} + +export function LoadingSpinner() { + return ( +
+
+
+ ) +} + +export function ErrorMessage({ message, onRetry }: { message: string; onRetry?: () => void }) { + return ( +
+

{message}

+ {onRetry && ( + + )} +
+ ) +} diff --git a/admin-compliance/app/sdk/gci/_components/ISOTab.tsx b/admin-compliance/app/sdk/gci/_components/ISOTab.tsx new file mode 100644 index 0000000..537351a --- /dev/null +++ b/admin-compliance/app/sdk/gci/_components/ISOTab.tsx @@ -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 + + return ( +
+
+
+ +
+

ISO 27001:2022 Gap-Analyse

+
+
+
{iso.covered_full}
+
Voll abgedeckt
+
+
+
{iso.covered_partial}
+
Teilweise
+
+
+
{iso.not_covered}
+
Nicht abgedeckt
+
+
+
+
+
+ +
+

Kategorien

+
+ {iso.category_summaries.map(cat => ( +
+
+ {cat.category_id}: {cat.category_name} + {cat.covered_full}/{cat.total_controls} Controls +
+
+
+
+
+
+ ))} +
+
+ + {iso.gaps && iso.gaps.length > 0 && ( +
+

Offene Gaps ({iso.gaps.length})

+
+ {iso.gaps.map(gap => ( +
+ + {gap.priority} + +
+
{gap.control_id}: {gap.control_name}
+
{gap.recommendation}
+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/gci/_components/MatrixTab.tsx b/admin-compliance/app/sdk/gci/_components/MatrixTab.tsx new file mode 100644 index 0000000..34e670d --- /dev/null +++ b/admin-compliance/app/sdk/gci/_components/MatrixTab.tsx @@ -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 + + const regulations = matrix.matrix.length > 0 ? Object.keys(matrix.matrix[0].regulations) : [] + + return ( +
+
+

Compliance-Matrix (Rollen x Regulierungen)

+
+ + + + + {regulations.map(r => ( + + ))} + + + + + + {matrix.matrix.map(entry => ( + + + {regulations.map(r => ( + + ))} + + + + ))} + +
Rolle{r}GesamtModule
{entry.role_name} + + {entry.regulations[r].toFixed(0)}% + + + + {entry.overall_score.toFixed(0)}% + + + {entry.completed_modules}/{entry.required_modules} +
+
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/gci/_components/NIS2Tab.tsx b/admin-compliance/app/sdk/gci/_components/NIS2Tab.tsx new file mode 100644 index 0000000..0ccec41 --- /dev/null +++ b/admin-compliance/app/sdk/gci/_components/NIS2Tab.tsx @@ -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 + + return ( +
+
+
+ +
+

NIS2 Compliance Score

+

Network and Information Security Directive 2 (EU 2022/2555)

+
+
+
+ +
+

NIS2 Bereiche

+
+ {nis2.areas.map(area => ( + + ))} +
+
+ + {nis2.role_scores && nis2.role_scores.length > 0 && ( +
+

Rollen-Compliance

+
+ {nis2.role_scores.map(role => ( +
+
{role.role_name}
+
+ + {(role.completion_rate * 100).toFixed(0)}% + + {role.modules_completed}/{role.modules_required} Module +
+
+
+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/gci/_components/OverviewTab.tsx b/admin-compliance/app/sdk/gci/_components/OverviewTab.tsx new file mode 100644 index 0000000..7a7f58e --- /dev/null +++ b/admin-compliance/app/sdk/gci/_components/OverviewTab.tsx @@ -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 ( +
+ {profiles.length > 0 && ( +
+ + +
+ )} + +
+
+ +
+
+

Gesamt-Compliance-Index

+
+ + + Berechnet: {new Date(gci.calculated_at).toLocaleString('de-DE')} + +
+
+

{MATURITY_INFO[gci.maturity_level]?.description || ''}

+
+
+
+ +
+

Regulierungsbereiche

+
+ {gci.area_scores.map(area => ( + + ))} +
+
+ + {history && history.snapshots.length > 0 && ( +
+

Verlauf

+
+ {history.snapshots.map((snap, i) => ( +
+ {snap.score.toFixed(0)} +
+ + {new Date(snap.calculated_at).toLocaleDateString('de-DE', { month: 'short' })} + +
+ ))} +
+
+ )} + +
+
+
Kritikalitaets-Multiplikator
+
{gci.criticality_multiplier.toFixed(2)}x
+
+
+
Incident-Korrektur
+
+ {gci.incident_adjustment > 0 ? '+' : ''}{gci.incident_adjustment.toFixed(1)} +
+
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/gci/_hooks/useGCI.ts b/admin-compliance/app/sdk/gci/_hooks/useGCI.ts new file mode 100644 index 0000000..30ae740 --- /dev/null +++ b/admin-compliance/app/sdk/gci/_hooks/useGCI.ts @@ -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('overview') + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const [gci, setGCI] = useState(null) + const [breakdown, setBreakdown] = useState(null) + const [history, setHistory] = useState(null) + const [matrix, setMatrix] = useState(null) + const [nis2, setNIS2] = useState(null) + const [iso, setISO] = useState(null) + const [profiles, setProfiles] = useState([]) + 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, + } +} diff --git a/admin-compliance/app/sdk/gci/page.tsx b/admin-compliance/app/sdk/gci/page.tsx index 8eed1fe..966e8d7 100644 --- a/admin-compliance/app/sdk/gci/page.tsx +++ b/admin-compliance/app/sdk/gci/page.tsx @@ -1,655 +1,38 @@ 'use client' -import React, { useState, useEffect, useCallback } from 'react' -import { - GCIResult, - GCIBreakdown, - GCIHistoryResponse, - GCIMatrixResponse, - NIS2Score, - ISOGapAnalysis, - WeightProfile, - MaturityLevel, - MATURITY_INFO, - getScoreColor, - getScoreRingColor, -} from '@/lib/sdk/gci/types' -import { - getGCIScore, - getGCIBreakdown, - getGCIHistory, - getGCIMatrix, - getNIS2Score, - getISOGapAnalysis, - getWeightProfiles, -} from '@/lib/sdk/gci/api' - -// ============================================================================= -// TYPES -// ============================================================================= - -type TabId = 'overview' | 'breakdown' | 'nis2' | 'iso' | 'matrix' | 'audit' - -interface Tab { - id: TabId - label: string -} - -const TABS: Tab[] = [ - { id: 'overview', label: 'Uebersicht' }, - { id: 'breakdown', label: 'Breakdown' }, - { id: 'nis2', label: 'NIS2' }, - { id: 'iso', label: 'ISO 27001' }, - { id: 'matrix', label: 'Matrix' }, - { id: 'audit', label: 'Audit Trail' }, -] - -// ============================================================================= -// HELPER COMPONENTS -// ============================================================================= - -function TabNavigation({ tabs, activeTab, onTabChange }: { tabs: Tab[]; activeTab: TabId; onTabChange: (tab: TabId) => void }) { - return ( -
- -
- ) -} - -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 ( -
- - - - -
- {score.toFixed(1)} - {label && {label}} -
-
- ) -} - -function MaturityBadge({ level }: { level: MaturityLevel }) { - const info = MATURITY_INFO[level] || MATURITY_INFO.HIGH_RISK - return ( - - {info.label} - - ) -} - -function AreaScoreBar({ name, score, weight }: { name: string; score: number; weight: number }) { - return ( -
-
- {name} - {score.toFixed(1)}% -
-
-
-
-
Gewichtung: {(weight * 100).toFixed(0)}%
-
- ) -} - -function LoadingSpinner() { - return ( -
-
-
- ) -} - -function ErrorMessage({ message, onRetry }: { message: string; onRetry?: () => void }) { - return ( -
-

{message}

- {onRetry && ( - - )} -
- ) -} - -// ============================================================================= -// TAB: OVERVIEW -// ============================================================================= - -function OverviewTab({ gci, history, profiles, selectedProfile, onProfileChange }: { - gci: GCIResult - history: GCIHistoryResponse | null - profiles: WeightProfile[] - selectedProfile: string - onProfileChange: (p: string) => void -}) { - return ( -
- {/* Profile Selector */} - {profiles.length > 0 && ( -
- - -
- )} - - {/* Main Score */} -
-
- -
-
-

Gesamt-Compliance-Index

-
- - - Berechnet: {new Date(gci.calculated_at).toLocaleString('de-DE')} - -
-
-

- {MATURITY_INFO[gci.maturity_level]?.description || ''} -

-
-
-
- - {/* Area Scores */} -
-

Regulierungsbereiche

-
- {gci.area_scores.map(area => ( - - ))} -
-
- - {/* History Chart (simplified) */} - {history && history.snapshots.length > 0 && ( -
-

Verlauf

-
- {history.snapshots.map((snap, i) => ( -
- {snap.score.toFixed(0)} -
- - {new Date(snap.calculated_at).toLocaleDateString('de-DE', { month: 'short' })} - -
- ))} -
-
- )} - - {/* Adjustments */} -
-
-
Kritikalitaets-Multiplikator
-
{gci.criticality_multiplier.toFixed(2)}x
-
-
-
Incident-Korrektur
-
- {gci.incident_adjustment > 0 ? '+' : ''}{gci.incident_adjustment.toFixed(1)} -
-
-
-
- ) -} - -// ============================================================================= -// TAB: BREAKDOWN -// ============================================================================= - -function BreakdownTab({ breakdown }: { breakdown: GCIBreakdown | null; loading: boolean }) { - if (!breakdown) return - - return ( -
- {/* Level 1: Modules */} -
-

Level 1: Modul-Scores

-
- - - - - - - - - - - - - - {breakdown.level1_modules.map(m => ( - - - - - - - - - - ))} - -
ModulKategorieZugewiesenAbgeschlossenRaw ScoreValiditaetFinal
{m.module_name} - - {m.category} - - {m.assigned}{m.completed}{(m.raw_score * 100).toFixed(1)}%{(m.validity_factor * 100).toFixed(0)}% - {(m.final_score * 100).toFixed(1)}% -
-
-
- - {/* Level 2: Areas */} -
-

Level 2: Regulierungsbereiche (risikogewichtet)

-
- {breakdown.level2_areas.map(area => ( -
-
-

{area.area_name}

- - {area.area_score.toFixed(1)}% - -
-
- {area.modules.map(m => ( -
- {m.module_name} - {(m.final_score * 100).toFixed(0)}% (w:{m.risk_weight.toFixed(1)}) -
- ))} -
-
- ))} -
-
-
- ) -} - -// ============================================================================= -// TAB: NIS2 -// ============================================================================= - -function NIS2Tab({ nis2 }: { nis2: NIS2Score | null }) { - if (!nis2) return - - return ( -
- {/* NIS2 Overall */} -
-
- -
-

NIS2 Compliance Score

-

- Network and Information Security Directive 2 (EU 2022/2555) -

-
-
-
- - {/* NIS2 Areas */} -
-

NIS2 Bereiche

-
- {nis2.areas.map(area => ( - - ))} -
-
- - {/* NIS2 Roles */} - {nis2.role_scores && nis2.role_scores.length > 0 && ( -
-

Rollen-Compliance

-
- {nis2.role_scores.map(role => ( -
-
{role.role_name}
-
- - {(role.completion_rate * 100).toFixed(0)}% - - - {role.modules_completed}/{role.modules_required} Module - -
-
-
-
-
- ))} -
-
- )} -
- ) -} - -// ============================================================================= -// TAB: ISO 27001 -// ============================================================================= - -function ISOTab({ iso }: { iso: ISOGapAnalysis | null }) { - if (!iso) return - - return ( -
- {/* Coverage Overview */} -
-
- -
-

ISO 27001:2022 Gap-Analyse

-
-
-
{iso.covered_full}
-
Voll abgedeckt
-
-
-
{iso.covered_partial}
-
Teilweise
-
-
-
{iso.not_covered}
-
Nicht abgedeckt
-
-
-
-
-
- - {/* Category Summaries */} -
-

Kategorien

-
- {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 ( -
-
- {cat.category_id}: {cat.category_name} - - {cat.covered_full}/{cat.total_controls} Controls - -
-
-
-
-
-
- ) - })} -
-
- - {/* Gaps */} - {iso.gaps && iso.gaps.length > 0 && ( -
-

- Offene Gaps ({iso.gaps.length}) -

-
- {iso.gaps.map(gap => ( -
- - {gap.priority} - -
-
{gap.control_id}: {gap.control_name}
-
{gap.recommendation}
-
-
- ))} -
-
- )} -
- ) -} - -// ============================================================================= -// TAB: MATRIX -// ============================================================================= - -function MatrixTab({ matrix }: { matrix: GCIMatrixResponse | null }) { - if (!matrix || !matrix.matrix) return - - const regulations = matrix.matrix.length > 0 ? Object.keys(matrix.matrix[0].regulations) : [] - - return ( -
-
-

Compliance-Matrix (Rollen x Regulierungen)

-
- - - - - {regulations.map(r => ( - - ))} - - - - - - {matrix.matrix.map(entry => ( - - - {regulations.map(r => ( - - ))} - - - - ))} - -
Rolle{r}GesamtModule
{entry.role_name} - - {entry.regulations[r].toFixed(0)}% - - - - {entry.overall_score.toFixed(0)}% - - - {entry.completed_modules}/{entry.required_modules} -
-
-
-
- ) -} - -// ============================================================================= -// TAB: AUDIT TRAIL -// ============================================================================= - -function AuditTab({ gci }: { gci: GCIResult }) { - return ( -
-
-

- Audit Trail - Berechnung GCI {gci.gci_score.toFixed(1)} -

-

- Jeder Schritt der GCI-Berechnung ist nachvollziehbar und prueffaehig dokumentiert. -

-
- {gci.audit_trail.map((entry, i) => ( -
-
-
-
- {entry.factor} - - {entry.value > 0 ? '+' : ''}{entry.value.toFixed(2)} - -
-

{entry.description}

-
-
- ))} -
-
-
- ) -} - -// ============================================================================= -// MAIN PAGE -// ============================================================================= +import { TABS, TabNavigation, LoadingSpinner, ErrorMessage } from './_components/GCIHelpers' +import { OverviewTab } from './_components/OverviewTab' +import { BreakdownTab } from './_components/BreakdownTab' +import { NIS2Tab } from './_components/NIS2Tab' +import { ISOTab } from './_components/ISOTab' +import { MatrixTab } from './_components/MatrixTab' +import { AuditTab } from './_components/AuditTab' +import { useGCI } from './_hooks/useGCI' export default function GCIPage() { - const [activeTab, setActiveTab] = useState('overview') - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - const [gci, setGCI] = useState(null) - const [breakdown, setBreakdown] = useState(null) - const [history, setHistory] = useState(null) - const [matrix, setMatrix] = useState(null) - const [nis2, setNIS2] = useState(null) - const [iso, setISO] = useState(null) - const [profiles, setProfiles] = useState([]) - const [selectedProfile, setSelectedProfile] = useState('default') - - const loadData = useCallback(async (profile?: string) => { - setLoading(true) - setError(null) - try { - const [gciRes, historyRes, profilesRes] = await Promise.all([ - getGCIScore(profile), - getGCIHistory(), - getWeightProfiles(), - ]) - setGCI(gciRes) - setHistory(historyRes) - setProfiles(profilesRes.profiles || []) - } catch (err: any) { - setError(err.message || 'Fehler beim Laden der GCI-Daten') - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - loadData(selectedProfile) - }, [selectedProfile, loadData]) - - // Lazy-load tab data - useEffect(() => { - if (activeTab === 'breakdown' && !breakdown && gci) { - getGCIBreakdown(selectedProfile).then(setBreakdown).catch(() => {}) - } - if (activeTab === 'nis2' && !nis2) { - getNIS2Score().then(setNIS2).catch(() => {}) - } - if (activeTab === 'iso' && !iso) { - getISOGapAnalysis().then(setISO).catch(() => {}) - } - if (activeTab === 'matrix' && !matrix) { - getGCIMatrix().then(setMatrix).catch(() => {}) - } - }, [activeTab, breakdown, nis2, iso, matrix, gci, selectedProfile]) - - const handleProfileChange = (profile: string) => { - setSelectedProfile(profile) - setBreakdown(null) // reset breakdown to reload - } + const { + activeTab, + setActiveTab, + loading, + error, + gci, + breakdown, + history, + matrix, + nis2, + iso, + profiles, + selectedProfile, + loadData, + handleProfileChange, + } = useGCI() return (
- {/* Header */}

Gesamt-Compliance-Index (GCI)

-

- 4-stufiges, mathematisch fundiertes Compliance-Scoring -

+

4-stufiges, mathematisch fundiertes Compliance-Scoring

- {/* Tabs */} - {/* Content */} {error && loadData(selectedProfile)} />} {loading && !gci ? ( diff --git a/admin-compliance/app/sdk/import/_components/FileItem.tsx b/admin-compliance/app/sdk/import/_components/FileItem.tsx new file mode 100644 index 0000000..f5c4f5b --- /dev/null +++ b/admin-compliance/app/sdk/import/_components/FileItem.tsx @@ -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 ( +
+
+ + + +
+ +
+

{file.file.name}

+

{(file.file.size / 1024 / 1024).toFixed(2)} MB

+
+ + + + {file.status === 'pending' && ( + + )} + {file.status === 'uploading' && ( +
+
+
+
+ {file.progress}% +
+ )} + {file.status === 'analyzing' && ( +
+ + + + + Analysiere... +
+ )} + {file.status === 'complete' && file.error === 'offline' && ( +
+ + + + Offline — nicht analysiert +
+ )} + {file.status === 'complete' && file.error !== 'offline' && ( +
+ + + + Fertig +
+ )} + {file.status === 'error' && ( +
+ + + + {file.error || 'Fehler'} +
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/import/_components/GapAnalysisPreview.tsx b/admin-compliance/app/sdk/import/_components/GapAnalysisPreview.tsx new file mode 100644 index 0000000..3ce701f --- /dev/null +++ b/admin-compliance/app/sdk/import/_components/GapAnalysisPreview.tsx @@ -0,0 +1,77 @@ +'use client' + +import type { GapAnalysis, GapItem } from '@/lib/sdk/types' + +export function GapAnalysisPreview({ analysis }: { analysis: GapAnalysis }) { + return ( +
+
+
+ 📊 +
+
+

Gap-Analyse Ergebnis

+

+ {analysis.totalGaps} Luecken in {analysis.gaps.length} Kategorien gefunden +

+
+
+ +
+
+
{analysis.criticalGaps}
+
Kritisch
+
+
+
{analysis.highGaps}
+
Hoch
+
+
+
{analysis.mediumGaps}
+
Mittel
+
+
+
{analysis.lowGaps}
+
Niedrig
+
+
+ +
+ {analysis.gaps.slice(0, 5).map((gap: GapItem) => ( +
+
+
+
{gap.category}
+

{gap.description}

+
+ + {gap.severity} + +
+
+ Regulierung: {gap.regulation} | Aktion: {gap.requiredAction} +
+
+ ))} + {analysis.gaps.length > 5 && ( +

+ + {analysis.gaps.length - 5} weitere Luecken +

+ )} +
+
+ ) +} diff --git a/admin-compliance/app/sdk/import/_components/ImportHistory.tsx b/admin-compliance/app/sdk/import/_components/ImportHistory.tsx new file mode 100644 index 0000000..c0da2f1 --- /dev/null +++ b/admin-compliance/app/sdk/import/_components/ImportHistory.tsx @@ -0,0 +1,56 @@ +'use client' + +export function ImportHistory({ + importHistory, + historyLoading, + onDelete, +}: { + importHistory: any[] + historyLoading: boolean + onDelete: (id: string) => void +}) { + if (historyLoading) { + return
Import-Verlauf wird geladen...
+ } + + if (importHistory.length === 0) return null + + return ( +
+
+

Import-Verlauf

+

{importHistory.length} fruehere Imports

+
+
+ {importHistory.map((item: any, idx: number) => ( +
+
+
+ + + +
+
+

{item.name || item.filename || `Import #${idx + 1}`}

+

+ {item.document_type || item.type || 'Unbekannt'} — {item.uploaded_at ? new Date(item.uploaded_at).toLocaleString('de-DE') : 'Unbekannt'} +

+
+
+ +
+ ))} +
+
+ ) +} diff --git a/admin-compliance/app/sdk/import/_components/UploadZone.tsx b/admin-compliance/app/sdk/import/_components/UploadZone.tsx new file mode 100644 index 0000000..1433dbb --- /dev/null +++ b/admin-compliance/app/sdk/import/_components/UploadZone.tsx @@ -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) => { + if (e.target.files && !isDisabled) { + onFilesAdded(Array.from(e.target.files)) + } + }, + [onFilesAdded, isDisabled] + ) + + return ( +
+ +
+
+ + + +
+
+

+ {isDragging ? 'Dateien hier ablegen' : 'Dokumente hochladen'} +

+

+ Ziehen Sie PDF-Dateien hierher oder klicken Sie zum Auswaehlen +

+
+
+ Unterstuetzte Formate: + PDF + JPG + PNG +
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/import/_hooks/useImport.ts b/admin-compliance/app/sdk/import/_hooks/useImport.ts new file mode 100644 index 0000000..19e147d --- /dev/null +++ b/admin-compliance/app/sdk/import/_hooks/useImport.ts @@ -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([]) + const [isAnalyzing, setIsAnalyzing] = useState(false) + const [analysisResult, setAnalysisResult] = useState(null) + const [importHistory, setImportHistory] = useState([]) + const [historyLoading, setHistoryLoading] = useState(false) + const [objectUrls, setObjectUrls] = useState([]) + + 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, + } +} diff --git a/admin-compliance/app/sdk/import/page.tsx b/admin-compliance/app/sdk/import/page.tsx index 68e194d..c5367ea 100644 --- a/admin-compliance/app/sdk/import/page.tsx +++ b/admin-compliance/app/sdk/import/page.tsx @@ -1,521 +1,29 @@ 'use client' -import { useState, useCallback, useEffect } from 'react' import { useRouter } from 'next/navigation' -import { useSDK } from '@/lib/sdk' -import type { ImportedDocument, ImportedDocumentType, GapAnalysis, GapItem } from '@/lib/sdk/types' - -// ============================================================================= -// DOCUMENT TYPE OPTIONS -// ============================================================================= - -const DOCUMENT_TYPES: { value: ImportedDocumentType; label: string; icon: string }[] = [ - { value: 'DSFA', label: 'Datenschutz-Folgenabschaetzung (DSFA)', icon: '📄' }, - { value: 'TOM', label: 'Technisch-organisatorische Massnahmen (TOMs)', icon: '🔒' }, - { value: 'VVT', label: 'Verarbeitungsverzeichnis (VVT)', icon: '📊' }, - { value: 'AGB', label: 'Allgemeine Geschaeftsbedingungen (AGB)', icon: '📜' }, - { value: 'PRIVACY_POLICY', label: 'Datenschutzerklaerung', icon: '🔐' }, - { value: 'COOKIE_POLICY', label: 'Cookie-Richtlinie', icon: '🍪' }, - { value: 'RISK_ASSESSMENT', label: 'Risikobewertung', icon: '⚠️' }, - { value: 'AUDIT_REPORT', label: 'Audit-Bericht', icon: '✅' }, - { value: 'OTHER', label: 'Sonstiges Dokument', icon: '📎' }, -] - -// ============================================================================= -// UPLOAD ZONE -// ============================================================================= - -interface UploadedFile { - id: string - file: File - type: ImportedDocumentType - status: 'pending' | 'uploading' | 'analyzing' | 'complete' | 'error' - progress: number - error?: string -} - -function UploadZone({ - onFilesAdded, - isDisabled, -}: { - onFilesAdded: (files: File[]) => void - isDisabled: boolean -}) { - const [isDragging, setIsDragging] = useState(false) - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault() - if (!isDisabled) setIsDragging(true) - }, [isDisabled]) - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault() - setIsDragging(false) - }, []) - - const handleDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault() - setIsDragging(false) - if (isDisabled) return - - const files = Array.from(e.dataTransfer.files).filter( - f => f.type === 'application/pdf' || f.type.startsWith('image/') - ) - if (files.length > 0) { - onFilesAdded(files) - } - }, - [onFilesAdded, isDisabled] - ) - - const handleFileSelect = useCallback( - (e: React.ChangeEvent) => { - if (e.target.files && !isDisabled) { - const files = Array.from(e.target.files) - onFilesAdded(files) - } - }, - [onFilesAdded, isDisabled] - ) - - return ( -
- - -
-
- - - -
- -
-

- {isDragging ? 'Dateien hier ablegen' : 'Dokumente hochladen'} -

-

- Ziehen Sie PDF-Dateien hierher oder klicken Sie zum Auswaehlen -

-
- -
- Unterstuetzte Formate: - PDF - JPG - PNG -
-
-
- ) -} - -// ============================================================================= -// FILE LIST -// ============================================================================= - -function FileItem({ - file, - onTypeChange, - onRemove, -}: { - file: UploadedFile - onTypeChange: (id: string, type: ImportedDocumentType) => void - onRemove: (id: string) => void -}) { - return ( -
- {/* File Icon */} -
- - - -
- - {/* File Info */} -
-

{file.file.name}

-

{(file.file.size / 1024 / 1024).toFixed(2)} MB

-
- - {/* Type Selector */} - - - {/* Status / Actions */} - {file.status === 'pending' && ( - - )} - {file.status === 'uploading' && ( -
-
-
-
- {file.progress}% -
- )} - {file.status === 'analyzing' && ( -
- - - - - Analysiere... -
- )} - {file.status === 'complete' && file.error === 'offline' && ( -
- - - - Offline — nicht analysiert -
- )} - {file.status === 'complete' && file.error !== 'offline' && ( -
- - - - Fertig -
- )} - {file.status === 'error' && ( -
- - - - {file.error || 'Fehler'} -
- )} -
- ) -} - -// ============================================================================= -// GAP ANALYSIS PREVIEW -// ============================================================================= - -function GapAnalysisPreview({ analysis }: { analysis: GapAnalysis }) { - return ( -
-
-
- 📊 -
-
-

Gap-Analyse Ergebnis

-

- {analysis.totalGaps} Luecken in {analysis.gaps.length} Kategorien gefunden -

-
-
- - {/* Summary Stats */} -
-
-
{analysis.criticalGaps}
-
Kritisch
-
-
-
{analysis.highGaps}
-
Hoch
-
-
-
{analysis.mediumGaps}
-
Mittel
-
-
-
{analysis.lowGaps}
-
Niedrig
-
-
- - {/* Gap List */} -
- {analysis.gaps.slice(0, 5).map((gap: GapItem) => ( -
-
-
-
{gap.category}
-

{gap.description}

-
- - {gap.severity} - -
-
- Regulierung: {gap.regulation} | Aktion: {gap.requiredAction} -
-
- ))} - {analysis.gaps.length > 5 && ( -

- + {analysis.gaps.length - 5} weitere Luecken -

- )} -
-
- ) -} - -// ============================================================================= -// MAIN PAGE -// ============================================================================= +import { UploadZone } from './_components/UploadZone' +import { FileItem } from './_components/FileItem' +import { GapAnalysisPreview } from './_components/GapAnalysisPreview' +import { ImportHistory } from './_components/ImportHistory' +import { useImport } from './_hooks/useImport' export default function ImportPage() { const router = useRouter() - const { state, addImportedDocument, setGapAnalysis, dispatch } = useSDK() - const [files, setFiles] = useState([]) - const [isAnalyzing, setIsAnalyzing] = useState(false) - const [analysisResult, setAnalysisResult] = useState(null) - const [importHistory, setImportHistory] = useState([]) - const [historyLoading, setHistoryLoading] = useState(false) - const [objectUrls, setObjectUrls] = useState([]) + const { + state, + files, + setFiles, + isAnalyzing, + analysisResult, + importHistory, + historyLoading, + handleFilesAdded, + handleTypeChange, + handleRemove, + handleAnalyze, + handleDeleteHistory, + } = useImport() - // 4.1: Load import history - useEffect(() => { - const loadHistory = async () => { - setHistoryLoading(true) - try { - const response = await fetch('/api/sdk/v1/import?tenant_id=default') - if (response.ok) { - const data = await response.json() - setImportHistory(Array.isArray(data) ? data : data.items || []) - } - } catch (err) { - console.error('Failed to load import history:', err) - } finally { - setHistoryLoading(false) - } - } - loadHistory() - }, [analysisResult]) - - // 4.4: Cleanup ObjectURLs on unmount - useEffect(() => { - return () => { - objectUrls.forEach(url => URL.revokeObjectURL(url)) - } - }, [objectUrls]) - - // Helper to create and track ObjectURLs - const createTrackedObjectURL = useCallback((file: File) => { - const url = URL.createObjectURL(file) - setObjectUrls(prev => [...prev, url]) - return url - }, []) - - const handleFilesAdded = useCallback((newFiles: File[]) => { - const uploadedFiles: UploadedFile[] = newFiles.map(file => ({ - id: `file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, - file, - type: 'OTHER' as ImportedDocumentType, - status: 'pending' as const, - progress: 0, - })) - setFiles(prev => [...prev, ...uploadedFiles]) - }, []) - - const handleTypeChange = useCallback((id: string, type: ImportedDocumentType) => { - setFiles(prev => prev.map(f => (f.id === id ? { ...f, type } : f))) - }, []) - - const handleRemove = useCallback((id: string) => { - setFiles(prev => prev.filter(f => f.id !== id)) - }, []) - - const handleAnalyze = async () => { - if (files.length === 0) return - - setIsAnalyzing(true) - const allGaps: GapItem[] = [] - - for (let i = 0; i < files.length; i++) { - const file = files[i] - - // Update to uploading - setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, status: 'uploading' as const } : f))) - - // Upload progress - setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 30 } : f))) - - // Prepare form data for backend - const formData = new FormData() - formData.append('file', file.file) - formData.append('document_type', file.type) - formData.append('tenant_id', 'default') - - setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 60, status: 'analyzing' as const } : f))) - - try { - const response = await fetch('/api/sdk/v1/import/analyze', { - method: 'POST', - body: formData, - }) - - if (response.ok) { - const result = await response.json() - - // Create imported document from backend response - const doc: ImportedDocument = { - id: result.document_id || file.id, - name: file.file.name, - type: result.detected_type || file.type, - fileUrl: createTrackedObjectURL(file.file), - uploadedAt: new Date(), - analyzedAt: new Date(), - analysisResult: { - detectedType: result.detected_type || file.type, - confidence: result.confidence || 0.85, - extractedEntities: result.extracted_entities || [], - gaps: result.gap_analysis?.gaps || [], - recommendations: result.recommendations || [], - }, - } - - addImportedDocument(doc) - - // Collect gaps - if (result.gap_analysis?.gaps) { - for (const gap of result.gap_analysis.gaps) { - allGaps.push({ - id: gap.id, - category: gap.category, - description: gap.description, - severity: gap.severity, - regulation: gap.regulation, - requiredAction: gap.required_action, - relatedStepId: gap.related_step_id || '', - }) - } - } - - setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 100, status: 'complete' as const } : f))) - } else { - setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, status: 'error' as const, error: 'Analyse fehlgeschlagen' } : f))) - } - } catch { - // Offline-Modus: create basic document without backend analysis - const doc: ImportedDocument = { - id: file.id, - name: file.file.name, - type: file.type, - fileUrl: createTrackedObjectURL(file.file), - uploadedAt: new Date(), - analyzedAt: new Date(), - analysisResult: { - detectedType: file.type, - confidence: 0.5, - extractedEntities: [], - gaps: [], - recommendations: ['Offline-Modus — Backend nicht erreichbar, manuelle Pruefung empfohlen'], - }, - } - addImportedDocument(doc) - setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, progress: 100, status: 'complete' as const, error: 'offline' } : f))) - } - } - - // Build gap analysis summary - const gapAnalysis: GapAnalysis = { - id: `analysis-${Date.now()}`, - createdAt: new Date(), - totalGaps: allGaps.length, - criticalGaps: allGaps.filter(g => g.severity === 'CRITICAL').length, - highGaps: allGaps.filter(g => g.severity === 'HIGH').length, - mediumGaps: allGaps.filter(g => g.severity === 'MEDIUM').length, - lowGaps: allGaps.filter(g => g.severity === 'LOW').length, - gaps: allGaps, - recommendedPackages: allGaps.length > 0 ? ['analyse', 'dokumentation'] : [], - } - - setAnalysisResult(gapAnalysis) - setGapAnalysis(gapAnalysis) - setIsAnalyzing(false) - - // Mark step as complete - dispatch({ type: 'COMPLETE_STEP', payload: 'import' }) - } - - const handleContinue = () => { - router.push('/sdk/screening') - } - - // Redirect if not existing customer if (state.customerType === 'new') { router.push('/sdk') return null @@ -540,22 +48,14 @@ export default function ImportPage() {

{files.length} Dokument(e)

{!isAnalyzing && !analysisResult && ( - )}
{files.map(file => ( - + ))}
@@ -599,7 +99,7 @@ export default function ImportPage() { Die Gap-Analyse wurde gespeichert. Sie koennen jetzt mit dem Compliance-Assessment fortfahren.

)} - {/* Import-Verlauf (4.1) */} - {importHistory.length > 0 && ( -
-
-

Import-Verlauf

-

{importHistory.length} fruehere Imports

-
-
- {importHistory.map((item: any, idx: number) => ( -
-
-
- - - -
-
-

{item.name || item.filename || `Import #${idx + 1}`}

-

- {item.document_type || item.type || 'Unbekannt'} — {item.uploaded_at ? new Date(item.uploaded_at).toLocaleString('de-DE') : 'Unbekannt'} -

-
-
- -
- ))} -
-
- )} - {historyLoading && ( -
Import-Verlauf wird geladen...
- )} + {/* Import History */} +
) } diff --git a/admin-compliance/app/sdk/portfolio/_components/CreatePortfolioModal.tsx b/admin-compliance/app/sdk/portfolio/_components/CreatePortfolioModal.tsx new file mode 100644 index 0000000..8d5fbe7 --- /dev/null +++ b/admin-compliance/app/sdk/portfolio/_components/CreatePortfolioModal.tsx @@ -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 ( +
+
e.stopPropagation()}> +

Neues Portfolio

+
+
+ + 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" /> +
+
+ +