fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
370
website/components/compliance/ExpiredEvidenceAlert.tsx
Normal file
370
website/components/compliance/ExpiredEvidenceAlert.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ExpiredEvidenceAlert - Alert-Komponente fuer abgelaufene Nachweise
|
||||
*
|
||||
* Zeigt eine Warnung wenn Evidence-Dokumente abgelaufen sind oder bald ablaufen.
|
||||
* Kann im Dashboard, in der Evidence-Page oder als globaler Alert verwendet werden.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Language } from '@/lib/compliance-i18n'
|
||||
|
||||
export interface ExpiredEvidence {
|
||||
id: string
|
||||
title: string
|
||||
control_id: string
|
||||
control_name: string
|
||||
expired_at: string | null // Ablaufdatum
|
||||
days_expired: number | null // Tage seit Ablauf (positiv = abgelaufen)
|
||||
days_until_expiry: number | null // Tage bis Ablauf (negativ wenn abgelaufen)
|
||||
status: 'expired' | 'expiring_soon' | 'valid'
|
||||
artifact_type: string
|
||||
}
|
||||
|
||||
interface ExpiredEvidenceAlertProps {
|
||||
// Liste der abgelaufenen/ablaufenden Evidence-Eintraege
|
||||
evidenceList?: ExpiredEvidence[]
|
||||
// Auto-fetch vom Backend?
|
||||
autoFetch?: boolean
|
||||
// Variante: 'banner' fuer volle Breite, 'card' fuer kompakt, 'minimal' fuer nur Icon
|
||||
variant?: 'banner' | 'card' | 'minimal'
|
||||
// Sprache
|
||||
language?: Language
|
||||
// Max. Anzahl anzuzeigender Items (fuer 'card' Variante)
|
||||
maxItems?: number
|
||||
// Callback wenn auf "Alle anzeigen" geklickt wird
|
||||
onViewAll?: () => void
|
||||
// Callback wenn auf einzelnes Item geklickt wird
|
||||
onItemClick?: (evidence: ExpiredEvidence) => void
|
||||
// Zusaetzliche CSS-Klassen
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Mock-Daten fuer Demonstration
|
||||
const MOCK_EXPIRED_EVIDENCE: ExpiredEvidence[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'SAST Scan Report Q4/2025',
|
||||
control_id: 'SDLC-001',
|
||||
control_name: 'SAST Scanning',
|
||||
expired_at: '2025-12-31',
|
||||
days_expired: 18,
|
||||
days_until_expiry: -18,
|
||||
status: 'expired',
|
||||
artifact_type: 'scan_report'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Penetration Test Report 2025',
|
||||
control_id: 'SDLC-004',
|
||||
control_name: 'Security Testing',
|
||||
expired_at: '2026-01-15',
|
||||
days_expired: 3,
|
||||
days_until_expiry: -3,
|
||||
status: 'expired',
|
||||
artifact_type: 'pentest_report'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Backup Recovery Test',
|
||||
control_id: 'OPS-002',
|
||||
control_name: 'Backup & Recovery',
|
||||
expired_at: '2026-01-25',
|
||||
days_expired: null,
|
||||
days_until_expiry: 7,
|
||||
status: 'expiring_soon',
|
||||
artifact_type: 'test_result'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Privacy Policy v3.2',
|
||||
control_id: 'PRIV-001',
|
||||
control_name: 'Verarbeitungsverzeichnis',
|
||||
expired_at: '2026-01-30',
|
||||
days_expired: null,
|
||||
days_until_expiry: 12,
|
||||
status: 'expiring_soon',
|
||||
artifact_type: 'policy'
|
||||
}
|
||||
]
|
||||
|
||||
export default function ExpiredEvidenceAlert({
|
||||
evidenceList,
|
||||
autoFetch = false,
|
||||
variant = 'banner',
|
||||
language = 'de',
|
||||
maxItems = 5,
|
||||
onViewAll,
|
||||
onItemClick,
|
||||
className = ''
|
||||
}: ExpiredEvidenceAlertProps) {
|
||||
const router = useRouter()
|
||||
const [evidence, setEvidence] = useState<ExpiredEvidence[]>(evidenceList || [])
|
||||
const [loading, setLoading] = useState(autoFetch)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFetch) {
|
||||
fetchExpiredEvidence()
|
||||
} else if (evidenceList) {
|
||||
setEvidence(evidenceList)
|
||||
} else {
|
||||
// Demo-Modus mit Mock-Daten
|
||||
setEvidence(MOCK_EXPIRED_EVIDENCE)
|
||||
}
|
||||
}, [autoFetch, evidenceList])
|
||||
|
||||
const fetchExpiredEvidence = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch('/api/v1/compliance/evidence/expiring')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setEvidence(data.items || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch expired evidence:', error)
|
||||
// Fallback zu Mock-Daten
|
||||
setEvidence(MOCK_EXPIRED_EVIDENCE)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Aufteilen in abgelaufen und bald ablaufend
|
||||
const expiredItems = evidence.filter(e => e.status === 'expired')
|
||||
const expiringItems = evidence.filter(e => e.status === 'expiring_soon')
|
||||
|
||||
const totalCount = evidence.length
|
||||
const expiredCount = expiredItems.length
|
||||
const expiringCount = expiringItems.length
|
||||
|
||||
// Nichts anzeigen wenn keine problematischen Items
|
||||
if (totalCount === 0 || dismissed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleItemClick = (item: ExpiredEvidence) => {
|
||||
if (onItemClick) {
|
||||
onItemClick(item)
|
||||
} else {
|
||||
router.push(`/admin/compliance/evidence?control=${item.control_id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewAll = () => {
|
||||
if (onViewAll) {
|
||||
onViewAll()
|
||||
} else {
|
||||
router.push('/admin/compliance/evidence?status=expired,expiring_soon')
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal-Variante: Nur Icon mit Badge
|
||||
if (variant === 'minimal') {
|
||||
return (
|
||||
<button
|
||||
onClick={handleViewAll}
|
||||
className={`relative p-2 text-orange-500 hover:text-orange-400 transition-colors ${className}`}
|
||||
title={language === 'de'
|
||||
? `${expiredCount} abgelaufen, ${expiringCount} laufen bald ab`
|
||||
: `${expiredCount} expired, ${expiringCount} expiring soon`
|
||||
}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{totalCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs w-5 h-5 rounded-full flex items-center justify-center font-bold">
|
||||
{totalCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Card-Variante: Kompakte Karte
|
||||
if (variant === 'card') {
|
||||
return (
|
||||
<div className={`bg-slate-800 border border-orange-500/50 rounded-xl overflow-hidden ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="bg-orange-500/10 px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="text-orange-500 font-semibold text-sm">
|
||||
{language === 'de' ? 'Nachweis-Warnungen' : 'Evidence Warnings'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{expiredCount > 0 && (
|
||||
<span className="bg-red-500/20 text-red-400 text-xs px-2 py-0.5 rounded">
|
||||
{expiredCount} {language === 'de' ? 'abgelaufen' : 'expired'}
|
||||
</span>
|
||||
)}
|
||||
{expiringCount > 0 && (
|
||||
<span className="bg-orange-500/20 text-orange-400 text-xs px-2 py-0.5 rounded">
|
||||
{expiringCount} {language === 'de' ? 'bald' : 'soon'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste */}
|
||||
<div className="p-3 space-y-2">
|
||||
{evidence.slice(0, maxItems).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleItemClick(item)}
|
||||
className="w-full text-left bg-slate-700/50 hover:bg-slate-700 rounded-lg p-3 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-medium truncate">{item.title}</p>
|
||||
<p className="text-slate-400 text-xs">
|
||||
{item.control_id} - {item.control_name}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-xs whitespace-nowrap ${
|
||||
item.status === 'expired' ? 'text-red-400' : 'text-orange-400'
|
||||
}`}>
|
||||
{item.status === 'expired'
|
||||
? (language === 'de' ? `${item.days_expired}d abgelaufen` : `${item.days_expired}d expired`)
|
||||
: (language === 'de' ? `noch ${item.days_until_expiry}d` : `${item.days_until_expiry}d left`)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{totalCount > maxItems && (
|
||||
<div className="px-4 py-2 border-t border-slate-700">
|
||||
<button
|
||||
onClick={handleViewAll}
|
||||
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
{language === 'de' ? `Alle ${totalCount} anzeigen` : `View all ${totalCount}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Banner-Variante (default): Volle Breite
|
||||
return (
|
||||
<div className={`rounded-xl overflow-hidden ${className}`}>
|
||||
{/* Main Alert */}
|
||||
<div className={`px-4 py-3 flex items-center justify-between ${
|
||||
expiredCount > 0 ? 'bg-red-500/10 border border-red-500/50' : 'bg-orange-500/10 border border-orange-500/50'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Icon */}
|
||||
<div className={`p-2 rounded-lg ${expiredCount > 0 ? 'bg-red-500/20' : 'bg-orange-500/20'}`}>
|
||||
<svg className={`w-6 h-6 ${expiredCount > 0 ? 'text-red-500' : 'text-orange-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div>
|
||||
<p className={`font-semibold ${expiredCount > 0 ? 'text-red-400' : 'text-orange-400'}`}>
|
||||
{language === 'de'
|
||||
? `${totalCount} Nachweis${totalCount !== 1 ? 'e' : ''} erfordern Aufmerksamkeit`
|
||||
: `${totalCount} evidence item${totalCount !== 1 ? 's' : ''} require attention`
|
||||
}
|
||||
</p>
|
||||
<p className="text-slate-400 text-sm">
|
||||
{expiredCount > 0 && (
|
||||
<span className="text-red-400">
|
||||
{expiredCount} {language === 'de' ? 'abgelaufen' : 'expired'}
|
||||
</span>
|
||||
)}
|
||||
{expiredCount > 0 && expiringCount > 0 && ' | '}
|
||||
{expiringCount > 0 && (
|
||||
<span className="text-orange-400">
|
||||
{expiringCount} {language === 'de' ? 'laufen bald ab' : 'expiring soon'}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="px-3 py-1.5 bg-slate-700 text-slate-300 rounded-lg text-sm hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
{expanded
|
||||
? (language === 'de' ? 'Ausblenden' : 'Hide')
|
||||
: (language === 'de' ? 'Details' : 'Details')
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleViewAll}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
expiredCount > 0
|
||||
? 'bg-red-600 text-white hover:bg-red-500'
|
||||
: 'bg-orange-600 text-white hover:bg-orange-500'
|
||||
}`}
|
||||
>
|
||||
{language === 'de' ? 'Nachweise verwalten' : 'Manage Evidence'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="p-1.5 text-slate-400 hover:text-white transition-colors"
|
||||
title={language === 'de' ? 'Schliessen' : 'Dismiss'}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expanded && (
|
||||
<div className="bg-slate-800/50 border-x border-b border-slate-700 p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{evidence.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleItemClick(item)}
|
||||
className="text-left bg-slate-800 hover:bg-slate-700 rounded-lg p-3 border border-slate-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded ${item.status === 'expired' ? 'bg-red-500/20' : 'bg-orange-500/20'}`}>
|
||||
<svg className={`w-4 h-4 ${item.status === 'expired' ? 'text-red-400' : 'text-orange-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium text-sm truncate">{item.title}</p>
|
||||
<p className="text-slate-400 text-xs">{item.control_id} - {item.control_name}</p>
|
||||
<p className={`text-xs mt-1 ${item.status === 'expired' ? 'text-red-400' : 'text-orange-400'}`}>
|
||||
{item.status === 'expired'
|
||||
? (language === 'de'
|
||||
? `Abgelaufen am ${new Date(item.expired_at!).toLocaleDateString('de-DE')} (${item.days_expired} Tage)`
|
||||
: `Expired on ${new Date(item.expired_at!).toLocaleDateString('en-US')} (${item.days_expired} days)`)
|
||||
: (language === 'de'
|
||||
? `Laeuft ab am ${new Date(item.expired_at!).toLocaleDateString('de-DE')} (noch ${item.days_until_expiry} Tage)`
|
||||
: `Expires on ${new Date(item.expired_at!).toLocaleDateString('en-US')} (${item.days_until_expiry} days left)`)
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
165
website/components/compliance/GlossaryTooltip.tsx
Normal file
165
website/components/compliance/GlossaryTooltip.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* GlossaryTooltip Component
|
||||
*
|
||||
* Displays a term with a hover tooltip that explains the compliance concept.
|
||||
* Supports bilingual content (DE/EN) from the i18n system.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { getTerm, getDescription, Language } from '@/lib/compliance-i18n'
|
||||
|
||||
interface GlossaryTooltipProps {
|
||||
termKey: string
|
||||
lang?: Language
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
showIcon?: boolean
|
||||
}
|
||||
|
||||
export default function GlossaryTooltip({
|
||||
termKey,
|
||||
lang = 'de',
|
||||
children,
|
||||
className = '',
|
||||
showIcon = true,
|
||||
}: GlossaryTooltipProps) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [position, setPosition] = useState<'top' | 'bottom'>('top')
|
||||
const triggerRef = useRef<HTMLSpanElement>(null)
|
||||
const tooltipRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const term = getTerm(lang, termKey)
|
||||
const description = getDescription(lang, termKey)
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && triggerRef.current && tooltipRef.current) {
|
||||
const triggerRect = triggerRef.current.getBoundingClientRect()
|
||||
const tooltipHeight = tooltipRef.current.offsetHeight
|
||||
const spaceAbove = triggerRect.top
|
||||
const spaceBelow = window.innerHeight - triggerRect.bottom
|
||||
|
||||
// Position tooltip where there's more space
|
||||
if (spaceAbove < tooltipHeight + 10 && spaceBelow > spaceAbove) {
|
||||
setPosition('bottom')
|
||||
} else {
|
||||
setPosition('top')
|
||||
}
|
||||
}
|
||||
}, [isVisible])
|
||||
|
||||
if (!description) {
|
||||
// No description available, just render the term
|
||||
return <span className={className}>{children || term}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={triggerRef}
|
||||
className={`relative inline-flex items-center gap-1 cursor-help ${className}`}
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
onFocus={() => setIsVisible(true)}
|
||||
onBlur={() => setIsVisible(false)}
|
||||
tabIndex={0}
|
||||
>
|
||||
{children || term}
|
||||
{showIcon && (
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
{isVisible && (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={`
|
||||
absolute z-50 w-64 p-3 text-sm
|
||||
bg-slate-900 text-white rounded-lg shadow-xl
|
||||
transition-opacity duration-150
|
||||
${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'}
|
||||
left-1/2 -translate-x-1/2
|
||||
`}
|
||||
role="tooltip"
|
||||
>
|
||||
{/* Arrow */}
|
||||
<div
|
||||
className={`
|
||||
absolute left-1/2 -translate-x-1/2 w-0 h-0
|
||||
border-l-8 border-r-8 border-transparent
|
||||
${position === 'top'
|
||||
? 'top-full border-t-8 border-t-slate-900'
|
||||
: 'bottom-full border-b-8 border-b-slate-900'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="font-semibold text-white mb-1">{term}</div>
|
||||
<div className="text-slate-300 text-xs leading-relaxed">{description}</div>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* InfoIcon Component
|
||||
*
|
||||
* A standalone info icon that shows a tooltip on hover.
|
||||
*/
|
||||
interface InfoIconProps {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function InfoIcon({ text, className = '' }: InfoIconProps) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`relative inline-flex items-center cursor-help ${className}`}
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{isVisible && (
|
||||
<div
|
||||
className="
|
||||
absolute z-50 w-56 p-2 text-xs
|
||||
bg-slate-800 text-slate-200 rounded-lg shadow-lg
|
||||
bottom-full mb-2 left-1/2 -translate-x-1/2
|
||||
"
|
||||
>
|
||||
{text}
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-6 border-r-6 border-transparent border-t-6 border-t-slate-800" />
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
252
website/components/compliance/LLMProviderToggle.tsx
Normal file
252
website/components/compliance/LLMProviderToggle.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface AIStatus {
|
||||
provider: string
|
||||
model: string
|
||||
is_available: boolean
|
||||
is_mock: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
interface LLMProviderToggleProps {
|
||||
aiStatus: AIStatus | null
|
||||
onStatusChange?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM Provider Toggle Component
|
||||
*
|
||||
* Allows developers to switch between:
|
||||
* - Anthropic Claude API (Cloud, kostenpflichtig)
|
||||
* - Self-Hosted Ollama (Lokal auf Mac Mini, kostenlos, DSGVO-konform)
|
||||
*/
|
||||
export default function LLMProviderToggle({ aiStatus, onStatusChange }: LLMProviderToggleProps) {
|
||||
const [switching, setSwitching] = useState(false)
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
const isAnthropicActive = aiStatus?.provider === 'anthropic'
|
||||
const isSelfHostedActive = aiStatus?.provider === 'self_hosted'
|
||||
const isMockActive = aiStatus?.provider === 'mock'
|
||||
|
||||
const switchProvider = async (newProvider: 'anthropic' | 'self_hosted') => {
|
||||
setSwitching(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/ai/switch-provider`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: newProvider }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const result = await res.json()
|
||||
console.log('Provider switched:', result)
|
||||
onStatusChange?.()
|
||||
} else {
|
||||
const errorText = await res.text()
|
||||
setError(`Fehler: ${errorText}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to switch provider:', err)
|
||||
setError('Verbindung zum Backend fehlgeschlagen')
|
||||
} finally {
|
||||
setSwitching(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the badge color based on provider
|
||||
const getBadgeColor = () => {
|
||||
if (isMockActive) return 'bg-gray-100 text-gray-700 border-gray-300'
|
||||
if (isSelfHostedActive) return 'bg-green-100 text-green-700 border-green-300'
|
||||
if (isAnthropicActive) return 'bg-purple-100 text-purple-700 border-purple-300'
|
||||
return 'bg-gray-100 text-gray-600 border-gray-200'
|
||||
}
|
||||
|
||||
const getProviderIcon = () => {
|
||||
if (isSelfHostedActive) {
|
||||
// Local/Server icon
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (isAnthropicActive) {
|
||||
// Cloud icon
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
// Mock/Unknown icon
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Toggle Button */}
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
disabled={switching}
|
||||
className={`
|
||||
inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-lg border transition-all
|
||||
${getBadgeColor()}
|
||||
hover:shadow-md cursor-pointer
|
||||
${switching ? 'opacity-50' : ''}
|
||||
`}
|
||||
>
|
||||
{getProviderIcon()}
|
||||
<span>
|
||||
{isSelfHostedActive && 'Lokal (DSGVO)'}
|
||||
{isAnthropicActive && 'Anthropic API'}
|
||||
{isMockActive && 'Mock (Test)'}
|
||||
{!aiStatus && 'Laden...'}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${showDetails ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Panel */}
|
||||
{showDetails && (
|
||||
<div className="absolute right-0 mt-2 w-96 bg-white rounded-xl shadow-xl border border-slate-200 z-50 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200">
|
||||
<h3 className="font-semibold text-slate-800">KI-Provider Einstellungen</h3>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Wechseln Sie zwischen Cloud-API und lokalem Modell
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current Status */}
|
||||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-slate-500">Aktuell:</span>
|
||||
<span className="font-medium text-slate-800">{aiStatus?.provider || 'Unbekannt'}</span>
|
||||
<span className="text-slate-400">|</span>
|
||||
<span className="text-slate-600 text-xs">{aiStatus?.model || 'Kein Modell'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider Options */}
|
||||
<div className="p-3 space-y-2">
|
||||
{/* Self-Hosted Option */}
|
||||
<button
|
||||
onClick={() => switchProvider('self_hosted')}
|
||||
disabled={switching || isSelfHostedActive}
|
||||
className={`
|
||||
w-full p-3 rounded-lg border text-left transition-all
|
||||
${isSelfHostedActive
|
||||
? 'bg-green-50 border-green-300 ring-2 ring-green-200'
|
||||
: 'bg-white border-slate-200 hover:border-green-300 hover:bg-green-50'
|
||||
}
|
||||
${switching ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${isSelfHostedActive ? 'bg-green-200' : 'bg-slate-100'}`}>
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-slate-800">Self-Hosted (Ollama)</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">DSGVO</span>
|
||||
{isSelfHostedActive && (
|
||||
<span className="px-2 py-0.5 text-xs bg-green-500 text-white rounded-full">Aktiv</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Lokales LLM auf dem Mac Mini M4 Pro (64GB RAM).
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs">
|
||||
<span className="text-green-600 font-medium">Kostenlos</span>
|
||||
<span className="text-green-600">Daten bleiben intern</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Anthropic Option */}
|
||||
<button
|
||||
onClick={() => switchProvider('anthropic')}
|
||||
disabled={switching || isAnthropicActive}
|
||||
className={`
|
||||
w-full p-3 rounded-lg border text-left transition-all
|
||||
${isAnthropicActive
|
||||
? 'bg-purple-50 border-purple-300 ring-2 ring-purple-200'
|
||||
: 'bg-white border-slate-200 hover:border-purple-300 hover:bg-purple-50'
|
||||
}
|
||||
${switching ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${isAnthropicActive ? 'bg-purple-200' : 'bg-slate-100'}`}>
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-slate-800">Anthropic Claude API</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-yellow-100 text-yellow-700 rounded-full">Cloud</span>
|
||||
{isAnthropicActive && (
|
||||
<span className="px-2 py-0.5 text-xs bg-purple-500 text-white rounded-full">Aktiv</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Cloud-basierte KI von Anthropic (claude-sonnet-4).
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs">
|
||||
<span className="text-yellow-600 font-medium">Kostenpflichtig</span>
|
||||
<span className="text-yellow-600">Daten gehen zu Anthropic</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="px-4 py-2 bg-red-50 border-t border-red-200">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="px-4 py-3 bg-slate-50 border-t border-slate-200">
|
||||
<p className="text-xs text-slate-500">
|
||||
<strong>Hinweis:</strong> Die Umschaltung gilt nur fuer diese Session.
|
||||
Fuer permanente Aenderungen docker-compose.yml anpassen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Click outside to close */}
|
||||
{showDetails && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setShowDetails(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
website/components/compliance/LanguageSwitch.tsx
Normal file
109
website/components/compliance/LanguageSwitch.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* LanguageSwitch Component
|
||||
*
|
||||
* Toggle button for switching between German and English terminology.
|
||||
* Stores preference in localStorage.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Language, DEFAULT_LANGUAGE } from '@/lib/compliance-i18n'
|
||||
|
||||
interface LanguageSwitchProps {
|
||||
onChange?: (lang: Language) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'breakpilot-compliance-lang'
|
||||
|
||||
export default function LanguageSwitch({ onChange, className = '' }: LanguageSwitchProps) {
|
||||
const [language, setLanguage] = useState<Language>(DEFAULT_LANGUAGE)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Load language from localStorage on mount
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
const stored = localStorage.getItem(STORAGE_KEY) as Language | null
|
||||
if (stored && (stored === 'de' || stored === 'en')) {
|
||||
setLanguage(stored)
|
||||
onChange?.(stored)
|
||||
}
|
||||
}, [onChange])
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const newLang: Language = language === 'de' ? 'en' : 'de'
|
||||
setLanguage(newLang)
|
||||
localStorage.setItem(STORAGE_KEY, newLang)
|
||||
onChange?.(newLang)
|
||||
}
|
||||
|
||||
// Avoid hydration mismatch
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className={`h-8 w-16 bg-slate-100 rounded-full animate-pulse ${className}`} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleLanguage}
|
||||
className={`
|
||||
relative inline-flex items-center h-8 px-1
|
||||
bg-slate-100 hover:bg-slate-200
|
||||
rounded-full transition-colors
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2
|
||||
${className}
|
||||
`}
|
||||
title={language === 'de' ? 'Switch to English' : 'Zu Deutsch wechseln'}
|
||||
aria-label={language === 'de' ? 'Switch to English' : 'Zu Deutsch wechseln'}
|
||||
>
|
||||
{/* DE Button */}
|
||||
<span
|
||||
className={`
|
||||
px-2 py-1 text-xs font-semibold rounded-full transition-all
|
||||
${language === 'de'
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
DE
|
||||
</span>
|
||||
|
||||
{/* EN Button */}
|
||||
<span
|
||||
className={`
|
||||
px-2 py-1 text-xs font-semibold rounded-full transition-all
|
||||
${language === 'en'
|
||||
? 'bg-white text-slate-900 shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
EN
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get and set compliance language
|
||||
*/
|
||||
export function useComplianceLanguage(): [Language, (lang: Language) => void] {
|
||||
const [language, setLanguage] = useState<Language>(DEFAULT_LANGUAGE)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY) as Language | null
|
||||
if (stored && (stored === 'de' || stored === 'en')) {
|
||||
setLanguage(stored)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setAndStore = (lang: Language) => {
|
||||
setLanguage(lang)
|
||||
localStorage.setItem(STORAGE_KEY, lang)
|
||||
}
|
||||
|
||||
return [language, setAndStore]
|
||||
}
|
||||
238
website/components/compliance/charts/ComplianceTrendChart.tsx
Normal file
238
website/components/compliance/charts/ComplianceTrendChart.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ComplianceTrendChart Component
|
||||
*
|
||||
* Displays compliance score trend over time using Recharts.
|
||||
* Shows 12-month history with interactive tooltip.
|
||||
*/
|
||||
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Area,
|
||||
AreaChart,
|
||||
} from 'recharts'
|
||||
import { Language, getTerm } from '@/lib/compliance-i18n'
|
||||
|
||||
interface TrendDataPoint {
|
||||
date: string
|
||||
score: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface ComplianceTrendChartProps {
|
||||
data: TrendDataPoint[]
|
||||
lang?: Language
|
||||
height?: number
|
||||
showArea?: boolean
|
||||
}
|
||||
|
||||
export default function ComplianceTrendChart({
|
||||
data,
|
||||
lang = 'de',
|
||||
height = 200,
|
||||
showArea = true,
|
||||
}: ComplianceTrendChartProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center text-slate-400 text-sm"
|
||||
style={{ height }}
|
||||
>
|
||||
{lang === 'de' ? 'Keine Trenddaten verfuegbar' : 'No trend data available'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return '#22c55e' // green-500
|
||||
if (score >= 60) return '#eab308' // yellow-500
|
||||
return '#ef4444' // red-500
|
||||
}
|
||||
|
||||
const latestScore = data[data.length - 1]?.score || 0
|
||||
const strokeColor = getScoreColor(latestScore)
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const score = payload[0].value
|
||||
return (
|
||||
<div className="bg-white px-3 py-2 rounded-lg shadow-lg border border-slate-200">
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
<p className="text-lg font-bold" style={{ color: getScoreColor(score) }}>
|
||||
{score.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (showArea) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={data} margin={{ top: 5, right: 5, left: -20, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorScore" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={strokeColor} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={strokeColor} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
axisLine={{ stroke: '#e2e8f0' }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
axisLine={{ stroke: '#e2e8f0' }}
|
||||
tickLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="score"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorScore)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 5, left: -20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
axisLine={{ stroke: '#e2e8f0' }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
axisLine={{ stroke: '#e2e8f0' }}
|
||||
tickLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="score"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={2}
|
||||
dot={{ fill: strokeColor, strokeWidth: 2, r: 3 }}
|
||||
activeDot={{ r: 5, stroke: strokeColor, strokeWidth: 2, fill: 'white' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* TrafficLightIndicator Component
|
||||
*
|
||||
* Large circular indicator showing overall compliance status.
|
||||
*/
|
||||
interface TrafficLightIndicatorProps {
|
||||
status: 'green' | 'yellow' | 'red'
|
||||
score: number
|
||||
lang?: Language
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export function TrafficLightIndicator({
|
||||
status,
|
||||
score,
|
||||
lang = 'de',
|
||||
size = 'lg'
|
||||
}: TrafficLightIndicatorProps) {
|
||||
const colors = {
|
||||
green: { bg: 'bg-green-500', ring: 'ring-green-200', text: 'text-green-700' },
|
||||
yellow: { bg: 'bg-yellow-500', ring: 'ring-yellow-200', text: 'text-yellow-700' },
|
||||
red: { bg: 'bg-red-500', ring: 'ring-red-200', text: 'text-red-700' },
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: { container: 'w-16 h-16', text: 'text-lg', label: 'text-xs' },
|
||||
md: { container: 'w-24 h-24', text: 'text-2xl', label: 'text-sm' },
|
||||
lg: { container: 'w-32 h-32', text: 'text-4xl', label: 'text-base' },
|
||||
}
|
||||
|
||||
const labels = {
|
||||
green: { de: 'Gut', en: 'Good' },
|
||||
yellow: { de: 'Achtung', en: 'Attention' },
|
||||
red: { de: 'Kritisch', en: 'Critical' },
|
||||
}
|
||||
|
||||
const { bg, ring, text } = colors[status]
|
||||
const { container, text: textSize, label: labelSize } = sizes[size]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`
|
||||
${container} rounded-full flex items-center justify-center
|
||||
${bg} ring-4 ${ring} shadow-lg
|
||||
transition-all duration-300
|
||||
`}
|
||||
>
|
||||
<span className={`${textSize} font-bold text-white`}>
|
||||
{score.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<span className={`mt-2 ${labelSize} font-medium ${text}`}>
|
||||
{labels[status][lang]}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* MiniSparkline Component
|
||||
*
|
||||
* Tiny inline chart for trend indication.
|
||||
*/
|
||||
interface MiniSparklineProps {
|
||||
data: number[]
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export function MiniSparkline({ data, width = 60, height = 20 }: MiniSparklineProps) {
|
||||
if (!data || data.length < 2) {
|
||||
return <span className="text-slate-300">--</span>
|
||||
}
|
||||
|
||||
const chartData = data.map((value, index) => ({ value, index }))
|
||||
const trend = data[data.length - 1] - data[0]
|
||||
const color = trend >= 0 ? '#22c55e' : '#ef4444'
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width={width} height={height}>
|
||||
<LineChart data={chartData}>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
566
website/components/compliance/charts/DependencyMap.tsx
Normal file
566
website/components/compliance/charts/DependencyMap.tsx
Normal file
@@ -0,0 +1,566 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DependencyMap Component
|
||||
*
|
||||
* Visualizes the relationship between Controls and Requirements.
|
||||
* Shows which controls satisfy which regulatory requirements.
|
||||
*
|
||||
* Sprint 5: PDF Reports & Erweiterte Visualisierungen
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Language, getTerm } from '@/lib/compliance-i18n'
|
||||
|
||||
interface Requirement {
|
||||
id: string
|
||||
article: string
|
||||
title: string
|
||||
regulation_code: string
|
||||
}
|
||||
|
||||
interface Control {
|
||||
id: string
|
||||
control_id: string
|
||||
title: string
|
||||
domain: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Mapping {
|
||||
requirement_id: string
|
||||
control_id: string
|
||||
coverage_level: 'full' | 'partial' | 'planned'
|
||||
}
|
||||
|
||||
interface DependencyMapProps {
|
||||
requirements: Requirement[]
|
||||
controls: Control[]
|
||||
mappings: Mapping[]
|
||||
lang?: Language
|
||||
onControlClick?: (control: Control) => void
|
||||
onRequirementClick?: (requirement: Requirement) => void
|
||||
}
|
||||
|
||||
const DOMAIN_COLORS: Record<string, string> = {
|
||||
gov: '#64748b',
|
||||
priv: '#3b82f6',
|
||||
iam: '#a855f7',
|
||||
crypto: '#eab308',
|
||||
sdlc: '#22c55e',
|
||||
ops: '#f97316',
|
||||
ai: '#ec4899',
|
||||
cra: '#06b6d4',
|
||||
aud: '#6366f1',
|
||||
}
|
||||
|
||||
const DOMAIN_LABELS: Record<string, string> = {
|
||||
gov: 'Governance',
|
||||
priv: 'Datenschutz',
|
||||
iam: 'Identity & Access',
|
||||
crypto: 'Kryptografie',
|
||||
sdlc: 'Secure Dev',
|
||||
ops: 'Operations',
|
||||
ai: 'KI-spezifisch',
|
||||
cra: 'Supply Chain',
|
||||
aud: 'Audit',
|
||||
}
|
||||
|
||||
const COVERAGE_COLORS: Record<string, { bg: string; border: string; text: string }> = {
|
||||
full: { bg: 'bg-green-100', border: 'border-green-500', text: 'text-green-700' },
|
||||
partial: { bg: 'bg-yellow-100', border: 'border-yellow-500', text: 'text-yellow-700' },
|
||||
planned: { bg: 'bg-slate-100', border: 'border-slate-400', text: 'text-slate-600' },
|
||||
}
|
||||
|
||||
export default function DependencyMap({
|
||||
requirements,
|
||||
controls,
|
||||
mappings,
|
||||
lang = 'de',
|
||||
onControlClick,
|
||||
onRequirementClick,
|
||||
}: DependencyMapProps) {
|
||||
const [selectedControl, setSelectedControl] = useState<string | null>(null)
|
||||
const [selectedRequirement, setSelectedRequirement] = useState<string | null>(null)
|
||||
const [filterRegulation, setFilterRegulation] = useState<string>('')
|
||||
const [filterDomain, setFilterDomain] = useState<string>('')
|
||||
const [viewMode, setViewMode] = useState<'matrix' | 'sankey'>('matrix')
|
||||
|
||||
// Get unique regulations
|
||||
const regulations = useMemo(() => {
|
||||
const regs = new Set(requirements.map((r) => r.regulation_code))
|
||||
return Array.from(regs).sort()
|
||||
}, [requirements])
|
||||
|
||||
// Get unique domains
|
||||
const domains = useMemo(() => {
|
||||
const doms = new Set(controls.map((c) => c.domain))
|
||||
return Array.from(doms).sort()
|
||||
}, [controls])
|
||||
|
||||
// Filter requirements and controls
|
||||
const filteredRequirements = useMemo(() => {
|
||||
return requirements.filter((r) => {
|
||||
if (filterRegulation && r.regulation_code !== filterRegulation) return false
|
||||
return true
|
||||
})
|
||||
}, [requirements, filterRegulation])
|
||||
|
||||
const filteredControls = useMemo(() => {
|
||||
return controls.filter((c) => {
|
||||
if (filterDomain && c.domain !== filterDomain) return false
|
||||
return true
|
||||
})
|
||||
}, [controls, filterDomain])
|
||||
|
||||
// Build mapping lookup
|
||||
const mappingLookup = useMemo(() => {
|
||||
const lookup: Record<string, Record<string, Mapping>> = {}
|
||||
mappings.forEach((m) => {
|
||||
if (!lookup[m.control_id]) lookup[m.control_id] = {}
|
||||
lookup[m.control_id][m.requirement_id] = m
|
||||
})
|
||||
return lookup
|
||||
}, [mappings])
|
||||
|
||||
// Get connected requirements for a control
|
||||
const getConnectedRequirements = (controlId: string) => {
|
||||
return Object.keys(mappingLookup[controlId] || {})
|
||||
}
|
||||
|
||||
// Get connected controls for a requirement
|
||||
const getConnectedControls = (requirementId: string) => {
|
||||
return Object.keys(mappingLookup)
|
||||
.filter((controlId) => mappingLookup[controlId][requirementId])
|
||||
.map((controlId) => ({
|
||||
controlId,
|
||||
coverage: mappingLookup[controlId][requirementId].coverage_level,
|
||||
}))
|
||||
}
|
||||
|
||||
// Handle control selection
|
||||
const handleControlClick = (control: Control) => {
|
||||
if (selectedControl === control.control_id) {
|
||||
setSelectedControl(null)
|
||||
} else {
|
||||
setSelectedControl(control.control_id)
|
||||
setSelectedRequirement(null)
|
||||
}
|
||||
onControlClick?.(control)
|
||||
}
|
||||
|
||||
// Handle requirement selection
|
||||
const handleRequirementClick = (requirement: Requirement) => {
|
||||
if (selectedRequirement === requirement.id) {
|
||||
setSelectedRequirement(null)
|
||||
} else {
|
||||
setSelectedRequirement(requirement.id)
|
||||
setSelectedControl(null)
|
||||
}
|
||||
onRequirementClick?.(requirement)
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const stats = useMemo(() => {
|
||||
const totalMappings = mappings.length
|
||||
const fullMappings = mappings.filter((m) => m.coverage_level === 'full').length
|
||||
const partialMappings = mappings.filter((m) => m.coverage_level === 'partial').length
|
||||
const plannedMappings = mappings.filter((m) => m.coverage_level === 'planned').length
|
||||
|
||||
const coveredRequirements = new Set(mappings.map((m) => m.requirement_id)).size
|
||||
const usedControls = new Set(mappings.map((m) => m.control_id)).size
|
||||
|
||||
return {
|
||||
totalMappings,
|
||||
fullMappings,
|
||||
partialMappings,
|
||||
plannedMappings,
|
||||
coveredRequirements,
|
||||
totalRequirements: requirements.length,
|
||||
usedControls,
|
||||
totalControls: controls.length,
|
||||
coveragePercent: ((coveredRequirements / requirements.length) * 100).toFixed(1),
|
||||
}
|
||||
}, [mappings, requirements, controls])
|
||||
|
||||
if (requirements.length === 0 || controls.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-8 text-center">
|
||||
<svg className="w-16 h-16 text-slate-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p className="text-slate-500">
|
||||
{lang === 'de' ? 'Keine Mappings vorhanden' : 'No mappings available'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Statistics Header */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<p className="text-sm text-slate-500">
|
||||
{lang === 'de' ? 'Abdeckung' : 'Coverage'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-primary-600">{stats.coveragePercent}%</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{stats.coveredRequirements}/{stats.totalRequirements} {lang === 'de' ? 'Anforderungen' : 'Requirements'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<p className="text-sm text-slate-500">
|
||||
{lang === 'de' ? 'Vollstaendig' : 'Full'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-green-600">{stats.fullMappings}</p>
|
||||
<p className="text-xs text-slate-400">{lang === 'de' ? 'Mappings' : 'Mappings'}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<p className="text-sm text-slate-500">
|
||||
{lang === 'de' ? 'Teilweise' : 'Partial'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">{stats.partialMappings}</p>
|
||||
<p className="text-xs text-slate-400">{lang === 'de' ? 'Mappings' : 'Mappings'}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<p className="text-sm text-slate-500">
|
||||
{lang === 'de' ? 'Geplant' : 'Planned'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-slate-600">{stats.plannedMappings}</p>
|
||||
<p className="text-xs text-slate-400">{lang === 'de' ? 'Mappings' : 'Mappings'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<select
|
||||
value={filterRegulation}
|
||||
onChange={(e) => setFilterRegulation(e.target.value)}
|
||||
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">{lang === 'de' ? 'Alle Verordnungen' : 'All Regulations'}</option>
|
||||
{regulations.map((reg) => (
|
||||
<option key={reg} value={reg}>{reg}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterDomain}
|
||||
onChange={(e) => setFilterDomain(e.target.value)}
|
||||
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">{lang === 'de' ? 'Alle Domains' : 'All Domains'}</option>
|
||||
{domains.map((dom) => (
|
||||
<option key={dom} value={dom}>{DOMAIN_LABELS[dom] || dom}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex bg-slate-100 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('matrix')}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
viewMode === 'matrix' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
Matrix
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('sankey')}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
viewMode === 'sankey' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{lang === 'de' ? 'Verbindungen' : 'Connections'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Visualization */}
|
||||
{viewMode === 'matrix' ? (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4 overflow-auto">
|
||||
<div className="min-w-[800px]">
|
||||
{/* Matrix Header */}
|
||||
<div className="flex">
|
||||
<div className="w-48 flex-shrink-0" />
|
||||
<div className="flex-1 flex">
|
||||
{filteredControls.map((control) => (
|
||||
<div
|
||||
key={control.control_id}
|
||||
onClick={() => handleControlClick(control)}
|
||||
className={`
|
||||
w-20 flex-shrink-0 text-center p-2 cursor-pointer transition-colors
|
||||
${selectedControl === control.control_id ? 'bg-primary-100' : 'hover:bg-slate-50'}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mx-auto mb-1"
|
||||
style={{ backgroundColor: DOMAIN_COLORS[control.domain] || '#94a3b8' }}
|
||||
/>
|
||||
<p className="text-xs font-mono font-medium truncate" title={control.control_id}>
|
||||
{control.control_id}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Matrix Body */}
|
||||
{filteredRequirements.map((req) => {
|
||||
const connectedControls = getConnectedControls(req.id)
|
||||
const isHighlighted = selectedRequirement === req.id ||
|
||||
(selectedControl && connectedControls.some((c) => c.controlId === selectedControl))
|
||||
|
||||
return (
|
||||
<div
|
||||
key={req.id}
|
||||
className={`flex border-t ${isHighlighted ? 'bg-primary-50' : ''}`}
|
||||
>
|
||||
<div
|
||||
onClick={() => handleRequirementClick(req)}
|
||||
className={`
|
||||
w-48 flex-shrink-0 p-2 cursor-pointer transition-colors
|
||||
${selectedRequirement === req.id ? 'bg-primary-100' : 'hover:bg-slate-50'}
|
||||
`}
|
||||
>
|
||||
<p className="text-xs font-medium text-slate-700 truncate" title={req.article}>
|
||||
{req.regulation_code} {req.article}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 truncate" title={req.title}>
|
||||
{req.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 flex">
|
||||
{filteredControls.map((control) => {
|
||||
const mapping = mappingLookup[control.control_id]?.[req.id]
|
||||
const isControlHighlighted = selectedControl === control.control_id
|
||||
const isConnected = selectedControl && mapping
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${req.id}-${control.control_id}`}
|
||||
className={`
|
||||
w-20 flex-shrink-0 h-10 flex items-center justify-center
|
||||
${isControlHighlighted ? 'bg-primary-50' : ''}
|
||||
${isConnected ? 'ring-2 ring-primary-400' : ''}
|
||||
`}
|
||||
>
|
||||
{mapping && (
|
||||
<div
|
||||
className={`
|
||||
w-6 h-6 rounded flex items-center justify-center
|
||||
${COVERAGE_COLORS[mapping.coverage_level].bg}
|
||||
${COVERAGE_COLORS[mapping.coverage_level].text}
|
||||
border ${COVERAGE_COLORS[mapping.coverage_level].border}
|
||||
`}
|
||||
title={`${mapping.coverage_level === 'full' ? 'Vollstaendig' : mapping.coverage_level === 'partial' ? 'Teilweise' : 'Geplant'}`}
|
||||
>
|
||||
{mapping.coverage_level === 'full' && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
{mapping.coverage_level === 'partial' && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
)}
|
||||
{mapping.coverage_level === 'planned' && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Sankey/Connection View */
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex gap-8">
|
||||
{/* Controls Column */}
|
||||
<div className="w-1/3 space-y-2">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">
|
||||
Controls ({filteredControls.length})
|
||||
</h4>
|
||||
{filteredControls.map((control) => {
|
||||
const connectedReqs = getConnectedRequirements(control.control_id)
|
||||
const isSelected = selectedControl === control.control_id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={control.control_id}
|
||||
onClick={() => handleControlClick(control)}
|
||||
className={`
|
||||
w-full text-left p-3 rounded-lg border transition-all
|
||||
${isSelected ? 'border-primary-500 bg-primary-50 shadow' : 'border-slate-200 hover:border-slate-300'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: DOMAIN_COLORS[control.domain] || '#94a3b8' }}
|
||||
/>
|
||||
<span className="font-mono text-sm font-medium">{control.control_id}</span>
|
||||
<span className="text-xs text-slate-400 ml-auto">{connectedReqs.length}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1 truncate">{control.title}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Connection Lines (simplified) */}
|
||||
<div className="w-1/3 flex items-center justify-center">
|
||||
<div className="relative w-full h-full min-h-[200px]">
|
||||
{selectedControl && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
|
||||
{getConnectedRequirements(selectedControl).slice(0, 10).map((reqId, idx) => {
|
||||
const req = requirements.find((r) => r.id === reqId)
|
||||
const mapping = mappingLookup[selectedControl][reqId]
|
||||
if (!req) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={reqId}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-full text-xs font-medium
|
||||
${COVERAGE_COLORS[mapping.coverage_level].bg}
|
||||
${COVERAGE_COLORS[mapping.coverage_level].text}
|
||||
border ${COVERAGE_COLORS[mapping.coverage_level].border}
|
||||
`}
|
||||
>
|
||||
{req.regulation_code} {req.article}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{getConnectedRequirements(selectedControl).length > 10 && (
|
||||
<span className="text-xs text-slate-400">
|
||||
+{getConnectedRequirements(selectedControl).length - 10} {lang === 'de' ? 'weitere' : 'more'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectedRequirement && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
|
||||
{getConnectedControls(selectedRequirement).slice(0, 10).map(({ controlId, coverage }) => {
|
||||
const control = controls.find((c) => c.control_id === controlId)
|
||||
if (!control) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={controlId}
|
||||
className={`
|
||||
px-3 py-1.5 rounded-full text-xs font-medium
|
||||
${COVERAGE_COLORS[coverage].bg}
|
||||
${COVERAGE_COLORS[coverage].text}
|
||||
border ${COVERAGE_COLORS[coverage].border}
|
||||
`}
|
||||
>
|
||||
{control.control_id}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!selectedControl && !selectedRequirement && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<p className="text-sm text-slate-400 text-center">
|
||||
{lang === 'de'
|
||||
? 'Waehlen Sie ein Control oder eine Anforderung aus'
|
||||
: 'Select a control or requirement'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements Column */}
|
||||
<div className="w-1/3 space-y-2">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3">
|
||||
{lang === 'de' ? 'Anforderungen' : 'Requirements'} ({filteredRequirements.length})
|
||||
</h4>
|
||||
{filteredRequirements.slice(0, 15).map((req) => {
|
||||
const connectedCtrls = getConnectedControls(req.id)
|
||||
const isSelected = selectedRequirement === req.id
|
||||
const isHighlighted = selectedControl && connectedCtrls.some((c) => c.controlId === selectedControl)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={req.id}
|
||||
onClick={() => handleRequirementClick(req)}
|
||||
className={`
|
||||
w-full text-left p-3 rounded-lg border transition-all
|
||||
${isSelected ? 'border-primary-500 bg-primary-50 shadow' : ''}
|
||||
${isHighlighted && !isSelected ? 'border-primary-300 bg-primary-25' : ''}
|
||||
${!isSelected && !isHighlighted ? 'border-slate-200 hover:border-slate-300' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-slate-600">{req.regulation_code}</span>
|
||||
<span className="font-mono text-sm">{req.article}</span>
|
||||
<span className="text-xs text-slate-400 ml-auto">{connectedCtrls.length}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1 truncate">{req.title}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{filteredRequirements.length > 15 && (
|
||||
<p className="text-xs text-slate-400 text-center py-2">
|
||||
+{filteredRequirements.length - 15} {lang === 'de' ? 'weitere' : 'more'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<div className="flex flex-wrap gap-6 justify-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-6 h-6 rounded flex items-center justify-center ${COVERAGE_COLORS.full.bg} ${COVERAGE_COLORS.full.text} border ${COVERAGE_COLORS.full.border}`}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-slate-600">
|
||||
{lang === 'de' ? 'Vollstaendig abgedeckt' : 'Fully covered'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-6 h-6 rounded flex items-center justify-center ${COVERAGE_COLORS.partial.bg} ${COVERAGE_COLORS.partial.text} border ${COVERAGE_COLORS.partial.border}`}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-slate-600">
|
||||
{lang === 'de' ? 'Teilweise abgedeckt' : 'Partially covered'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-6 h-6 rounded flex items-center justify-center ${COVERAGE_COLORS.planned.bg} ${COVERAGE_COLORS.planned.text} border ${COVERAGE_COLORS.planned.border}`}>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm text-slate-600">
|
||||
{lang === 'de' ? 'Geplant' : 'Planned'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
680
website/components/compliance/charts/RiskHeatmap.tsx
Normal file
680
website/components/compliance/charts/RiskHeatmap.tsx
Normal file
@@ -0,0 +1,680 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* RiskHeatmap Component
|
||||
*
|
||||
* Enhanced risk matrix visualization with:
|
||||
* - 5x5 likelihood x impact matrix
|
||||
* - Drill-down on matrix cells
|
||||
* - Category filtering
|
||||
* - Inherent vs Residual comparison view
|
||||
* - Linked controls display
|
||||
*
|
||||
* Sprint 5: PDF Reports & Erweiterte Visualisierungen
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Language, getTerm } from '@/lib/compliance-i18n'
|
||||
|
||||
export interface Risk {
|
||||
id: string
|
||||
risk_id: string
|
||||
title: string
|
||||
description?: string
|
||||
category: string
|
||||
likelihood: number
|
||||
impact: number
|
||||
inherent_risk: string
|
||||
mitigating_controls?: string[] | null
|
||||
residual_likelihood?: number | null
|
||||
residual_impact?: number | null
|
||||
residual_risk?: string | null
|
||||
owner?: string
|
||||
status: string
|
||||
treatment_plan?: string
|
||||
}
|
||||
|
||||
export interface Control {
|
||||
id: string
|
||||
control_id: string
|
||||
title: string
|
||||
domain: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface RiskHeatmapProps {
|
||||
risks: Risk[]
|
||||
controls?: Control[]
|
||||
lang?: Language
|
||||
onRiskClick?: (risk: Risk) => void
|
||||
onCellClick?: (likelihood: number, impact: number, risks: Risk[]) => void
|
||||
showComparison?: boolean
|
||||
height?: number
|
||||
}
|
||||
|
||||
const RISK_LEVEL_COLORS: Record<string, { bg: string; text: string; border: string }> = {
|
||||
low: { bg: 'bg-green-100', text: 'text-green-700', border: 'border-green-300' },
|
||||
medium: { bg: 'bg-yellow-100', text: 'text-yellow-700', border: 'border-yellow-300' },
|
||||
high: { bg: 'bg-orange-100', text: 'text-orange-700', border: 'border-orange-300' },
|
||||
critical: { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-300' },
|
||||
}
|
||||
|
||||
const RISK_BADGE_COLORS: Record<string, string> = {
|
||||
low: 'bg-green-500',
|
||||
medium: 'bg-yellow-500',
|
||||
high: 'bg-orange-500',
|
||||
critical: 'bg-red-500',
|
||||
}
|
||||
|
||||
const CATEGORY_OPTIONS: Record<string, { de: string; en: string }> = {
|
||||
data_breach: { de: 'Datenschutzverletzung', en: 'Data Breach' },
|
||||
compliance_gap: { de: 'Compliance-Luecke', en: 'Compliance Gap' },
|
||||
vendor_risk: { de: 'Lieferantenrisiko', en: 'Vendor Risk' },
|
||||
operational: { de: 'Betriebsrisiko', en: 'Operational Risk' },
|
||||
technical: { de: 'Technisches Risiko', en: 'Technical Risk' },
|
||||
legal: { de: 'Rechtliches Risiko', en: 'Legal Risk' },
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: Record<string, { de: string; en: string; color: string }> = {
|
||||
open: { de: 'Offen', en: 'Open', color: 'bg-slate-100 text-slate-700' },
|
||||
mitigated: { de: 'Gemindert', en: 'Mitigated', color: 'bg-green-100 text-green-700' },
|
||||
accepted: { de: 'Akzeptiert', en: 'Accepted', color: 'bg-blue-100 text-blue-700' },
|
||||
transferred: { de: 'Uebertragen', en: 'Transferred', color: 'bg-purple-100 text-purple-700' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate risk level from likelihood and impact
|
||||
*/
|
||||
export const calculateRiskLevel = (likelihood: number, impact: number): string => {
|
||||
const score = likelihood * impact
|
||||
if (score >= 20) return 'critical'
|
||||
if (score >= 12) return 'high'
|
||||
if (score >= 6) return 'medium'
|
||||
return 'low'
|
||||
}
|
||||
|
||||
export default function RiskHeatmap({
|
||||
risks,
|
||||
controls = [],
|
||||
lang = 'de',
|
||||
onRiskClick,
|
||||
onCellClick,
|
||||
showComparison = false,
|
||||
height = 400,
|
||||
}: RiskHeatmapProps) {
|
||||
const [viewMode, setViewMode] = useState<'inherent' | 'residual' | 'comparison'>('inherent')
|
||||
const [filterCategory, setFilterCategory] = useState<string>('')
|
||||
const [filterStatus, setFilterStatus] = useState<string>('')
|
||||
const [selectedCell, setSelectedCell] = useState<{ l: number; i: number } | null>(null)
|
||||
const [selectedRisk, setSelectedRisk] = useState<Risk | null>(null)
|
||||
|
||||
// Get unique categories from risks
|
||||
const categories = useMemo(() => {
|
||||
const cats = new Set(risks.map((r) => r.category))
|
||||
return Array.from(cats).sort()
|
||||
}, [risks])
|
||||
|
||||
// Filter risks
|
||||
const filteredRisks = useMemo(() => {
|
||||
return risks.filter((r) => {
|
||||
if (filterCategory && r.category !== filterCategory) return false
|
||||
if (filterStatus && r.status !== filterStatus) return false
|
||||
return true
|
||||
})
|
||||
}, [risks, filterCategory, filterStatus])
|
||||
|
||||
// Build matrix data structure for inherent risk
|
||||
const inherentMatrix = useMemo(() => {
|
||||
const matrix: Record<number, Record<number, Risk[]>> = {}
|
||||
for (let l = 1; l <= 5; l++) {
|
||||
matrix[l] = {}
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
matrix[l][i] = []
|
||||
}
|
||||
}
|
||||
filteredRisks.forEach((risk) => {
|
||||
if (matrix[risk.likelihood] && matrix[risk.likelihood][risk.impact]) {
|
||||
matrix[risk.likelihood][risk.impact].push(risk)
|
||||
}
|
||||
})
|
||||
return matrix
|
||||
}, [filteredRisks])
|
||||
|
||||
// Build matrix data structure for residual risk
|
||||
const residualMatrix = useMemo(() => {
|
||||
const matrix: Record<number, Record<number, Risk[]>> = {}
|
||||
for (let l = 1; l <= 5; l++) {
|
||||
matrix[l] = {}
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
matrix[l][i] = []
|
||||
}
|
||||
}
|
||||
filteredRisks.forEach((risk) => {
|
||||
const likelihood = risk.residual_likelihood ?? risk.likelihood
|
||||
const impact = risk.residual_impact ?? risk.impact
|
||||
if (matrix[likelihood] && matrix[likelihood][impact]) {
|
||||
matrix[likelihood][impact].push(risk)
|
||||
}
|
||||
})
|
||||
return matrix
|
||||
}, [filteredRisks])
|
||||
|
||||
// Get controls for a risk
|
||||
const getControlsForRisk = (risk: Risk): Control[] => {
|
||||
if (!risk.mitigating_controls || risk.mitigating_controls.length === 0) return []
|
||||
return controls.filter((c) => risk.mitigating_controls?.includes(c.control_id))
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const stats = useMemo(() => {
|
||||
const total = filteredRisks.length
|
||||
const byLevel: Record<string, number> = { low: 0, medium: 0, high: 0, critical: 0 }
|
||||
const byStatus: Record<string, number> = {}
|
||||
|
||||
filteredRisks.forEach((r) => {
|
||||
byLevel[r.inherent_risk] = (byLevel[r.inherent_risk] || 0) + 1
|
||||
byStatus[r.status] = (byStatus[r.status] || 0) + 1
|
||||
})
|
||||
|
||||
// Calculate residual stats
|
||||
const residualByLevel: Record<string, number> = { low: 0, medium: 0, high: 0, critical: 0 }
|
||||
filteredRisks.forEach((r) => {
|
||||
const level = r.residual_risk || r.inherent_risk
|
||||
residualByLevel[level] = (residualByLevel[level] || 0) + 1
|
||||
})
|
||||
|
||||
return { total, byLevel, byStatus, residualByLevel }
|
||||
}, [filteredRisks])
|
||||
|
||||
// Handle cell click
|
||||
const handleCellClick = (likelihood: number, impact: number, matrix: Record<number, Record<number, Risk[]>>) => {
|
||||
const cellRisks = matrix[likelihood][impact]
|
||||
if (selectedCell?.l === likelihood && selectedCell?.i === impact) {
|
||||
setSelectedCell(null)
|
||||
} else {
|
||||
setSelectedCell({ l: likelihood, i: impact })
|
||||
}
|
||||
onCellClick?.(likelihood, impact, cellRisks)
|
||||
}
|
||||
|
||||
// Handle risk click
|
||||
const handleRiskClick = (risk: Risk) => {
|
||||
if (selectedRisk?.id === risk.id) {
|
||||
setSelectedRisk(null)
|
||||
} else {
|
||||
setSelectedRisk(risk)
|
||||
}
|
||||
onRiskClick?.(risk)
|
||||
}
|
||||
|
||||
// Render single matrix
|
||||
const renderMatrix = (matrix: Record<number, Record<number, Risk[]>>, title: string) => (
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-3 text-center">{title}</h4>
|
||||
<div className="inline-block">
|
||||
{/* Column headers (Impact) */}
|
||||
<div className="flex ml-12">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="w-16 text-center text-xs font-medium text-slate-500 pb-1">
|
||||
I{i}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Matrix rows */}
|
||||
{[5, 4, 3, 2, 1].map((likelihood) => (
|
||||
<div key={likelihood} className="flex items-center">
|
||||
<div className="w-12 text-xs font-medium text-slate-500 text-right pr-2">
|
||||
L{likelihood}
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5].map((impact) => {
|
||||
const level = calculateRiskLevel(likelihood, impact)
|
||||
const cellRisks = matrix[likelihood][impact]
|
||||
const isSelected = selectedCell?.l === likelihood && selectedCell?.i === impact
|
||||
const colors = RISK_LEVEL_COLORS[level]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={impact}
|
||||
onClick={() => handleCellClick(likelihood, impact, matrix)}
|
||||
className={`
|
||||
w-16 h-14 border m-0.5 rounded flex flex-col items-center justify-center
|
||||
cursor-pointer transition-all
|
||||
${colors.bg} ${colors.border}
|
||||
${isSelected ? 'ring-2 ring-primary-500 ring-offset-1' : 'hover:ring-1 hover:ring-slate-300'}
|
||||
`}
|
||||
>
|
||||
{cellRisks.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-0.5 justify-center max-h-12 overflow-hidden">
|
||||
{cellRisks.slice(0, 4).map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRiskClick(r)
|
||||
}}
|
||||
className={`
|
||||
px-1.5 py-0.5 text-[10px] font-medium rounded text-white
|
||||
${RISK_BADGE_COLORS[r.inherent_risk] || 'bg-slate-500'}
|
||||
hover:opacity-80 transition-opacity
|
||||
${selectedRisk?.id === r.id ? 'ring-2 ring-white' : ''}
|
||||
`}
|
||||
title={r.title}
|
||||
>
|
||||
{r.risk_id.replace('RISK-', 'R')}
|
||||
</button>
|
||||
))}
|
||||
{cellRisks.length > 4 && (
|
||||
<span className="text-[10px] text-slate-600">+{cellRisks.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-400">-</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render risk details panel
|
||||
const renderRiskDetails = () => {
|
||||
const risksToShow = selectedRisk
|
||||
? [selectedRisk]
|
||||
: selectedCell
|
||||
? (viewMode === 'residual' ? residualMatrix : inherentMatrix)[selectedCell.l][selectedCell.i]
|
||||
: []
|
||||
|
||||
if (risksToShow.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-slate-400 py-8">
|
||||
{lang === 'de'
|
||||
? 'Klicken Sie auf eine Zelle oder ein Risiko fuer Details'
|
||||
: 'Click a cell or risk for details'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 max-h-[300px] overflow-y-auto">
|
||||
{risksToShow.map((risk) => {
|
||||
const riskControls = getControlsForRisk(risk)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={risk.id}
|
||||
className={`
|
||||
p-3 rounded-lg border transition-colors
|
||||
${selectedRisk?.id === risk.id ? 'border-primary-500 bg-primary-50' : 'border-slate-200'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div>
|
||||
<span className="font-mono text-sm font-medium text-primary-600">{risk.risk_id}</span>
|
||||
<h4 className="font-medium text-slate-900">{risk.title}</h4>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<span className={`px-2 py-0.5 text-xs font-medium rounded text-white ${RISK_BADGE_COLORS[risk.inherent_risk]}`}>
|
||||
{risk.inherent_risk}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${STATUS_OPTIONS[risk.status]?.color || 'bg-slate-100'}`}>
|
||||
{STATUS_OPTIONS[risk.status]?.[lang] || risk.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{risk.description && (
|
||||
<p className="text-sm text-slate-600 mb-2">{risk.description}</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
|
||||
<div>
|
||||
<span className="text-slate-500">{lang === 'de' ? 'Kategorie' : 'Category'}:</span>{' '}
|
||||
<span className="font-medium">{CATEGORY_OPTIONS[risk.category]?.[lang] || risk.category}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">{lang === 'de' ? 'Verantwortlich' : 'Owner'}:</span>{' '}
|
||||
<span className="font-medium">{risk.owner || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">{lang === 'de' ? 'Inhaerent' : 'Inherent'}:</span>{' '}
|
||||
<span className="font-medium">{risk.likelihood} x {risk.impact} = {risk.likelihood * risk.impact}</span>
|
||||
</div>
|
||||
{(risk.residual_likelihood && risk.residual_impact) && (
|
||||
<div>
|
||||
<span className="text-slate-500">{lang === 'de' ? 'Residual' : 'Residual'}:</span>{' '}
|
||||
<span className="font-medium">{risk.residual_likelihood} x {risk.residual_impact} = {risk.residual_likelihood * risk.residual_impact}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mitigating Controls */}
|
||||
{riskControls.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t">
|
||||
<p className="text-xs text-slate-500 mb-1">
|
||||
{lang === 'de' ? 'Mitigierende Massnahmen' : 'Mitigating Controls'} ({riskControls.length})
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{riskControls.map((ctrl) => (
|
||||
<span
|
||||
key={ctrl.control_id}
|
||||
className="px-2 py-0.5 text-xs bg-slate-100 text-slate-700 rounded"
|
||||
title={ctrl.title}
|
||||
>
|
||||
{ctrl.control_id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{risk.treatment_plan && (
|
||||
<div className="mt-2 pt-2 border-t">
|
||||
<p className="text-xs text-slate-500 mb-1">
|
||||
{lang === 'de' ? 'Behandlungsplan' : 'Treatment Plan'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600">{risk.treatment_plan}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (risks.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-8 text-center">
|
||||
<svg className="w-16 h-16 text-slate-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<p className="text-slate-500">
|
||||
{lang === 'de' ? 'Keine Risiken vorhanden' : 'No risks available'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Statistics Header */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<p className="text-xs text-slate-500">{lang === 'de' ? 'Gesamt' : 'Total'}</p>
|
||||
<p className="text-xl font-bold text-slate-800">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<p className="text-xs text-slate-500">Critical</p>
|
||||
<p className="text-xl font-bold text-red-600">{stats.byLevel.critical}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<p className="text-xs text-slate-500">High</p>
|
||||
<p className="text-xl font-bold text-orange-600">{stats.byLevel.high}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<p className="text-xs text-slate-500">Medium</p>
|
||||
<p className="text-xl font-bold text-yellow-600">{stats.byLevel.medium}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<p className="text-xs text-slate-500">Low</p>
|
||||
<p className="text-xl font-bold text-green-600">{stats.byLevel.low}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Controls */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 text-sm"
|
||||
>
|
||||
<option value="">{lang === 'de' ? 'Alle Kategorien' : 'All Categories'}</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{CATEGORY_OPTIONS[cat]?.[lang] || cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 text-sm"
|
||||
>
|
||||
<option value="">{lang === 'de' ? 'Alle Status' : 'All Status'}</option>
|
||||
{Object.entries(STATUS_OPTIONS).map(([key, val]) => (
|
||||
<option key={key} value={key}>
|
||||
{val[lang]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{showComparison && (
|
||||
<div className="flex bg-slate-100 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('inherent')}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
viewMode === 'inherent' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{lang === 'de' ? 'Inhaerent' : 'Inherent'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('residual')}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
viewMode === 'residual' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
Residual
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('comparison')}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
viewMode === 'comparison' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{lang === 'de' ? 'Vergleich' : 'Compare'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Matrix View(s) */}
|
||||
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border p-4">
|
||||
{viewMode === 'comparison' ? (
|
||||
<div className="flex gap-6 overflow-x-auto">
|
||||
{renderMatrix(inherentMatrix, lang === 'de' ? 'Inhaerent' : 'Inherent')}
|
||||
<div className="w-px bg-slate-200 self-stretch" />
|
||||
{renderMatrix(residualMatrix, 'Residual')}
|
||||
</div>
|
||||
) : viewMode === 'residual' ? (
|
||||
<div className="flex justify-center">
|
||||
{renderMatrix(residualMatrix, lang === 'de' ? 'Residuales Risiko' : 'Residual Risk')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
{renderMatrix(inherentMatrix, lang === 'de' ? 'Inhaeentes Risiko' : 'Inherent Risk')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex gap-4 mt-4 pt-4 border-t justify-center flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-green-500 rounded" />
|
||||
<span className="text-xs text-slate-600">Low (1-5)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-yellow-500 rounded" />
|
||||
<span className="text-xs text-slate-600">Medium (6-11)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-orange-500 rounded" />
|
||||
<span className="text-xs text-slate-600">High (12-19)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-red-500 rounded" />
|
||||
<span className="text-xs text-slate-600">Critical (20-25)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Details Panel */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-3">
|
||||
{lang === 'de' ? 'Risiko-Details' : 'Risk Details'}
|
||||
</h3>
|
||||
{renderRiskDetails()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Movement Summary (when comparison mode) */}
|
||||
{viewMode === 'comparison' && (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-3">
|
||||
{lang === 'de' ? 'Risikoveraenderung durch Massnahmen' : 'Risk Change from Controls'}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{stats.byLevel.critical - stats.residualByLevel.critical}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{lang === 'de' ? 'Critical reduziert' : 'Critical reduced'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-orange-600">
|
||||
{stats.byLevel.high - stats.residualByLevel.high}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{lang === 'de' ? 'High reduziert' : 'High reduced'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-yellow-600">
|
||||
{stats.byLevel.medium - stats.residualByLevel.medium}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{lang === 'de' ? 'Medium reduziert' : 'Medium reduced'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-slate-600">
|
||||
{filteredRisks.filter((r) => r.residual_likelihood && r.residual_impact).length}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{lang === 'de' ? 'Bewertet' : 'Assessed'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mini Risk Matrix for compact display
|
||||
*/
|
||||
interface MiniRiskMatrixProps {
|
||||
risks: Risk[]
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
export function MiniRiskMatrix({ risks, size = 'sm' }: MiniRiskMatrixProps) {
|
||||
const matrix = useMemo(() => {
|
||||
const m: Record<number, Record<number, number>> = {}
|
||||
for (let l = 1; l <= 5; l++) {
|
||||
m[l] = {}
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
m[l][i] = 0
|
||||
}
|
||||
}
|
||||
risks.forEach((r) => {
|
||||
if (m[r.likelihood] && m[r.likelihood][r.impact] !== undefined) {
|
||||
m[r.likelihood][r.impact]++
|
||||
}
|
||||
})
|
||||
return m
|
||||
}, [risks])
|
||||
|
||||
const cellSize = size === 'sm' ? 'w-6 h-6' : 'w-8 h-8'
|
||||
const fontSize = size === 'sm' ? 'text-[8px]' : 'text-[10px]'
|
||||
|
||||
return (
|
||||
<div className="inline-block">
|
||||
{[5, 4, 3, 2, 1].map((l) => (
|
||||
<div key={l} className="flex">
|
||||
{[1, 2, 3, 4, 5].map((i) => {
|
||||
const level = calculateRiskLevel(l, i)
|
||||
const count = matrix[l][i]
|
||||
const colors = RISK_LEVEL_COLORS[level]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${cellSize} ${colors.bg} border ${colors.border} flex items-center justify-center m-px rounded-sm`}
|
||||
>
|
||||
{count > 0 && (
|
||||
<span className={`${fontSize} font-bold ${colors.text}`}>{count}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Risk Distribution Chart (simple bar representation)
|
||||
*/
|
||||
interface RiskDistributionProps {
|
||||
risks: Risk[]
|
||||
lang?: Language
|
||||
}
|
||||
|
||||
export function RiskDistribution({ risks, lang = 'de' }: RiskDistributionProps) {
|
||||
const stats = useMemo(() => {
|
||||
const byLevel: Record<string, number> = { critical: 0, high: 0, medium: 0, low: 0 }
|
||||
risks.forEach((r) => {
|
||||
byLevel[r.inherent_risk] = (byLevel[r.inherent_risk] || 0) + 1
|
||||
})
|
||||
return byLevel
|
||||
}, [risks])
|
||||
|
||||
const total = risks.length || 1
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{['critical', 'high', 'medium', 'low'].map((level) => {
|
||||
const count = stats[level]
|
||||
const percentage = (count / total) * 100
|
||||
|
||||
return (
|
||||
<div key={level} className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500 w-16 capitalize">{level}</span>
|
||||
<div className="flex-1 h-4 bg-slate-100 rounded overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${RISK_BADGE_COLORS[level]} transition-all`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-slate-700 w-8 text-right">{count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
website/components/compliance/charts/index.ts
Normal file
24
website/components/compliance/charts/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Compliance Charts Module
|
||||
*
|
||||
* Re-exports all chart components for easy importing:
|
||||
*
|
||||
* import {
|
||||
* ComplianceTrendChart,
|
||||
* TrafficLightIndicator,
|
||||
* MiniSparkline,
|
||||
* DependencyMap,
|
||||
* RiskHeatmap,
|
||||
* MiniRiskMatrix,
|
||||
* RiskDistribution,
|
||||
* } from '@/components/compliance/charts'
|
||||
*/
|
||||
|
||||
export { default as ComplianceTrendChart } from './ComplianceTrendChart'
|
||||
export { TrafficLightIndicator, MiniSparkline } from './ComplianceTrendChart'
|
||||
|
||||
export { default as DependencyMap } from './DependencyMap'
|
||||
|
||||
export { default as RiskHeatmap } from './RiskHeatmap'
|
||||
export { MiniRiskMatrix, RiskDistribution, calculateRiskLevel } from './RiskHeatmap'
|
||||
export type { Risk, Control } from './RiskHeatmap'
|
||||
Reference in New Issue
Block a user