Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
371 lines
14 KiB
TypeScript
371 lines
14 KiB
TypeScript
'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>
|
|
)
|
|
}
|