All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 33s
CI / test-python-backend-compliance (push) Successful in 34s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 18s
- ISMS: markStepCompleted entfernt (existiert nicht in SDKContext, verursachte Application Error) - AI Routes: 13 ungenutzte Endpoints entfernt (ai_routes.py 1266→379 Zeilen, -887) - Schemas: 12 ungenutzte AI-Schema-Klassen entfernt (-108 Zeilen) - Compliance-Hub: 5 Fetch-URLs von /api/admin/... auf /api/sdk/v1/... umgestellt - Tests: 1361 passed, 0 Regressionen Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1261 lines
52 KiB
TypeScript
1261 lines
52 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
interface ISMSOverview {
|
|
overall_status: string
|
|
certification_readiness: number
|
|
chapters: ChapterStatus[]
|
|
scope_approved: boolean
|
|
soa_approved: boolean
|
|
last_management_review: string | null
|
|
last_internal_audit: string | null
|
|
open_major_findings: number
|
|
open_minor_findings: number
|
|
policies_count: number
|
|
policies_approved: number
|
|
objectives_count: number
|
|
objectives_achieved: number
|
|
}
|
|
|
|
interface ChapterStatus {
|
|
chapter: string
|
|
title: string
|
|
status: string
|
|
completion_percentage: number
|
|
open_findings: number
|
|
key_documents: string[]
|
|
last_reviewed: string | null
|
|
}
|
|
|
|
interface ISMSScope {
|
|
id: string
|
|
scope_statement: string
|
|
included_locations: string[]
|
|
included_processes: string[]
|
|
included_services: string[]
|
|
excluded_items: string[]
|
|
exclusion_justification: string
|
|
organizational_boundary: string
|
|
physical_boundary: string
|
|
technical_boundary: string
|
|
status: string
|
|
version: string
|
|
created_by: string
|
|
approved_by: string | null
|
|
approved_at: string | null
|
|
effective_date: string | null
|
|
review_date: string | null
|
|
created_at: string
|
|
}
|
|
|
|
interface ISMSContext {
|
|
id: string
|
|
internal_issues: Record<string, unknown>[] | null
|
|
external_issues: Record<string, unknown>[] | null
|
|
interested_parties: Record<string, unknown>[] | null
|
|
regulatory_requirements: string[]
|
|
contractual_requirements: string[]
|
|
swot_strengths: string[]
|
|
swot_weaknesses: string[]
|
|
swot_opportunities: string[]
|
|
swot_threats: string[]
|
|
status: string
|
|
created_at: string
|
|
}
|
|
|
|
interface ISMSPolicy {
|
|
id: string
|
|
policy_id: string
|
|
title: string
|
|
policy_type: string
|
|
description: string
|
|
policy_text: string
|
|
applies_to: string[]
|
|
review_frequency_months: number
|
|
related_controls: string[]
|
|
status: string
|
|
version: string
|
|
authored_by: string
|
|
reviewed_by: string | null
|
|
approved_by: string | null
|
|
approved_at: string | null
|
|
effective_date: string | null
|
|
next_review_date: string | null
|
|
created_at: string
|
|
}
|
|
|
|
interface SecurityObjective {
|
|
id: string
|
|
objective_id: string
|
|
title: string
|
|
description: string
|
|
category: string
|
|
kpi_name: string
|
|
kpi_target: number
|
|
kpi_unit: string
|
|
measurement_frequency: string
|
|
owner: string
|
|
target_date: string
|
|
progress_percentage: number
|
|
status: string
|
|
achieved_date: string | null
|
|
}
|
|
|
|
interface SoAEntry {
|
|
id: string
|
|
annex_a_control: string
|
|
annex_a_title: string
|
|
annex_a_category: string
|
|
is_applicable: boolean
|
|
applicability_justification: string
|
|
implementation_status: string
|
|
implementation_notes: string
|
|
breakpilot_control_ids: string[]
|
|
coverage_level: string
|
|
evidence_description: string
|
|
version: string
|
|
approved_at: string | null
|
|
}
|
|
|
|
interface InternalAudit {
|
|
id: string
|
|
audit_id: string
|
|
title: string
|
|
audit_type: string
|
|
scope_description: string
|
|
iso_chapters_covered: string[]
|
|
planned_date: string
|
|
actual_end_date: string | null
|
|
lead_auditor: string
|
|
audit_team: string[]
|
|
status: string
|
|
total_findings: number
|
|
major_findings: number
|
|
minor_findings: number
|
|
ofi_count: number
|
|
audit_conclusion: string | null
|
|
overall_assessment: string | null
|
|
}
|
|
|
|
interface AuditFinding {
|
|
id: string
|
|
finding_id: string
|
|
finding_type: string
|
|
iso_chapter: string
|
|
annex_a_control: string | null
|
|
title: string
|
|
description: string
|
|
objective_evidence: string
|
|
owner: string
|
|
auditor: string
|
|
status: string
|
|
due_date: string | null
|
|
closed_date: string | null
|
|
internal_audit_id: string | null
|
|
is_blocking: boolean
|
|
}
|
|
|
|
interface CAPA {
|
|
id: string
|
|
capa_id: string
|
|
finding_id: string
|
|
capa_type: string
|
|
title: string
|
|
description: string
|
|
assigned_to: string
|
|
status: string
|
|
planned_completion: string
|
|
actual_completion: string | null
|
|
effectiveness_verified: boolean | null
|
|
}
|
|
|
|
interface ManagementReview {
|
|
id: string
|
|
review_id: string
|
|
title: string
|
|
review_date: string
|
|
review_period_start: string
|
|
review_period_end: string
|
|
chairperson: string
|
|
attendees: Record<string, unknown>[] | null
|
|
status: string
|
|
approved_by: string | null
|
|
approved_at: string | null
|
|
next_review_date: string | null
|
|
action_items: Record<string, unknown>[] | null
|
|
}
|
|
|
|
interface ReadinessCheck {
|
|
id: string
|
|
check_date: string
|
|
overall_status: string
|
|
certification_possible: boolean
|
|
readiness_score: number
|
|
chapter_4_status: string
|
|
chapter_5_status: string
|
|
chapter_6_status: string
|
|
chapter_7_status: string
|
|
chapter_8_status: string
|
|
chapter_9_status: string
|
|
chapter_10_status: string
|
|
potential_majors: PotentialFinding[]
|
|
potential_minors: PotentialFinding[]
|
|
priority_actions: string[]
|
|
}
|
|
|
|
interface PotentialFinding {
|
|
check: string
|
|
status: string
|
|
recommendation: string
|
|
iso_reference: string
|
|
}
|
|
|
|
type TabId = 'overview' | 'policies' | 'soa' | 'objectives' | 'audits' | 'reviews'
|
|
|
|
const API = '/api/sdk/v1/isms'
|
|
|
|
// =============================================================================
|
|
// HELPER COMPONENTS
|
|
// =============================================================================
|
|
|
|
function StatusBadge({ status, size = 'sm' }: { status: string; size?: 'sm' | 'md' }) {
|
|
const colors: Record<string, string> = {
|
|
ready: 'bg-green-100 text-green-700',
|
|
compliant: 'bg-green-100 text-green-700',
|
|
approved: 'bg-green-100 text-green-700',
|
|
pass: 'bg-green-100 text-green-700',
|
|
implemented: 'bg-green-100 text-green-700',
|
|
completed: 'bg-green-100 text-green-700',
|
|
verified: 'bg-green-100 text-green-700',
|
|
achieved: 'bg-green-100 text-green-700',
|
|
closed: 'bg-green-100 text-green-700',
|
|
at_risk: 'bg-yellow-100 text-yellow-700',
|
|
partial: 'bg-yellow-100 text-yellow-700',
|
|
warning: 'bg-yellow-100 text-yellow-700',
|
|
planned: 'bg-blue-100 text-blue-700',
|
|
draft: 'bg-gray-100 text-gray-700',
|
|
active: 'bg-blue-100 text-blue-700',
|
|
in_progress: 'bg-blue-100 text-blue-700',
|
|
not_ready: 'bg-red-100 text-red-700',
|
|
non_compliant: 'bg-red-100 text-red-700',
|
|
fail: 'bg-red-100 text-red-700',
|
|
open: 'bg-red-100 text-red-700',
|
|
corrective_action_pending: 'bg-orange-100 text-orange-700',
|
|
verification_pending: 'bg-yellow-100 text-yellow-700',
|
|
}
|
|
const cls = colors[status] || 'bg-gray-100 text-gray-600'
|
|
const labels: Record<string, string> = {
|
|
ready: 'Bereit', not_ready: 'Nicht bereit', at_risk: 'Risiko',
|
|
compliant: 'Konform', non_compliant: 'Nicht konform', partial: 'Teilweise',
|
|
approved: 'Genehmigt', draft: 'Entwurf', pass: 'Bestanden', fail: 'Fehlgeschlagen',
|
|
warning: 'Warnung', implemented: 'Implementiert', planned: 'Geplant',
|
|
active: 'Aktiv', achieved: 'Erreicht', completed: 'Abgeschlossen',
|
|
open: 'Offen', closed: 'Geschlossen', verified: 'Verifiziert',
|
|
corrective_action_pending: 'CAPA ausstehend', verification_pending: 'Verifizierung',
|
|
in_progress: 'In Bearbeitung',
|
|
not_applicable: 'N/A',
|
|
}
|
|
const pad = size === 'md' ? 'px-3 py-1 text-sm' : 'px-2 py-0.5 text-xs'
|
|
return <span className={`${cls} ${pad} rounded-full font-medium`}>{labels[status] || status}</span>
|
|
}
|
|
|
|
function StatCard({ label, value, sub, color = 'purple' }: { label: string; value: string | number; sub?: string; color?: string }) {
|
|
const colors: Record<string, string> = {
|
|
purple: 'border-purple-200 bg-purple-50',
|
|
green: 'border-green-200 bg-green-50',
|
|
red: 'border-red-200 bg-red-50',
|
|
yellow: 'border-yellow-200 bg-yellow-50',
|
|
blue: 'border-blue-200 bg-blue-50',
|
|
}
|
|
return (
|
|
<div className={`border rounded-xl p-4 ${colors[color] || colors.purple}`}>
|
|
<div className="text-2xl font-bold text-gray-900">{value}</div>
|
|
<div className="text-sm font-medium text-gray-700 mt-1">{label}</div>
|
|
{sub && <div className="text-xs text-gray-500 mt-0.5">{sub}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function LoadingSpinner() {
|
|
return (
|
|
<div className="flex items-center justify-center py-20">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EmptyState({ text, action, onAction }: { text: string; action?: string; onAction?: () => void }) {
|
|
return (
|
|
<div className="text-center py-16 text-gray-500">
|
|
<p>{text}</p>
|
|
{action && onAction && (
|
|
<button onClick={onAction} className="mt-3 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm">
|
|
{action}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// TAB: OVERVIEW
|
|
// =============================================================================
|
|
|
|
function OverviewTab() {
|
|
const [overview, setOverview] = useState<ISMSOverview | null>(null)
|
|
const [readiness, setReadiness] = useState<ReadinessCheck | null>(null)
|
|
const [scope, setScope] = useState<ISMSScope | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [running, setRunning] = useState(false)
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const [ovRes, rdRes, scRes] = await Promise.allSettled([
|
|
fetch(`${API}/overview`),
|
|
fetch(`${API}/readiness-check/latest`),
|
|
fetch(`${API}/scope`),
|
|
])
|
|
if (ovRes.status === 'fulfilled' && ovRes.value.ok) setOverview(await ovRes.value.json())
|
|
if (rdRes.status === 'fulfilled' && rdRes.value.ok) setReadiness(await rdRes.value.json())
|
|
if (scRes.status === 'fulfilled' && scRes.value.ok) setScope(await scRes.value.json())
|
|
} catch { /* ignore */ }
|
|
setLoading(false)
|
|
}, [])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
const runCheck = async () => {
|
|
setRunning(true)
|
|
try {
|
|
const res = await fetch(`${API}/readiness-check`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ triggered_by: 'admin-ui' }),
|
|
})
|
|
if (res.ok) {
|
|
setReadiness(await res.json())
|
|
load()
|
|
}
|
|
} catch { /* ignore */ }
|
|
setRunning(false)
|
|
}
|
|
|
|
if (loading) return <LoadingSpinner />
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Readiness Score */}
|
|
<div className="bg-white border rounded-xl p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-900">ISO 27001 Zertifizierungsbereitschaft</h3>
|
|
<button
|
|
onClick={runCheck}
|
|
disabled={running}
|
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 text-sm"
|
|
>
|
|
{running ? 'Pruefe...' : 'Readiness-Check starten'}
|
|
</button>
|
|
</div>
|
|
|
|
{overview && (
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
|
<StatCard
|
|
label="Bereitschaft"
|
|
value={`${Math.round(overview.certification_readiness)}%`}
|
|
color={overview.certification_readiness >= 80 ? 'green' : overview.certification_readiness >= 50 ? 'yellow' : 'red'}
|
|
/>
|
|
<StatCard label="Policies" value={`${overview.policies_approved}/${overview.policies_count}`} sub="genehmigt" color="blue" />
|
|
<StatCard label="Ziele" value={`${overview.objectives_achieved}/${overview.objectives_count}`} sub="erreicht" color="blue" />
|
|
<StatCard label="Major Findings" value={overview.open_major_findings} color={overview.open_major_findings > 0 ? 'red' : 'green'} />
|
|
<StatCard label="Minor Findings" value={overview.open_minor_findings} color={overview.open_minor_findings > 0 ? 'yellow' : 'green'} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Chapter Overview */}
|
|
{overview?.chapters && (
|
|
<div>
|
|
<h4 className="font-medium text-gray-700 mb-3">ISO 27001 Kapitel-Status</h4>
|
|
<div className="space-y-2">
|
|
{overview.chapters.map(ch => (
|
|
<div key={ch.chapter} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
|
<span className="font-mono text-sm font-bold text-gray-600 w-8">Kap.{ch.chapter}</span>
|
|
<span className="flex-1 text-sm font-medium text-gray-800">{ch.title}</span>
|
|
<div className="w-32">
|
|
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full ${ch.completion_percentage >= 80 ? 'bg-green-500' : ch.completion_percentage >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
|
style={{ width: `${ch.completion_percentage}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<span className="text-xs text-gray-500 w-10 text-right">{Math.round(ch.completion_percentage)}%</span>
|
|
<StatusBadge status={ch.status} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Readiness Findings */}
|
|
{readiness && (
|
|
<div className="bg-white border rounded-xl p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-900">Readiness-Check Ergebnis</h3>
|
|
<div className="flex items-center gap-2">
|
|
<StatusBadge status={readiness.overall_status} size="md" />
|
|
<span className="text-sm text-gray-500">Score: {Math.round(readiness.readiness_score)}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
{readiness.potential_majors.length > 0 && (
|
|
<div className="mb-4">
|
|
<h4 className="text-sm font-medium text-red-700 mb-2">Potenzielle Major-Findings ({readiness.potential_majors.length})</h4>
|
|
<div className="space-y-2">
|
|
{readiness.potential_majors.map((f, i) => (
|
|
<div key={i} className="flex items-start gap-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
|
<span className="text-red-500 mt-0.5">✖</span>
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900">{f.check}</div>
|
|
<div className="text-xs text-gray-600 mt-0.5">{f.recommendation}</div>
|
|
<div className="text-xs text-gray-400 mt-0.5">ISO Referenz: {f.iso_reference}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{readiness.potential_minors.length > 0 && (
|
|
<div className="mb-4">
|
|
<h4 className="text-sm font-medium text-yellow-700 mb-2">Potenzielle Minor-Findings ({readiness.potential_minors.length})</h4>
|
|
<div className="space-y-2">
|
|
{readiness.potential_minors.map((f, i) => (
|
|
<div key={i} className="flex items-start gap-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
|
<span className="text-yellow-500 mt-0.5">⚠</span>
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900">{f.check}</div>
|
|
<div className="text-xs text-gray-600 mt-0.5">{f.recommendation}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{readiness.priority_actions.length > 0 && (
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Prioritaere Massnahmen</h4>
|
|
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
|
|
{readiness.priority_actions.map((a, i) => <li key={i}>{a}</li>)}
|
|
</ol>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Scope Summary */}
|
|
{scope && (
|
|
<div className="bg-white border rounded-xl p-6">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-lg font-semibold text-gray-900">ISMS Scope (Kap. 4.3)</h3>
|
|
<StatusBadge status={scope.status} size="md" />
|
|
</div>
|
|
<p className="text-sm text-gray-700 mb-3">{scope.scope_statement}</p>
|
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<span className="font-medium text-gray-600">Standorte:</span>
|
|
<ul className="list-disc list-inside text-gray-700 mt-1">
|
|
{scope.included_locations?.map((l, i) => <li key={i}>{l}</li>)}
|
|
</ul>
|
|
</div>
|
|
<div>
|
|
<span className="font-medium text-gray-600">Prozesse:</span>
|
|
<ul className="list-disc list-inside text-gray-700 mt-1">
|
|
{scope.included_processes?.map((p, i) => <li key={i}>{p}</li>)}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
{scope.approved_by && (
|
|
<div className="mt-3 text-xs text-gray-500">
|
|
Genehmigt von {scope.approved_by} am {new Date(scope.approved_at!).toLocaleDateString('de-DE')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// TAB: POLICIES
|
|
// =============================================================================
|
|
|
|
function PoliciesTab() {
|
|
const [policies, setPolicies] = useState<ISMSPolicy[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [filter, setFilter] = useState<string>('')
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const url = filter ? `${API}/policies?policy_type=${filter}` : `${API}/policies`
|
|
const res = await fetch(url)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setPolicies(data.policies || [])
|
|
}
|
|
} catch { /* ignore */ }
|
|
setLoading(false)
|
|
}, [filter])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
const createPolicy = async (form: Record<string, unknown>) => {
|
|
try {
|
|
const res = await fetch(`${API}/policies`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(form),
|
|
})
|
|
if (res.ok) { setShowCreate(false); load() }
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
const approvePolicy = async (policyId: string) => {
|
|
try {
|
|
await fetch(`${API}/policies/${policyId}/approve`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
reviewed_by: 'admin',
|
|
approved_by: 'admin',
|
|
effective_date: new Date().toISOString().split('T')[0],
|
|
}),
|
|
})
|
|
load()
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
if (loading) return <LoadingSpinner />
|
|
|
|
const policyTypes = ['master', 'topic', 'operational', 'standard']
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex gap-2">
|
|
<button onClick={() => setFilter('')} className={`px-3 py-1.5 rounded-lg text-sm ${!filter ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}>Alle</button>
|
|
{policyTypes.map(t => (
|
|
<button key={t} onClick={() => setFilter(t)} className={`px-3 py-1.5 rounded-lg text-sm capitalize ${filter === t ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}>{t}</button>
|
|
))}
|
|
</div>
|
|
<button onClick={() => setShowCreate(true)} className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm">Neue Policy</button>
|
|
</div>
|
|
|
|
{policies.length === 0 ? (
|
|
<EmptyState text="Keine Policies vorhanden" action="Policy erstellen" onAction={() => setShowCreate(true)} />
|
|
) : (
|
|
<div className="space-y-3">
|
|
{policies.map(p => (
|
|
<div key={p.id} className="bg-white border rounded-xl p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs text-gray-500">{p.policy_id}</span>
|
|
<span className="text-sm font-medium text-gray-900">{p.title}</span>
|
|
<StatusBadge status={p.status} />
|
|
<span className="text-xs text-gray-400">v{p.version}</span>
|
|
</div>
|
|
<p className="text-xs text-gray-600 mt-1 line-clamp-2">{p.description}</p>
|
|
<div className="flex gap-3 mt-1 text-xs text-gray-500">
|
|
<span>Typ: {p.policy_type}</span>
|
|
<span>Review: alle {p.review_frequency_months} Monate</span>
|
|
{p.next_review_date && <span>Naechste Review: {new Date(p.next_review_date).toLocaleDateString('de-DE')}</span>}
|
|
</div>
|
|
</div>
|
|
{p.status === 'draft' && (
|
|
<button onClick={() => approvePolicy(p.id)} className="px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 text-xs">Genehmigen</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create Modal */}
|
|
{showCreate && (
|
|
<PolicyCreateModal onClose={() => setShowCreate(false)} onSave={createPolicy} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PolicyCreateModal({ onClose, onSave }: { onClose: () => void; onSave: (data: Record<string, unknown>) => void }) {
|
|
const [form, setForm] = useState({
|
|
policy_id: '', title: '', policy_type: 'topic', description: '', policy_text: '',
|
|
applies_to: ['Alle Mitarbeiter'], review_frequency_months: 12, related_controls: [] as string[],
|
|
authored_by: 'admin',
|
|
})
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
|
|
<h3 className="text-lg font-semibold mb-4">Neue ISMS Policy</h3>
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Policy-ID</label>
|
|
<input value={form.policy_id} onChange={e => setForm({ ...form, policy_id: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="z.B. POL-SEC-001" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Typ</label>
|
|
<select value={form.policy_type} onChange={e => setForm({ ...form, policy_type: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm">
|
|
<option value="master">Master Policy</option>
|
|
<option value="topic">Topic Policy</option>
|
|
<option value="operational">Operational</option>
|
|
<option value="standard">Standard</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Titel</label>
|
|
<input value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Beschreibung</label>
|
|
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" rows={2} />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Policy-Text</label>
|
|
<textarea value={form.policy_text} onChange={e => setForm({ ...form, policy_text: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" rows={5} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Review-Frequenz (Monate)</label>
|
|
<input type="number" value={form.review_frequency_months} onChange={e => setForm({ ...form, review_frequency_months: Number(e.target.value) })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Autor</label>
|
|
<input value={form.authored_by} onChange={e => setForm({ ...form, authored_by: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 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={() => onSave(form)} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Erstellen</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// TAB: SOA (Statement of Applicability)
|
|
// =============================================================================
|
|
|
|
function SoATab() {
|
|
const [entries, setEntries] = useState<SoAEntry[]>([])
|
|
const [stats, setStats] = useState({ applicable: 0, notApplicable: 0, implemented: 0, planned: 0 })
|
|
const [loading, setLoading] = useState(true)
|
|
const [filterStatus, setFilterStatus] = useState('')
|
|
const [filterApplicable, setFilterApplicable] = useState<string>('')
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const params = new URLSearchParams()
|
|
if (filterStatus) params.set('implementation_status', filterStatus)
|
|
if (filterApplicable) params.set('is_applicable', filterApplicable)
|
|
const res = await fetch(`${API}/soa?${params}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setEntries(data.entries || [])
|
|
setStats({
|
|
applicable: data.applicable_count || 0,
|
|
notApplicable: data.not_applicable_count || 0,
|
|
implemented: data.implemented_count || 0,
|
|
planned: data.planned_count || 0,
|
|
})
|
|
}
|
|
} catch { /* ignore */ }
|
|
setLoading(false)
|
|
}, [filterStatus, filterApplicable])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
if (loading) return <LoadingSpinner />
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-4 gap-3">
|
|
<StatCard label="Anwendbar" value={stats.applicable} color="blue" />
|
|
<StatCard label="Nicht anwendbar" value={stats.notApplicable} color="purple" />
|
|
<StatCard label="Implementiert" value={stats.implemented} color="green" />
|
|
<StatCard label="Geplant" value={stats.planned} color="yellow" />
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex gap-2">
|
|
<select value={filterApplicable} onChange={e => setFilterApplicable(e.target.value)} className="border rounded-lg px-3 py-1.5 text-sm">
|
|
<option value="">Alle Controls</option>
|
|
<option value="true">Anwendbar</option>
|
|
<option value="false">Nicht anwendbar</option>
|
|
</select>
|
|
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="border rounded-lg px-3 py-1.5 text-sm">
|
|
<option value="">Alle Status</option>
|
|
<option value="implemented">Implementiert</option>
|
|
<option value="planned">Geplant</option>
|
|
<option value="not_started">Nicht gestartet</option>
|
|
<option value="not_applicable">N/A</option>
|
|
</select>
|
|
</div>
|
|
|
|
{entries.length === 0 ? (
|
|
<EmptyState text="Noch keine SoA-Eintraege vorhanden. Erstellen Sie Eintraege fuer alle 93 Annex-A-Controls." />
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-gray-50 text-left">
|
|
<th className="px-3 py-2 font-medium text-gray-600">Control</th>
|
|
<th className="px-3 py-2 font-medium text-gray-600">Titel</th>
|
|
<th className="px-3 py-2 font-medium text-gray-600">Kategorie</th>
|
|
<th className="px-3 py-2 font-medium text-gray-600">Anwendbar</th>
|
|
<th className="px-3 py-2 font-medium text-gray-600">Status</th>
|
|
<th className="px-3 py-2 font-medium text-gray-600">Coverage</th>
|
|
<th className="px-3 py-2 font-medium text-gray-600">Version</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{entries.map(e => (
|
|
<tr key={e.id} className="border-t hover:bg-gray-50">
|
|
<td className="px-3 py-2 font-mono text-xs">{e.annex_a_control}</td>
|
|
<td className="px-3 py-2 text-gray-800">{e.annex_a_title}</td>
|
|
<td className="px-3 py-2 text-gray-600">{e.annex_a_category}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={`px-2 py-0.5 rounded-full text-xs ${e.is_applicable ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}>
|
|
{e.is_applicable ? 'Ja' : 'Nein'}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2"><StatusBadge status={e.implementation_status} /></td>
|
|
<td className="px-3 py-2 text-xs text-gray-600">{e.coverage_level || '-'}</td>
|
|
<td className="px-3 py-2 text-xs text-gray-400">v{e.version}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// TAB: OBJECTIVES
|
|
// =============================================================================
|
|
|
|
function ObjectivesTab() {
|
|
const [objectives, setObjectives] = useState<SecurityObjective[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch(`${API}/objectives`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setObjectives(data.objectives || [])
|
|
}
|
|
} catch { /* ignore */ }
|
|
setLoading(false)
|
|
}, [])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
const createObjective = async (form: Record<string, unknown>) => {
|
|
try {
|
|
const res = await fetch(`${API}/objectives?created_by=admin`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(form),
|
|
})
|
|
if (res.ok) { setShowCreate(false); load() }
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
if (loading) return <LoadingSpinner />
|
|
|
|
const active = objectives.filter(o => o.status === 'active')
|
|
const achieved = objectives.filter(o => o.status === 'achieved')
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex gap-3 text-sm text-gray-600">
|
|
<span>Aktiv: {active.length}</span>
|
|
<span>Erreicht: {achieved.length}</span>
|
|
</div>
|
|
<button onClick={() => setShowCreate(true)} className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm">Neues Ziel</button>
|
|
</div>
|
|
|
|
{objectives.length === 0 ? (
|
|
<EmptyState text="Keine Sicherheitsziele definiert" action="Ziel erstellen" onAction={() => setShowCreate(true)} />
|
|
) : (
|
|
<div className="space-y-3">
|
|
{objectives.map(o => (
|
|
<div key={o.id} className="bg-white border rounded-xl p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs text-gray-500">{o.objective_id}</span>
|
|
<span className="text-sm font-medium text-gray-900">{o.title}</span>
|
|
<StatusBadge status={o.status} />
|
|
</div>
|
|
<span className="text-sm font-bold text-purple-600">{o.progress_percentage}%</span>
|
|
</div>
|
|
<div className="h-2 bg-gray-200 rounded-full overflow-hidden mb-2">
|
|
<div
|
|
className={`h-full rounded-full transition-all ${o.progress_percentage >= 100 ? 'bg-green-500' : 'bg-purple-500'}`}
|
|
style={{ width: `${Math.min(100, o.progress_percentage)}%` }}
|
|
/>
|
|
</div>
|
|
<div className="flex gap-4 text-xs text-gray-500">
|
|
<span>KPI: {o.kpi_name} — Ziel: {o.kpi_target} {o.kpi_unit}</span>
|
|
<span>Verantwortlich: {o.owner}</span>
|
|
<span>Zieldatum: {new Date(o.target_date).toLocaleDateString('de-DE')}</span>
|
|
<span>Messung: {o.measurement_frequency}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{showCreate && (
|
|
<ObjectiveCreateModal onClose={() => setShowCreate(false)} onSave={createObjective} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ObjectiveCreateModal({ onClose, onSave }: { onClose: () => void; onSave: (data: Record<string, unknown>) => void }) {
|
|
const [form, setForm] = useState({
|
|
objective_id: '', title: '', description: '', category: 'confidentiality',
|
|
specific: '', measurable: '', achievable: '', relevant: '', time_bound: '',
|
|
kpi_name: '', kpi_target: 95, kpi_unit: '%', measurement_frequency: 'monthly',
|
|
owner: '', target_date: '', related_controls: [] as string[], related_risks: [] as string[],
|
|
})
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
|
|
<h3 className="text-lg font-semibold mb-4">Neues Sicherheitsziel (SMART)</h3>
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Ziel-ID</label>
|
|
<input value={form.objective_id} onChange={e => setForm({ ...form, objective_id: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="OBJ-001" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Kategorie</label>
|
|
<select value={form.category} onChange={e => setForm({ ...form, category: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm">
|
|
<option value="confidentiality">Vertraulichkeit</option>
|
|
<option value="integrity">Integritaet</option>
|
|
<option value="availability">Verfuegbarkeit</option>
|
|
<option value="compliance">Compliance</option>
|
|
<option value="awareness">Awareness</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Titel</label>
|
|
<input value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Beschreibung</label>
|
|
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" rows={2} />
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">KPI Name</label>
|
|
<input value={form.kpi_name} onChange={e => setForm({ ...form, kpi_name: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="Patch-Rate" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Zielwert</label>
|
|
<input type="number" value={form.kpi_target} onChange={e => setForm({ ...form, kpi_target: Number(e.target.value) })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Einheit</label>
|
|
<input value={form.kpi_unit} onChange={e => setForm({ ...form, kpi_unit: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Verantwortlich</label>
|
|
<input value={form.owner} onChange={e => setForm({ ...form, owner: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Zieldatum</label>
|
|
<input type="date" value={form.target_date} onChange={e => setForm({ ...form, target_date: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 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={() => onSave(form)} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Erstellen</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// TAB: AUDITS (Internal Audits + Findings + CAPA)
|
|
// =============================================================================
|
|
|
|
function AuditsTab() {
|
|
const [audits, setAudits] = useState<InternalAudit[]>([])
|
|
const [findings, setFindings] = useState<AuditFinding[]>([])
|
|
const [capas, setCAPAs] = useState<CAPA[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [subTab, setSubTab] = useState<'audits' | 'findings' | 'capa'>('audits')
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const [aRes, fRes, cRes] = await Promise.all([
|
|
fetch(`${API}/internal-audits`),
|
|
fetch(`${API}/findings`),
|
|
fetch(`${API}/capa`),
|
|
])
|
|
if (aRes.ok) { const d = await aRes.json(); setAudits(d.audits || []) }
|
|
if (fRes.ok) { const d = await fRes.json(); setFindings(d.findings || []) }
|
|
if (cRes.ok) { const d = await cRes.json(); setCAPAs(d.actions || []) }
|
|
} catch { /* ignore */ }
|
|
setLoading(false)
|
|
}, [])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
if (loading) return <LoadingSpinner />
|
|
|
|
const openFindings = findings.filter(f => f.status !== 'closed')
|
|
const majors = findings.filter(f => f.finding_type === 'major')
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-4 gap-3">
|
|
<StatCard label="Interne Audits" value={audits.length} color="blue" />
|
|
<StatCard label="Offene Findings" value={openFindings.length} color={openFindings.length > 0 ? 'red' : 'green'} />
|
|
<StatCard label="Major Findings" value={majors.length} color={majors.length > 0 ? 'red' : 'green'} />
|
|
<StatCard label="CAPAs" value={capas.length} color="purple" />
|
|
</div>
|
|
|
|
{/* Sub-tabs */}
|
|
<div className="flex gap-2 border-b">
|
|
{(['audits', 'findings', 'capa'] as const).map(t => (
|
|
<button key={t} onClick={() => setSubTab(t)}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${subTab === t ? 'border-purple-600 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
|
>
|
|
{t === 'audits' ? 'Interne Audits' : t === 'findings' ? 'Findings' : 'CAPA'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{subTab === 'audits' && (
|
|
<div className="space-y-3">
|
|
{audits.length === 0 ? <EmptyState text="Noch keine internen Audits geplant" /> : audits.map(a => (
|
|
<div key={a.id} className="bg-white border rounded-xl p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs text-gray-500">{a.audit_id}</span>
|
|
<span className="text-sm font-medium text-gray-900">{a.title}</span>
|
|
<StatusBadge status={a.status} />
|
|
</div>
|
|
<div className="flex gap-3 text-xs text-gray-500 mt-1">
|
|
<span>Typ: {a.audit_type}</span>
|
|
<span>Datum: {new Date(a.planned_date).toLocaleDateString('de-DE')}</span>
|
|
<span>Auditor: {a.lead_auditor}</span>
|
|
<span>Findings: {a.total_findings || 0} (Major: {a.major_findings || 0}, Minor: {a.minor_findings || 0})</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{a.audit_conclusion && (
|
|
<p className="text-xs text-gray-600 mt-2 bg-gray-50 rounded p-2">{a.audit_conclusion}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{subTab === 'findings' && (
|
|
<div className="space-y-3">
|
|
{findings.length === 0 ? <EmptyState text="Keine Audit-Findings vorhanden" /> : findings.map(f => (
|
|
<div key={f.id} className={`bg-white border rounded-xl p-4 ${f.is_blocking ? 'border-red-300' : ''}`}>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs text-gray-500">{f.finding_id}</span>
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
f.finding_type === 'major' ? 'bg-red-100 text-red-700' :
|
|
f.finding_type === 'minor' ? 'bg-yellow-100 text-yellow-700' :
|
|
f.finding_type === 'ofi' ? 'bg-blue-100 text-blue-700' :
|
|
'bg-green-100 text-green-700'
|
|
}`}>{f.finding_type.toUpperCase()}</span>
|
|
<span className="text-sm font-medium text-gray-900">{f.title}</span>
|
|
<StatusBadge status={f.status} />
|
|
{f.is_blocking && <span className="px-2 py-0.5 bg-red-600 text-white text-xs rounded-full">Blockiert</span>}
|
|
</div>
|
|
<p className="text-xs text-gray-600 mt-1">{f.description}</p>
|
|
<div className="flex gap-3 text-xs text-gray-500 mt-1">
|
|
<span>ISO: {f.iso_chapter}</span>
|
|
<span>Verantwortlich: {f.owner}</span>
|
|
<span>Auditor: {f.auditor}</span>
|
|
{f.due_date && <span>Frist: {new Date(f.due_date).toLocaleDateString('de-DE')}</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{subTab === 'capa' && (
|
|
<div className="space-y-3">
|
|
{capas.length === 0 ? <EmptyState text="Keine Korrektur-/Vorbeugungsmassnahmen vorhanden" /> : capas.map(c => (
|
|
<div key={c.id} className="bg-white border rounded-xl p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs text-gray-500">{c.capa_id}</span>
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${c.capa_type === 'corrective' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'}`}>
|
|
{c.capa_type === 'corrective' ? 'Korrektur' : 'Vorbeugung'}
|
|
</span>
|
|
<span className="text-sm font-medium text-gray-900">{c.title}</span>
|
|
<StatusBadge status={c.status} />
|
|
</div>
|
|
<div className="flex gap-3 text-xs text-gray-500 mt-1">
|
|
<span>Zustaendig: {c.assigned_to}</span>
|
|
<span>Ziel: {new Date(c.planned_completion).toLocaleDateString('de-DE')}</span>
|
|
{c.actual_completion && <span>Abgeschlossen: {new Date(c.actual_completion).toLocaleDateString('de-DE')}</span>}
|
|
{c.effectiveness_verified !== null && (
|
|
<span className={c.effectiveness_verified ? 'text-green-600' : 'text-red-600'}>
|
|
Wirksamkeit: {c.effectiveness_verified ? 'Bestaetigt' : 'Nicht bestaetigt'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// TAB: MANAGEMENT REVIEWS
|
|
// =============================================================================
|
|
|
|
function ReviewsTab() {
|
|
const [reviews, setReviews] = useState<ManagementReview[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch(`${API}/management-reviews`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setReviews(data.reviews || [])
|
|
}
|
|
} catch { /* ignore */ }
|
|
setLoading(false)
|
|
}, [])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
const createReview = async (form: Record<string, unknown>) => {
|
|
try {
|
|
const res = await fetch(`${API}/management-reviews?created_by=admin`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(form),
|
|
})
|
|
if (res.ok) { setShowCreate(false); load() }
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
const approveReview = async (reviewId: string) => {
|
|
const nextYear = new Date()
|
|
nextYear.setFullYear(nextYear.getFullYear() + 1)
|
|
try {
|
|
await fetch(`${API}/management-reviews/${reviewId}/approve`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
approved_by: 'admin',
|
|
next_review_date: nextYear.toISOString().split('T')[0],
|
|
}),
|
|
})
|
|
load()
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
if (loading) return <LoadingSpinner />
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-medium text-gray-600">{reviews.length} Management-Reviews</h3>
|
|
<button onClick={() => setShowCreate(true)} className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm">Neue Review</button>
|
|
</div>
|
|
|
|
{reviews.length === 0 ? (
|
|
<EmptyState text="Keine Management-Reviews vorhanden" action="Review planen" onAction={() => setShowCreate(true)} />
|
|
) : (
|
|
<div className="space-y-3">
|
|
{reviews.map(r => (
|
|
<div key={r.id} className="bg-white border rounded-xl p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs text-gray-500">{r.review_id}</span>
|
|
<span className="text-sm font-medium text-gray-900">{r.title}</span>
|
|
<StatusBadge status={r.status} />
|
|
</div>
|
|
<div className="flex gap-3 text-xs text-gray-500 mt-1">
|
|
<span>Datum: {new Date(r.review_date).toLocaleDateString('de-DE')}</span>
|
|
<span>Zeitraum: {new Date(r.review_period_start).toLocaleDateString('de-DE')} - {new Date(r.review_period_end).toLocaleDateString('de-DE')}</span>
|
|
<span>Vorsitz: {r.chairperson}</span>
|
|
{r.next_review_date && <span>Naechste Review: {new Date(r.next_review_date).toLocaleDateString('de-DE')}</span>}
|
|
</div>
|
|
</div>
|
|
{r.status === 'draft' && (
|
|
<button onClick={() => approveReview(r.id)} className="px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 text-xs">Genehmigen</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{showCreate && (
|
|
<ReviewCreateModal onClose={() => setShowCreate(false)} onSave={createReview} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ReviewCreateModal({ onClose, onSave }: { onClose: () => void; onSave: (data: Record<string, unknown>) => void }) {
|
|
const today = new Date().toISOString().split('T')[0]
|
|
const [form, setForm] = useState({
|
|
title: '', review_date: today,
|
|
review_period_start: '', review_period_end: today,
|
|
chairperson: '', attendees: [] as Record<string, unknown>[],
|
|
})
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg p-6">
|
|
<h3 className="text-lg font-semibold mb-4">Neue Management-Review</h3>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Titel</label>
|
|
<input value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="Q1 2026 Management Review" />
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Review-Datum</label>
|
|
<input type="date" value={form.review_date} onChange={e => setForm({ ...form, review_date: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Zeitraum von</label>
|
|
<input type="date" value={form.review_period_start} onChange={e => setForm({ ...form, review_period_start: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Zeitraum bis</label>
|
|
<input type="date" value={form.review_period_end} onChange={e => setForm({ ...form, review_period_end: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Vorsitzender</label>
|
|
<input value={form.chairperson} onChange={e => setForm({ ...form, chairperson: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 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={() => onSave(form)} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Erstellen</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// MAIN PAGE
|
|
// =============================================================================
|
|
|
|
const TABS: { id: TabId; label: string }[] = [
|
|
{ id: 'overview', label: 'Uebersicht' },
|
|
{ id: 'policies', label: 'Policies' },
|
|
{ id: 'soa', label: 'SoA (Annex A)' },
|
|
{ id: 'objectives', label: 'Ziele' },
|
|
{ id: 'audits', label: 'Audits & Findings' },
|
|
{ id: 'reviews', label: 'Management Reviews' },
|
|
]
|
|
|
|
export default function ISMSPage() {
|
|
const [tab, setTab] = useState<TabId>('overview')
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 p-6">
|
|
<div className="max-w-7xl mx-auto">
|
|
{/* Header */}
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl font-bold text-gray-900">ISMS — ISO 27001</h1>
|
|
<p className="text-sm text-gray-600 mt-1">
|
|
Informationssicherheits-Managementsystem: Scope, Policies, SoA, Audits, CAPA und Management-Reviews
|
|
</p>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 bg-white border rounded-xl p-1 mb-6">
|
|
{TABS.map(t => (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => setTab(t.id)}
|
|
className={`flex-1 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
|
tab === t.id
|
|
? 'bg-purple-600 text-white shadow-sm'
|
|
: 'text-gray-600 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
{tab === 'overview' && <OverviewTab />}
|
|
{tab === 'policies' && <PoliciesTab />}
|
|
{tab === 'soa' && <SoATab />}
|
|
{tab === 'objectives' && <ObjectivesTab />}
|
|
{tab === 'audits' && <AuditsTab />}
|
|
{tab === 'reviews' && <ReviewsTab />}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|