feat(dsfa): Add complete 8-section DSFA module with sidebar navigation

Implement DSFA optimization plan based on DSK Kurzpapier Nr. 5:
- Section 0: ThresholdAnalysisSection (WP248, Art. 35 Abs. 3, KI-Trigger)
- Section 5: StakeholderConsultationSection (Art. 35 Abs. 9)
- Section 6: Art36Warning for authority consultation (Art. 36)
- Section 7: ReviewScheduleSection (Art. 35 Abs. 11)
- DSFASidebar with progress tracking for all 8 sections
- Extended DSFASectionProgress for sections 0, 6, 7

Replaces tab navigation with sidebar layout (1/4 + 3/4 grid).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
BreakPilot Dev
2026-02-09 11:50:04 +01:00
parent 3899c86b29
commit 95e0a327c4
8 changed files with 2703 additions and 150 deletions

View File

@@ -0,0 +1,372 @@
'use client'
import React, { useState } from 'react'
import {
DSFA,
DSFAConsultationRequirement,
DSFA_AUTHORITY_RESOURCES,
getFederalStateOptions,
getAuthorityResource,
} from '@/lib/sdk/dsfa/types'
interface Art36WarningProps {
dsfa: DSFA
onUpdate: (data: Record<string, unknown>) => Promise<void>
isSubmitting: boolean
}
export function Art36Warning({ dsfa, onUpdate, isSubmitting }: Art36WarningProps) {
const isHighResidualRisk = dsfa.residual_risk_level === 'high' || dsfa.residual_risk_level === 'very_high'
const consultationReq = dsfa.consultation_requirement
const [federalState, setFederalState] = useState(dsfa.federal_state || '')
const [authorityNotified, setAuthorityNotified] = useState(consultationReq?.authority_notified || false)
const [notificationDate, setNotificationDate] = useState(consultationReq?.notification_date || '')
const [waitingPeriodObserved, setWaitingPeriodObserved] = useState(consultationReq?.waiting_period_observed || false)
const [authorityResponse, setAuthorityResponse] = useState(consultationReq?.authority_response || '')
const [recommendations, setRecommendations] = useState<string[]>(consultationReq?.authority_recommendations || [])
const [newRecommendation, setNewRecommendation] = useState('')
const federalStateOptions = getFederalStateOptions()
const selectedAuthority = federalState ? getAuthorityResource(federalState) : null
const handleSave = async () => {
const requirement: DSFAConsultationRequirement = {
high_residual_risk: isHighResidualRisk,
consultation_required: isHighResidualRisk,
consultation_reason: isHighResidualRisk
? 'Trotz geplanter Massnahmen verbleibt ein hohes Restrisiko. Gem. Art. 36 Abs. 1 DSGVO ist vor der Verarbeitung die Aufsichtsbehoerde zu konsultieren.'
: undefined,
authority_notified: authorityNotified,
notification_date: notificationDate || undefined,
authority_response: authorityResponse || undefined,
authority_recommendations: recommendations.length > 0 ? recommendations : undefined,
waiting_period_observed: waitingPeriodObserved,
}
await onUpdate({
consultation_requirement: requirement,
federal_state: federalState,
authority_resource_id: federalState,
})
}
const addRecommendation = () => {
if (newRecommendation.trim()) {
setRecommendations([...recommendations, newRecommendation.trim()])
setNewRecommendation('')
}
}
const removeRecommendation = (index: number) => {
setRecommendations(recommendations.filter((_, i) => i !== index))
}
// Don't show if residual risk is not high
if (!isHighResidualRisk) {
return (
<div className="bg-green-50 border border-green-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<h3 className="font-semibold text-green-800">Keine Behoerdenkonsultation erforderlich</h3>
<p className="text-sm text-green-600 mt-1">
Das Restrisiko nach Umsetzung der geplanten Massnahmen ist nicht hoch.
Eine vorherige Konsultation der Aufsichtsbehoerde gem. Art. 36 DSGVO ist nicht erforderlich.
</p>
</div>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Warning Banner */}
<div className="bg-red-50 border-2 border-red-300 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-red-600" 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>
<div>
<h3 className="text-lg font-semibold text-red-800">
Behoerdenkonsultation erforderlich (Art. 36 DSGVO)
</h3>
<p className="text-sm text-red-700 mt-2">
Das Restrisiko nach Umsetzung aller geplanten Massnahmen wurde als
<span className="font-bold"> {dsfa.residual_risk_level === 'very_high' ? 'SEHR HOCH' : 'HOCH'} </span>
eingestuft.
</p>
<p className="text-sm text-red-600 mt-2">
Gemaess Art. 36 Abs. 1 DSGVO muessen Sie <strong>vor Beginn der Verarbeitung</strong> die
zustaendige Aufsichtsbehoerde konsultieren. Die Behoerde hat eine Frist von 8 Wochen
zur Stellungnahme (Art. 36 Abs. 2 DSGVO).
</p>
</div>
</div>
</div>
{/* Federal State Selection */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-4">Zustaendige Aufsichtsbehoerde</h4>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Bundesland / Zustaendigkeit *
</label>
<select
value={federalState}
onChange={(e) => setFederalState(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
<option value="">Bitte waehlen...</option>
{federalStateOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Authority Details */}
{selectedAuthority && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h5 className="font-medium text-blue-900">{selectedAuthority.name}</h5>
<p className="text-sm text-blue-700 mt-1">({selectedAuthority.shortName})</p>
<div className="mt-4 flex flex-wrap gap-2">
<a
href={selectedAuthority.overviewUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-100 text-blue-700 rounded-lg text-sm hover:bg-blue-200 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
DSFA-Informationen
</a>
{selectedAuthority.publicSectorListUrl && (
<a
href={selectedAuthority.publicSectorListUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-green-100 text-green-700 rounded-lg text-sm hover:bg-green-200 transition-colors"
>
<svg className="w-4 h-4" 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>
Muss-Liste (oeffentlich)
</a>
)}
{selectedAuthority.privateSectorListUrl && (
<a
href={selectedAuthority.privateSectorListUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-orange-100 text-orange-700 rounded-lg text-sm hover:bg-orange-200 transition-colors"
>
<svg className="w-4 h-4" 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>
Muss-Liste (nicht-oeffentlich)
</a>
)}
{selectedAuthority.templateUrl && (
<a
href={selectedAuthority.templateUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-purple-100 text-purple-700 rounded-lg text-sm hover:bg-purple-200 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
DSFA-Vorlage
</a>
)}
</div>
{selectedAuthority.additionalResources && selectedAuthority.additionalResources.length > 0 && (
<div className="mt-4 pt-4 border-t border-blue-200">
<p className="text-xs font-medium text-blue-800 mb-2">Weitere Ressourcen:</p>
<div className="flex flex-wrap gap-2">
{selectedAuthority.additionalResources.map((resource, idx) => (
<a
key={idx}
href={resource.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:text-blue-800 underline"
>
{resource.title}
</a>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Consultation Documentation */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-4">Konsultation dokumentieren</h4>
<div className="space-y-4">
{/* Authority Notified Checkbox */}
<label className={`flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-all ${
authorityNotified
? 'bg-green-50 border-green-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}>
<input
type="checkbox"
checked={authorityNotified}
onChange={(e) => setAuthorityNotified(e.target.checked)}
className="mt-1 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<div>
<span className="font-medium text-gray-900">Aufsichtsbehoerde wurde konsultiert</span>
<p className="text-sm text-gray-500 mt-1">
Die DSFA wurde der zustaendigen Aufsichtsbehoerde vorgelegt.
</p>
</div>
</label>
{authorityNotified && (
<>
{/* Notification Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Datum der Konsultation
</label>
<input
type="date"
value={notificationDate}
onChange={(e) => setNotificationDate(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
{/* 8-Week Waiting Period */}
<label className={`flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-all ${
waitingPeriodObserved
? 'bg-green-50 border-green-300'
: 'bg-yellow-50 border-yellow-300'
}`}>
<input
type="checkbox"
checked={waitingPeriodObserved}
onChange={(e) => setWaitingPeriodObserved(e.target.checked)}
className="mt-1 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<div>
<span className="font-medium text-gray-900">
8-Wochen-Frist eingehalten (Art. 36 Abs. 2 DSGVO)
</span>
<p className="text-sm text-gray-500 mt-1">
Die Aufsichtsbehoerde hat innerhalb von 8 Wochen nach Eingang der Konsultation
schriftlich Stellung genommen, oder die Frist ist abgelaufen.
</p>
{notificationDate && (
<p className="text-xs text-blue-600 mt-2">
8-Wochen-Frist endet am:{' '}
{new Date(new Date(notificationDate).getTime() + 8 * 7 * 24 * 60 * 60 * 1000).toLocaleDateString('de-DE')}
</p>
)}
</div>
</label>
{/* Authority Response */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Stellungnahme / Entscheidung der Behoerde
</label>
<textarea
value={authorityResponse}
onChange={(e) => setAuthorityResponse(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
rows={4}
placeholder="Zusammenfassung der behoerdlichen Stellungnahme..."
/>
</div>
{/* Authority Recommendations */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Auflagen / Empfehlungen der Behoerde
</label>
<div className="flex flex-wrap gap-2 mb-2">
{recommendations.map((rec, idx) => (
<span key={idx} className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm flex items-center gap-2">
{rec}
<button onClick={() => removeRecommendation(idx)} className="hover:text-blue-900">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={newRecommendation}
onChange={(e) => setNewRecommendation(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addRecommendation())}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="Auflage oder Empfehlung hinzufuegen..."
/>
<button
onClick={addRecommendation}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
+
</button>
</div>
</div>
</>
)}
</div>
</div>
{/* Important Note */}
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-yellow-500 mt-0.5" 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>
<div className="text-sm text-yellow-800">
<p className="font-medium mb-1">Wichtiger Hinweis</p>
<p>
Die Verarbeitung darf erst beginnen, nachdem die Aufsichtsbehoerde konsultiert wurde
und entweder ihre Zustimmung erteilt hat oder die 8-Wochen-Frist abgelaufen ist.
Die Behoerde kann diese Frist um weitere 6 Wochen verlaengern.
</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
onClick={handleSave}
disabled={isSubmitting || !federalState}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{isSubmitting ? 'Speichern...' : 'Dokumentation speichern'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,268 @@
'use client'
import React from 'react'
import { DSFA, DSFA_SECTIONS, DSFASectionProgress } from '@/lib/sdk/dsfa/types'
interface DSFASidebarProps {
dsfa: DSFA
activeSection: number
onSectionChange: (section: number) => void
}
// Calculate completion percentage for a section
function calculateSectionProgress(dsfa: DSFA, sectionNumber: number): number {
switch (sectionNumber) {
case 0: // Threshold Analysis
if (!dsfa.threshold_analysis) return 0
const ta = dsfa.threshold_analysis
if (ta.dsfa_required !== undefined && ta.decision_justification) return 100
if (ta.criteria_assessment?.some(c => c.applies)) return 50
return 0
case 1: // Processing Description
const s1Fields = [
dsfa.processing_purpose,
dsfa.processing_description,
dsfa.data_categories?.length,
dsfa.legal_basis,
]
return Math.round((s1Fields.filter(Boolean).length / s1Fields.length) * 100)
case 2: // Necessity & Proportionality
const s2Fields = [
dsfa.necessity_assessment,
dsfa.proportionality_assessment,
]
return Math.round((s2Fields.filter(Boolean).length / s2Fields.length) * 100)
case 3: // Risk Assessment
if (!dsfa.risks?.length) return 0
if (dsfa.overall_risk_level) return 100
return 50
case 4: // Mitigation Measures
if (!dsfa.mitigations?.length) return 0
if (dsfa.residual_risk_level) return 100
return 50
case 5: // Stakeholder Consultation (optional)
if (dsfa.stakeholder_consultation_not_appropriate && dsfa.stakeholder_consultation_not_appropriate_reason) return 100
if (dsfa.stakeholder_consultations?.length) return 100
return 0
case 6: // DPO & Authority Consultation
const s6Fields = [
dsfa.dpo_consulted,
dsfa.dpo_opinion,
]
const s6Progress = Math.round((s6Fields.filter(Boolean).length / s6Fields.length) * 100)
// Add extra progress if authority consultation is documented when required
if (dsfa.consultation_requirement?.consultation_required) {
if (dsfa.authority_consulted) return s6Progress
return Math.min(s6Progress, 75)
}
return s6Progress
case 7: // Review & Maintenance
if (!dsfa.review_schedule) return 0
const rs = dsfa.review_schedule
if (rs.next_review_date && rs.review_frequency_months && rs.review_responsible) return 100
if (rs.next_review_date) return 50
return 25
default:
return 0
}
}
// Check if a section is complete
function isSectionComplete(dsfa: DSFA, sectionNumber: number): boolean {
const progress = dsfa.section_progress
switch (sectionNumber) {
case 0: return progress.section_0_complete ?? false
case 1: return progress.section_1_complete ?? false
case 2: return progress.section_2_complete ?? false
case 3: return progress.section_3_complete ?? false
case 4: return progress.section_4_complete ?? false
case 5: return progress.section_5_complete ?? false
case 6: return progress.section_6_complete ?? false
case 7: return progress.section_7_complete ?? false
default: return false
}
}
// Calculate overall DSFA progress
function calculateOverallProgress(dsfa: DSFA): number {
const requiredSections = DSFA_SECTIONS.filter(s => s.required)
let totalProgress = 0
for (const section of requiredSections) {
totalProgress += calculateSectionProgress(dsfa, section.number)
}
return Math.round(totalProgress / requiredSections.length)
}
export function DSFASidebar({ dsfa, activeSection, onSectionChange }: DSFASidebarProps) {
const overallProgress = calculateOverallProgress(dsfa)
// Group sections by category
const thresholdSection = DSFA_SECTIONS.find(s => s.number === 0)
const art35Sections = DSFA_SECTIONS.filter(s => s.number >= 1 && s.number <= 4)
const stakeholderSection = DSFA_SECTIONS.find(s => s.number === 5)
const consultationSection = DSFA_SECTIONS.find(s => s.number === 6)
const reviewSection = DSFA_SECTIONS.find(s => s.number === 7)
const renderSectionItem = (section: typeof DSFA_SECTIONS[0]) => {
const progress = calculateSectionProgress(dsfa, section.number)
const isComplete = isSectionComplete(dsfa, section.number) || progress === 100
const isActive = activeSection === section.number
return (
<button
key={section.number}
onClick={() => onSectionChange(section.number)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all ${
isActive
? 'bg-purple-100 text-purple-700'
: 'hover:bg-gray-100 text-gray-700'
}`}
>
{/* Status indicator */}
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 ${
isComplete
? 'bg-green-500 text-white'
: isActive
? 'bg-purple-500 text-white'
: 'bg-gray-200 text-gray-500'
}`}>
{isComplete ? (
<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>
) : (
<span className="text-xs font-medium">{section.number}</span>
)}
</div>
{/* Section info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-sm font-medium truncate ${isActive ? 'text-purple-700' : ''}`}>
{section.titleDE}
</span>
{!section.required && (
<span className="text-[10px] px-1.5 py-0.5 bg-gray-200 text-gray-500 rounded">
optional
</span>
)}
</div>
{/* Progress bar */}
<div className="mt-1 h-1 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
isComplete ? 'bg-green-500' : 'bg-purple-500'
}`}
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Progress percentage */}
<span className={`text-xs font-medium ${
isComplete ? 'text-green-600' : 'text-gray-400'
}`}>
{progress}%
</span>
</button>
)
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-4">
{/* Overall Progress */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-gray-900">DSFA Fortschritt</h3>
<span className="text-lg font-bold text-purple-600">{overallProgress}%</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-500 to-purple-600 transition-all duration-500"
style={{ width: `${overallProgress}%` }}
/>
</div>
</div>
{/* Section 0: Threshold Analysis */}
<div className="mb-4">
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2 px-3">
Vorabpruefung
</div>
{thresholdSection && renderSectionItem(thresholdSection)}
</div>
{/* Sections 1-4: Art. 35 Abs. 7 */}
<div className="mb-4">
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2 px-3">
Art. 35 Abs. 7 DSGVO
</div>
<div className="space-y-1">
{art35Sections.map(section => renderSectionItem(section))}
</div>
</div>
{/* Section 5: Stakeholder Consultation */}
<div className="mb-4">
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2 px-3">
Betroffene
</div>
{stakeholderSection && renderSectionItem(stakeholderSection)}
</div>
{/* Section 6: DPO & Authority */}
<div className="mb-4">
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2 px-3">
Konsultation
</div>
{consultationSection && renderSectionItem(consultationSection)}
</div>
{/* Section 7: Review */}
<div>
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2 px-3">
Fortschreibung
</div>
{reviewSection && renderSectionItem(reviewSection)}
</div>
{/* Status Footer */}
<div className="mt-6 pt-4 border-t border-gray-200">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">Status</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${
dsfa.status === 'draft' ? 'bg-gray-100 text-gray-600' :
dsfa.status === 'in_review' ? 'bg-yellow-100 text-yellow-700' :
dsfa.status === 'approved' ? 'bg-green-100 text-green-700' :
dsfa.status === 'rejected' ? 'bg-red-100 text-red-700' :
'bg-orange-100 text-orange-700'
}`}>
{dsfa.status === 'draft' ? 'Entwurf' :
dsfa.status === 'in_review' ? 'In Pruefung' :
dsfa.status === 'approved' ? 'Genehmigt' :
dsfa.status === 'rejected' ? 'Abgelehnt' :
'Ueberarbeitung'}
</span>
</div>
{dsfa.version && (
<div className="flex items-center justify-between text-sm mt-2">
<span className="text-gray-500">Version</span>
<span className="text-gray-700">{dsfa.version}</span>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,323 @@
'use client'
import React, { useState } from 'react'
import { DSFA, DSFAReviewSchedule, DSFAReviewTrigger } from '@/lib/sdk/dsfa/types'
interface ReviewScheduleSectionProps {
dsfa: DSFA
onUpdate: (data: Record<string, unknown>) => Promise<void>
isSubmitting: boolean
}
const REVIEW_FREQUENCIES = [
{ value: 6, label: '6 Monate', description: 'Empfohlen bei hohem Risiko oder dynamischer Verarbeitung' },
{ value: 12, label: '12 Monate (jaehrlich)', description: 'Standardempfehlung fuer die meisten Verarbeitungen' },
{ value: 24, label: '24 Monate (alle 2 Jahre)', description: 'Bei stabilem, niedrigem Risiko' },
{ value: 36, label: '36 Monate (alle 3 Jahre)', description: 'Bei sehr stabiler Verarbeitung mit minimalem Risiko' },
]
const TRIGGER_TYPES = [
{ value: 'scheduled', label: 'Planmaessig', description: 'Regelmaessige Ueberpruefung nach Zeitplan', icon: '📅' },
{ value: 'risk_change', label: 'Risiko-Aenderung', description: 'Aenderung der Risikobewertung', icon: '⚠️' },
{ value: 'new_technology', label: 'Neue Technologie', description: 'Einfuehrung neuer technischer Systeme', icon: '🔧' },
{ value: 'new_purpose', label: 'Neuer Zweck', description: 'Aenderung oder Erweiterung des Verarbeitungszwecks', icon: '🎯' },
{ value: 'incident', label: 'Sicherheitsvorfall', description: 'Datenschutzvorfall oder Sicherheitsproblem', icon: '🚨' },
{ value: 'regulatory', label: 'Regulatorisch', description: 'Gesetzes- oder Behoerden-Aenderung', icon: '📜' },
{ value: 'other', label: 'Sonstiges', description: 'Anderer Ausloser', icon: '📋' },
]
export function ReviewScheduleSection({ dsfa, onUpdate, isSubmitting }: ReviewScheduleSectionProps) {
const existingSchedule = dsfa.review_schedule
const existingTriggers = dsfa.review_triggers || []
const [nextReviewDate, setNextReviewDate] = useState(existingSchedule?.next_review_date || '')
const [reviewFrequency, setReviewFrequency] = useState(existingSchedule?.review_frequency_months || 12)
const [reviewResponsible, setReviewResponsible] = useState(existingSchedule?.review_responsible || '')
const [triggers, setTriggers] = useState<DSFAReviewTrigger[]>(existingTriggers)
const [selectedTriggerTypes, setSelectedTriggerTypes] = useState<string[]>(
[...new Set(existingTriggers.map(t => t.trigger_type))]
)
// Calculate suggested next review date based on frequency
const suggestNextReviewDate = () => {
const date = new Date()
date.setMonth(date.getMonth() + reviewFrequency)
return date.toISOString().split('T')[0]
}
const toggleTriggerType = (type: string) => {
setSelectedTriggerTypes(prev =>
prev.includes(type)
? prev.filter(t => t !== type)
: [...prev, type]
)
}
const handleSave = async () => {
const schedule: DSFAReviewSchedule = {
next_review_date: nextReviewDate,
review_frequency_months: reviewFrequency,
last_review_date: existingSchedule?.last_review_date,
review_responsible: reviewResponsible,
}
// Generate triggers from selected types
const newTriggers: DSFAReviewTrigger[] = selectedTriggerTypes.map(type => {
const existingTrigger = triggers.find(t => t.trigger_type === type)
if (existingTrigger) return existingTrigger
const triggerInfo = TRIGGER_TYPES.find(t => t.value === type)
return {
id: crypto.randomUUID(),
trigger_type: type as DSFAReviewTrigger['trigger_type'],
description: triggerInfo?.description || '',
detected_at: new Date().toISOString(),
detected_by: 'system',
review_required: true,
review_completed: false,
changes_made: [],
}
})
await onUpdate({
review_schedule: schedule,
review_triggers: newTriggers,
})
}
// Check if review is overdue
const isOverdue = existingSchedule?.next_review_date
? new Date(existingSchedule.next_review_date) < new Date()
: false
return (
<div className="space-y-6">
{/* Info Banner */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-500 mt-0.5" 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>
<div>
<p className="text-sm font-medium text-blue-800">Art. 35 Abs. 11 DSGVO</p>
<p className="text-sm text-blue-600 mt-1">
&quot;Der Verantwortliche ueberprueft erforderlichenfalls, ob die Verarbeitung gemaess
der Datenschutz-Folgenabschaetzung durchgefuehrt wird, zumindest wenn hinsichtlich
des mit den Verarbeitungsvorgaengen verbundenen Risikos Aenderungen eingetreten sind.&quot;
</p>
</div>
</div>
</div>
{/* Overdue Warning */}
{isOverdue && (
<div className="bg-red-50 border-2 border-red-300 rounded-xl p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-red-600" 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>
<p className="font-semibold text-red-800">Review ueberfaellig!</p>
<p className="text-sm text-red-600">
Das naechste Review war fuer den{' '}
{new Date(existingSchedule!.next_review_date).toLocaleDateString('de-DE')} geplant.
Bitte aktualisieren Sie die DSFA.
</p>
</div>
</div>
</div>
)}
{/* Version Info */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-4">Versionsinformation</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="bg-gray-50 rounded-lg p-3">
<span className="text-gray-500">Aktuelle Version</span>
<p className="text-xl font-bold text-gray-900">{dsfa.version || 1}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<span className="text-gray-500">Erstellt am</span>
<p className="font-medium text-gray-900">
{new Date(dsfa.created_at).toLocaleDateString('de-DE')}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<span className="text-gray-500">Letzte Aenderung</span>
<p className="font-medium text-gray-900">
{new Date(dsfa.updated_at).toLocaleDateString('de-DE')}
</p>
</div>
{existingSchedule?.last_review_date && (
<div className="bg-gray-50 rounded-lg p-3">
<span className="text-gray-500">Letztes Review</span>
<p className="font-medium text-gray-900">
{new Date(existingSchedule.last_review_date).toLocaleDateString('de-DE')}
</p>
</div>
)}
</div>
</div>
{/* Review Schedule */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-4">Review-Planung</h4>
<div className="space-y-4">
{/* Review Frequency */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Review-Frequenz *
</label>
<div className="grid grid-cols-2 gap-3">
{REVIEW_FREQUENCIES.map((freq) => (
<label
key={freq.value}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
reviewFrequency === freq.value
? 'bg-purple-50 border-purple-300 ring-1 ring-purple-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="radio"
name="reviewFrequency"
value={freq.value}
checked={reviewFrequency === freq.value}
onChange={(e) => setReviewFrequency(Number(e.target.value))}
className="mt-1 text-purple-600 focus:ring-purple-500"
/>
<div>
<span className="font-medium text-gray-900 text-sm">{freq.label}</span>
<p className="text-xs text-gray-500">{freq.description}</p>
</div>
</label>
))}
</div>
</div>
{/* Next Review Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Naechstes Review-Datum *
</label>
<div className="flex gap-2">
<input
type="date"
value={nextReviewDate}
onChange={(e) => setNextReviewDate(e.target.value)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
<button
onClick={() => setNextReviewDate(suggestNextReviewDate())}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm"
>
Vorschlag: +{reviewFrequency} Monate
</button>
</div>
{nextReviewDate && (
<p className="text-xs text-gray-500 mt-1">
Das naechste Review ist in{' '}
{Math.ceil((new Date(nextReviewDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))} Tagen faellig.
</p>
)}
</div>
{/* Review Responsible */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Verantwortlich fuer Review *
</label>
<input
type="text"
value={reviewResponsible}
onChange={(e) => setReviewResponsible(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="Name oder Rolle der verantwortlichen Person/Abteilung..."
/>
</div>
</div>
</div>
{/* Review Triggers */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-2">Review-Ausloser definieren</h4>
<p className="text-sm text-gray-500 mb-4">
Waehlen Sie die Ereignisse aus, bei denen eine Ueberpruefung der DSFA ausgeloest werden soll.
</p>
<div className="space-y-3">
{TRIGGER_TYPES.map((trigger) => (
<label
key={trigger.value}
className={`flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-all ${
selectedTriggerTypes.includes(trigger.value)
? 'bg-green-50 border-green-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={selectedTriggerTypes.includes(trigger.value)}
onChange={() => toggleTriggerType(trigger.value)}
className="mt-1 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-lg">{trigger.icon}</span>
<span className="font-medium text-gray-900">{trigger.label}</span>
</div>
<p className="text-sm text-gray-500 mt-1">{trigger.description}</p>
</div>
</label>
))}
</div>
</div>
{/* Existing Pending Triggers */}
{triggers.filter(t => t.review_required && !t.review_completed).length > 0 && (
<div className="bg-orange-50 rounded-xl border border-orange-200 p-6">
<h4 className="font-semibold text-orange-900 mb-4">
Offene Review-Trigger ({triggers.filter(t => t.review_required && !t.review_completed).length})
</h4>
<div className="space-y-3">
{triggers
.filter(t => t.review_required && !t.review_completed)
.map((trigger) => {
const triggerInfo = TRIGGER_TYPES.find(t => t.value === trigger.trigger_type)
return (
<div
key={trigger.id}
className="bg-white rounded-lg border border-orange-200 p-3"
>
<div className="flex items-center gap-2">
<span className="text-lg">{triggerInfo?.icon || '📋'}</span>
<span className="font-medium text-gray-900">{triggerInfo?.label || trigger.trigger_type}</span>
</div>
<p className="text-sm text-gray-600 mt-1">{trigger.description}</p>
<p className="text-xs text-gray-400 mt-2">
Erkannt am {new Date(trigger.detected_at).toLocaleDateString('de-DE')} von {trigger.detected_by}
</p>
</div>
)
})}
</div>
</div>
)}
{/* Save Button */}
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
onClick={handleSave}
disabled={isSubmitting || !nextReviewDate || !reviewResponsible}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{isSubmitting ? 'Speichern...' : 'Review-Plan speichern'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,458 @@
'use client'
import React, { useState } from 'react'
import { DSFA, DSFAStakeholderConsultation } from '@/lib/sdk/dsfa/types'
interface StakeholderConsultationSectionProps {
dsfa: DSFA
onUpdate: (data: Record<string, unknown>) => Promise<void>
isSubmitting: boolean
}
const STAKEHOLDER_TYPES = [
{ value: 'data_subjects', label: 'Betroffene Personen', description: 'Direkt von der Verarbeitung betroffene Personen' },
{ value: 'representatives', label: 'Vertreter der Betroffenen', description: 'Z.B. Verbraucherorganisationen, Patientenvertreter' },
{ value: 'works_council', label: 'Betriebsrat / Personalrat', description: 'Bei Arbeitnehmer-Datenverarbeitung' },
{ value: 'other', label: 'Sonstige Stakeholder', description: 'Andere relevante Interessengruppen' },
]
const CONSULTATION_METHODS = [
{ value: 'survey', label: 'Umfrage', description: 'Schriftliche oder Online-Befragung' },
{ value: 'interview', label: 'Interview', description: 'Persoenliche oder telefonische Gespraeche' },
{ value: 'workshop', label: 'Workshop', description: 'Gemeinsame Erarbeitung in Gruppensitzung' },
{ value: 'written', label: 'Schriftliche Stellungnahme', description: 'Formelle schriftliche Anfrage' },
{ value: 'other', label: 'Andere Methode', description: 'Sonstige Konsultationsform' },
]
export function StakeholderConsultationSection({ dsfa, onUpdate, isSubmitting }: StakeholderConsultationSectionProps) {
const [consultations, setConsultations] = useState<DSFAStakeholderConsultation[]>(
dsfa.stakeholder_consultations || []
)
const [notAppropriate, setNotAppropriate] = useState(
dsfa.stakeholder_consultation_not_appropriate || false
)
const [notAppropriateReason, setNotAppropriateReason] = useState(
dsfa.stakeholder_consultation_not_appropriate_reason || ''
)
const [showAddForm, setShowAddForm] = useState(false)
// New consultation form state
const [newConsultation, setNewConsultation] = useState<Partial<DSFAStakeholderConsultation>>({
stakeholder_type: 'data_subjects',
stakeholder_description: '',
consultation_method: 'survey',
consultation_date: '',
summary: '',
concerns_raised: [],
addressed_in_dsfa: false,
})
const [newConcern, setNewConcern] = useState('')
const addConsultation = () => {
if (!newConsultation.stakeholder_description || !newConsultation.summary) return
const consultation: DSFAStakeholderConsultation = {
id: crypto.randomUUID(),
stakeholder_type: newConsultation.stakeholder_type as DSFAStakeholderConsultation['stakeholder_type'],
stakeholder_description: newConsultation.stakeholder_description || '',
consultation_method: newConsultation.consultation_method as DSFAStakeholderConsultation['consultation_method'],
consultation_date: newConsultation.consultation_date || undefined,
summary: newConsultation.summary || '',
concerns_raised: newConsultation.concerns_raised || [],
addressed_in_dsfa: newConsultation.addressed_in_dsfa || false,
}
setConsultations([...consultations, consultation])
setNewConsultation({
stakeholder_type: 'data_subjects',
stakeholder_description: '',
consultation_method: 'survey',
consultation_date: '',
summary: '',
concerns_raised: [],
addressed_in_dsfa: false,
})
setShowAddForm(false)
}
const removeConsultation = (id: string) => {
setConsultations(consultations.filter(c => c.id !== id))
}
const addConcern = () => {
if (newConcern.trim()) {
setNewConsultation({
...newConsultation,
concerns_raised: [...(newConsultation.concerns_raised || []), newConcern.trim()],
})
setNewConcern('')
}
}
const removeConcern = (index: number) => {
setNewConsultation({
...newConsultation,
concerns_raised: (newConsultation.concerns_raised || []).filter((_, i) => i !== index),
})
}
const handleSave = async () => {
await onUpdate({
stakeholder_consultations: notAppropriate ? [] : consultations,
stakeholder_consultation_not_appropriate: notAppropriate,
stakeholder_consultation_not_appropriate_reason: notAppropriate ? notAppropriateReason : undefined,
})
}
return (
<div className="space-y-6">
{/* Info Banner */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-500 mt-0.5" 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>
<div>
<p className="text-sm font-medium text-blue-800">Art. 35 Abs. 9 DSGVO</p>
<p className="text-sm text-blue-600 mt-1">
&quot;Der Verantwortliche holt gegebenenfalls den Standpunkt der betroffenen Personen oder
ihrer Vertreter zu der beabsichtigten Verarbeitung ein, ohne dass dadurch der Schutz
gewerblicher oder oeffentlicher Interessen oder die Sicherheit der Verarbeitungsvorgaenge
beeintraechtigt wird.&quot;
</p>
</div>
</div>
</div>
{/* Not Appropriate Option */}
<div className={`p-4 rounded-xl border transition-all ${
notAppropriate
? 'bg-orange-50 border-orange-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={notAppropriate}
onChange={(e) => setNotAppropriate(e.target.checked)}
className="mt-1 rounded border-gray-300 text-orange-600 focus:ring-orange-500"
/>
<div>
<span className="font-medium text-gray-900">
Konsultation nicht angemessen
</span>
<p className="text-sm text-gray-500 mt-1">
Die Einholung des Standpunkts der Betroffenen ist in diesem Fall nicht angemessen
(z.B. bei Gefaehrdung der Sicherheit oder gewerblicher Interessen).
</p>
</div>
</label>
{notAppropriate && (
<div className="mt-4 ml-7">
<label className="block text-sm font-medium text-gray-700 mb-2">
Begruendung *
</label>
<textarea
value={notAppropriateReason}
onChange={(e) => setNotAppropriateReason(e.target.value)}
className="w-full px-4 py-3 border border-orange-300 rounded-lg focus:ring-2 focus:ring-orange-500 bg-white"
rows={3}
placeholder="Begruenden Sie, warum eine Konsultation nicht angemessen ist..."
/>
</div>
)}
</div>
{/* Consultations List */}
{!notAppropriate && (
<>
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">
Durchgefuehrte Konsultationen ({consultations.length})
</h3>
<button
onClick={() => setShowAddForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Konsultation hinzufuegen
</button>
</div>
{consultations.length === 0 && !showAddForm ? (
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-xl border border-dashed border-gray-300">
<svg className="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<p>Noch keine Konsultationen dokumentiert</p>
<p className="text-sm mt-1">Fuegen Sie Ihre Stakeholder-Konsultationen hinzu.</p>
</div>
) : (
<div className="space-y-4">
{consultations.map((consultation) => {
const typeInfo = STAKEHOLDER_TYPES.find(t => t.value === consultation.stakeholder_type)
const methodInfo = CONSULTATION_METHODS.find(m => m.value === consultation.consultation_method)
return (
<div
key={consultation.id}
className="bg-white rounded-xl border border-gray-200 p-4"
>
<div className="flex items-start justify-between mb-3">
<div>
<span className="inline-block px-2 py-1 bg-purple-100 text-purple-700 rounded text-xs font-medium mb-2">
{typeInfo?.label}
</span>
<h4 className="font-medium text-gray-900">
{consultation.stakeholder_description}
</h4>
</div>
<button
onClick={() => removeConsultation(consultation.id)}
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div className="grid grid-cols-2 gap-4 text-sm mb-3">
<div>
<span className="text-gray-500">Methode:</span>
<span className="ml-2 text-gray-700">{methodInfo?.label}</span>
</div>
{consultation.consultation_date && (
<div>
<span className="text-gray-500">Datum:</span>
<span className="ml-2 text-gray-700">
{new Date(consultation.consultation_date).toLocaleDateString('de-DE')}
</span>
</div>
)}
</div>
<div className="text-sm text-gray-600 mb-3">
<p className="font-medium text-gray-700">Zusammenfassung:</p>
<p>{consultation.summary}</p>
</div>
{consultation.concerns_raised.length > 0 && (
<div className="mb-3">
<p className="text-sm font-medium text-gray-700 mb-2">Geaeusserte Bedenken:</p>
<ul className="space-y-1">
{consultation.concerns_raised.map((concern, idx) => (
<li key={idx} className="flex items-start gap-2 text-sm text-gray-600">
<span className="text-orange-500 mt-0.5">-</span>
{concern}
</li>
))}
</ul>
</div>
)}
<div className="flex items-center gap-2">
{consultation.addressed_in_dsfa ? (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 rounded text-xs">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
In DSFA beruecksichtigt
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">
Noch nicht beruecksichtigt
</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
{/* Add Consultation Form */}
{showAddForm && (
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-4">Neue Konsultation hinzufuegen</h4>
<div className="space-y-4">
{/* Stakeholder Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Stakeholder-Typ *
</label>
<div className="grid grid-cols-2 gap-3">
{STAKEHOLDER_TYPES.map((type) => (
<label
key={type.value}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
newConsultation.stakeholder_type === type.value
? 'bg-purple-50 border-purple-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="radio"
name="stakeholderType"
value={type.value}
checked={newConsultation.stakeholder_type === type.value}
onChange={(e) => setNewConsultation({ ...newConsultation, stakeholder_type: e.target.value as DSFAStakeholderConsultation['stakeholder_type'] })}
className="mt-1 text-purple-600 focus:ring-purple-500"
/>
<div>
<span className="font-medium text-gray-900 text-sm">{type.label}</span>
<p className="text-xs text-gray-500">{type.description}</p>
</div>
</label>
))}
</div>
</div>
{/* Stakeholder Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Beschreibung der Stakeholder *
</label>
<input
type="text"
value={newConsultation.stakeholder_description}
onChange={(e) => setNewConsultation({ ...newConsultation, stakeholder_description: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="Z.B. Mitarbeiter der IT-Abteilung, Kunden des Online-Shops..."
/>
</div>
{/* Consultation Method */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Konsultationsmethode *
</label>
<select
value={newConsultation.consultation_method}
onChange={(e) => setNewConsultation({ ...newConsultation, consultation_method: e.target.value as DSFAStakeholderConsultation['consultation_method'] })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
>
{CONSULTATION_METHODS.map((method) => (
<option key={method.value} value={method.value}>
{method.label} - {method.description}
</option>
))}
</select>
</div>
{/* Consultation Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Datum der Konsultation
</label>
<input
type="date"
value={newConsultation.consultation_date}
onChange={(e) => setNewConsultation({ ...newConsultation, consultation_date: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
{/* Summary */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Zusammenfassung der Ergebnisse *
</label>
<textarea
value={newConsultation.summary}
onChange={(e) => setNewConsultation({ ...newConsultation, summary: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
rows={3}
placeholder="Fassen Sie die wichtigsten Ergebnisse der Konsultation zusammen..."
/>
</div>
{/* Concerns */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Geaeusserte Bedenken
</label>
<div className="flex flex-wrap gap-2 mb-2">
{(newConsultation.concerns_raised || []).map((concern, idx) => (
<span key={idx} className="px-3 py-1 bg-orange-100 text-orange-700 rounded-full text-sm flex items-center gap-2">
{concern}
<button onClick={() => removeConcern(idx)} className="hover:text-orange-900">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={newConcern}
onChange={(e) => setNewConcern(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addConcern())}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="Bedenken hinzufuegen..."
/>
<button
onClick={addConcern}
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700"
>
+
</button>
</div>
</div>
{/* Addressed in DSFA */}
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={newConsultation.addressed_in_dsfa}
onChange={(e) => setNewConsultation({ ...newConsultation, addressed_in_dsfa: e.target.checked })}
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<span className="text-sm text-gray-700">
Bedenken wurden in der DSFA beruecksichtigt
</span>
</label>
{/* Form Actions */}
<div className="flex gap-3 pt-4">
<button
onClick={() => setShowAddForm(false)}
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
>
Abbrechen
</button>
<button
onClick={addConsultation}
disabled={!newConsultation.stakeholder_description || !newConsultation.summary}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
Konsultation hinzufuegen
</button>
</div>
</div>
</div>
)}
</>
)}
{/* Save Button */}
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
onClick={handleSave}
disabled={isSubmitting || (notAppropriate && !notAppropriateReason)}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{isSubmitting ? 'Speichern...' : 'Abschnitt speichern'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,418 @@
'use client'
import React, { useState, useEffect } from 'react'
import {
DSFA,
WP248_CRITERIA,
ART35_ABS3_CASES,
AI_DSFA_TRIGGERS,
checkDSFARequiredByWP248,
DSFAThresholdAnalysis,
} from '@/lib/sdk/dsfa/types'
interface ThresholdAnalysisSectionProps {
dsfa: DSFA
onUpdate: (data: Record<string, unknown>) => Promise<void>
isSubmitting: boolean
}
export function ThresholdAnalysisSection({ dsfa, onUpdate, isSubmitting }: ThresholdAnalysisSectionProps) {
// Initialize state from existing data
const existingAnalysis = dsfa.threshold_analysis
const [wp248Selected, setWp248Selected] = useState<string[]>(
dsfa.wp248_criteria_met ||
existingAnalysis?.criteria_assessment?.filter(c => c.applies).map(c => c.criterion_id) ||
[]
)
const [art35Selected, setArt35Selected] = useState<string[]>(
dsfa.art35_abs3_triggered ||
existingAnalysis?.art35_abs3_assessment?.filter(c => c.applies).map(c => c.case_id) ||
[]
)
const [aiTriggersSelected, setAiTriggersSelected] = useState<string[]>(
dsfa.ai_trigger_ids || []
)
const [dsfaRequired, setDsfaRequired] = useState<boolean | null>(
existingAnalysis?.dsfa_required ?? null
)
const [justification, setJustification] = useState(
existingAnalysis?.decision_justification || ''
)
// Calculate recommendation based on selections
const wp248Result = checkDSFARequiredByWP248(wp248Selected)
const hasArt35Trigger = art35Selected.length > 0
const hasAITrigger = aiTriggersSelected.length > 0
const recommendation = wp248Result.required || hasArt35Trigger || hasAITrigger
? 'required'
: wp248Selected.length === 1
? 'possible'
: 'not_required'
// Auto-generate justification when selections change
useEffect(() => {
if (dsfaRequired === null && !justification) {
const parts: string[] = []
if (wp248Selected.length > 0) {
const criteriaNames = wp248Selected.map(id =>
WP248_CRITERIA.find(c => c.id === id)?.code
).filter(Boolean).join(', ')
parts.push(`${wp248Selected.length} WP248-Kriterien erfuellt (${criteriaNames})`)
}
if (art35Selected.length > 0) {
parts.push(`Art. 35 Abs. 3 Regelbeispiel${art35Selected.length > 1 ? 'e' : ''} erfuellt`)
}
if (aiTriggersSelected.length > 0) {
parts.push(`${aiTriggersSelected.length} KI-spezifische${aiTriggersSelected.length > 1 ? '' : 'r'} Trigger erfuellt`)
}
if (parts.length > 0) {
setJustification(parts.join('. ') + '.')
}
}
}, [wp248Selected, art35Selected, aiTriggersSelected, dsfaRequired, justification])
const toggleWp248 = (criterionId: string) => {
setWp248Selected(prev =>
prev.includes(criterionId)
? prev.filter(id => id !== criterionId)
: [...prev, criterionId]
)
}
const toggleArt35 = (caseId: string) => {
setArt35Selected(prev =>
prev.includes(caseId)
? prev.filter(id => id !== caseId)
: [...prev, caseId]
)
}
const toggleAITrigger = (triggerId: string) => {
setAiTriggersSelected(prev =>
prev.includes(triggerId)
? prev.filter(id => id !== triggerId)
: [...prev, triggerId]
)
}
const handleSave = async () => {
const thresholdAnalysis: DSFAThresholdAnalysis = {
id: existingAnalysis?.id || crypto.randomUUID(),
dsfa_id: dsfa.id,
performed_at: new Date().toISOString(),
performed_by: 'current_user', // Would come from auth context
criteria_assessment: WP248_CRITERIA.map(c => ({
criterion_id: c.id,
applies: wp248Selected.includes(c.id),
justification: '',
})),
art35_abs3_assessment: ART35_ABS3_CASES.map(c => ({
case_id: c.id,
applies: art35Selected.includes(c.id),
justification: '',
})),
dsfa_required: dsfaRequired ?? recommendation === 'required',
decision_justification: justification,
documented: true,
}
await onUpdate({
threshold_analysis: thresholdAnalysis,
wp248_criteria_met: wp248Selected,
art35_abs3_triggered: art35Selected,
ai_trigger_ids: aiTriggersSelected,
involves_ai: aiTriggersSelected.length > 0,
})
}
return (
<div className="space-y-8">
{/* Step 1: WP248 Criteria */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Schritt 1: WP248 Kriterien pruefen
</h3>
<p className="text-sm text-gray-500 mb-4">
Pruefen Sie, welche der 9 Kriterien der Artikel-29-Datenschutzgruppe auf Ihre Verarbeitung zutreffen.
Bei 2 oder mehr erfuellten Kriterien ist eine DSFA in den meisten Faellen erforderlich.
</p>
<div className="space-y-3">
{WP248_CRITERIA.map((criterion) => (
<label
key={criterion.id}
className={`flex items-start gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
wp248Selected.includes(criterion.id)
? 'bg-purple-50 border-purple-300 ring-1 ring-purple-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={wp248Selected.includes(criterion.id)}
onChange={() => toggleWp248(criterion.id)}
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{criterion.code}:</span>
<span className="text-gray-900">{criterion.title}</span>
</div>
<p className="text-sm text-gray-500 mt-1">{criterion.description}</p>
{criterion.examples.length > 0 && (
<p className="text-xs text-gray-400 mt-1">
Beispiele: {criterion.examples.join(', ')}
</p>
)}
</div>
</label>
))}
</div>
{/* WP248 Summary */}
<div className={`mt-4 p-4 rounded-xl border ${
wp248Selected.length >= 2
? 'bg-orange-50 border-orange-200'
: wp248Selected.length === 1
? 'bg-yellow-50 border-yellow-200'
: 'bg-green-50 border-green-200'
}`}>
<div className="flex items-center gap-2">
{wp248Selected.length >= 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>
) : (
<svg className="w-5 h-5 text-green-500" 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>
)}
<span className="font-medium">
{wp248Selected.length} von 9 Kriterien erfuellt
</span>
</div>
<p className="text-sm mt-1 text-gray-600">
{wp248Result.reason}
</p>
</div>
</div>
{/* Step 2: Art. 35 Abs. 3 Cases */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Schritt 2: Art. 35 Abs. 3 Regelbeispiele
</h3>
<p className="text-sm text-gray-500 mb-4">
Bei Erfuellung eines Regelbeispiels ist eine DSFA zwingend erforderlich.
</p>
<div className="space-y-3">
{ART35_ABS3_CASES.map((caseItem) => (
<label
key={caseItem.id}
className={`flex items-start gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
art35Selected.includes(caseItem.id)
? 'bg-red-50 border-red-300 ring-1 ring-red-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={art35Selected.includes(caseItem.id)}
onChange={() => toggleArt35(caseItem.id)}
className="mt-1 rounded border-gray-300 text-red-600 focus:ring-red-500"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">lit. {caseItem.lit}:</span>
<span className="text-gray-900">{caseItem.title}</span>
</div>
<p className="text-sm text-gray-500 mt-1">{caseItem.description}</p>
<span className="text-xs text-blue-600 mt-1 inline-block">{caseItem.gdprRef}</span>
</div>
</label>
))}
</div>
</div>
{/* Step 3: AI-specific Triggers */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Schritt 3: KI-spezifische Trigger
</h3>
<p className="text-sm text-gray-500 mb-4">
Wird kuenstliche Intelligenz eingesetzt? Diese Trigger sind in der deutschen DSFA-Muss-Liste enthalten.
</p>
<div className="space-y-3">
{AI_DSFA_TRIGGERS.map((trigger) => (
<label
key={trigger.id}
className={`flex items-start gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
aiTriggersSelected.includes(trigger.id)
? 'bg-blue-50 border-blue-300 ring-1 ring-blue-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={aiTriggersSelected.includes(trigger.id)}
onChange={() => toggleAITrigger(trigger.id)}
className="mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<span className="font-medium text-gray-900">{trigger.title}</span>
<p className="text-sm text-gray-500 mt-1">{trigger.description}</p>
{trigger.examples.length > 0 && (
<p className="text-xs text-gray-400 mt-1">
Beispiele: {trigger.examples.join(', ')}
</p>
)}
</div>
</label>
))}
</div>
</div>
{/* Step 4: Decision */}
<div className="border-t border-gray-200 pt-8">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Schritt 4: Entscheidung
</h3>
<p className="text-sm text-gray-500 mb-4">
Dokumentieren Sie Ihre Entscheidung, ob eine DSFA erforderlich ist.
</p>
{/* Recommendation Banner */}
<div className={`mb-6 p-4 rounded-xl border ${
recommendation === 'required'
? 'bg-red-50 border-red-200'
: recommendation === 'possible'
? 'bg-yellow-50 border-yellow-200'
: 'bg-green-50 border-green-200'
}`}>
<div className="flex items-center gap-3">
{recommendation === 'required' ? (
<>
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<svg className="w-6 h-6 text-red-600" 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>
<div>
<p className="font-semibold text-red-800">DSFA erforderlich</p>
<p className="text-sm text-red-600">
Basierend auf Ihrer Auswahl ist eine DSFA in den meisten Faellen Pflicht.
</p>
</div>
</>
) : recommendation === 'possible' ? (
<>
<div className="w-10 h-10 rounded-full bg-yellow-100 flex items-center justify-center">
<svg className="w-6 h-6 text-yellow-600" 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>
</div>
<div>
<p className="font-semibold text-yellow-800">DSFA moeglicherweise erforderlich</p>
<p className="text-sm text-yellow-600">
Einzelfallpruefung empfohlen. Bei Unsicherheit DSFA durchfuehren.
</p>
</div>
</>
) : (
<>
<div className="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<p className="font-semibold text-green-800">DSFA wahrscheinlich nicht erforderlich</p>
<p className="text-sm text-green-600">
Keine Pflichtkriterien erfuellt. Dokumentieren Sie diese Entscheidung.
</p>
</div>
</>
)}
</div>
</div>
{/* Decision Radio Buttons */}
<div className="space-y-3 mb-6">
<label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
dsfaRequired === true
? 'bg-purple-50 border-purple-300 ring-1 ring-purple-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}>
<input
type="radio"
name="dsfaRequired"
checked={dsfaRequired === true}
onChange={() => setDsfaRequired(true)}
className="text-purple-600 focus:ring-purple-500"
/>
<div>
<span className="font-medium text-gray-900">DSFA erforderlich</span>
<p className="text-sm text-gray-500">Ich fuehre eine vollstaendige DSFA durch.</p>
</div>
</label>
<label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
dsfaRequired === false
? 'bg-purple-50 border-purple-300 ring-1 ring-purple-300'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}>
<input
type="radio"
name="dsfaRequired"
checked={dsfaRequired === false}
onChange={() => setDsfaRequired(false)}
className="text-purple-600 focus:ring-purple-500"
/>
<div>
<span className="font-medium text-gray-900">DSFA nicht erforderlich</span>
<p className="text-sm text-gray-500">
Die Verarbeitung erfordert keine DSFA. Die Entscheidung wird dokumentiert.
</p>
</div>
</label>
</div>
{/* Justification */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Begruendung der Entscheidung *
</label>
<p className="text-xs text-gray-500 mb-2">
Gem. DSK Kurzpapier Nr. 5 ist die Entscheidung und ihre Begruendung zu dokumentieren.
</p>
<textarea
value={justification}
onChange={(e) => setJustification(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
rows={4}
placeholder="Begruenden Sie, warum eine DSFA erforderlich/nicht erforderlich ist..."
/>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
onClick={handleSave}
disabled={isSubmitting || dsfaRequired === null || !justification.trim()}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{isSubmitting ? 'Speichern...' : 'Entscheidung speichern & fortfahren'}
</button>
</div>
</div>
)
}

View File

@@ -2,6 +2,16 @@
import React from 'react'
// =============================================================================
// RE-EXPORTS FROM SEPARATE FILES
// =============================================================================
export { ThresholdAnalysisSection } from './ThresholdAnalysisSection'
export { DSFASidebar } from './DSFASidebar'
export { StakeholderConsultationSection } from './StakeholderConsultationSection'
export { Art36Warning } from './Art36Warning'
export { ReviewScheduleSection } from './ReviewScheduleSection'
// =============================================================================
// DSFA Card Component
// =============================================================================
@@ -62,56 +72,83 @@ export function DSFACard({ dsfa, onDelete, onExport }: DSFACardProps) {
// Risk Matrix Component
// =============================================================================
interface RiskMatrixProps {
risks: Array<{
id: string
title: string
probability: number
impact: number
risk_level?: string
}>
onRiskClick?: (riskId: string) => void
// DSFARisk type matching lib/sdk/dsfa/types.ts
interface DSFARiskInput {
id: string
category?: string
description: string
likelihood: 'low' | 'medium' | 'high'
impact: 'low' | 'medium' | 'high'
risk_level?: string
affected_data?: string[]
}
export function RiskMatrix({ risks, onRiskClick }: RiskMatrixProps) {
const levels = [1, 2, 3, 4, 5]
const levelLabels = ['Sehr gering', 'Gering', 'Mittel', 'Hoch', 'Sehr hoch']
interface RiskMatrixProps {
risks: DSFARiskInput[]
onRiskSelect?: (risk: DSFARiskInput) => void
onRiskClick?: (riskId: string) => void
onAddRisk?: (likelihood: 'low' | 'medium' | 'high', impact: 'low' | 'medium' | 'high') => void
selectedRiskId?: string
readOnly?: boolean
}
export function RiskMatrix({ risks, onRiskSelect, onRiskClick, onAddRisk, selectedRiskId, readOnly }: RiskMatrixProps) {
const likelihoodLevels: Array<'low' | 'medium' | 'high'> = ['high', 'medium', 'low']
const impactLevels: Array<'low' | 'medium' | 'high'> = ['low', 'medium', 'high']
const levelLabels = { low: 'Niedrig', medium: 'Mittel', high: 'Hoch' }
const cellColors: Record<string, string> = {
low: 'bg-green-100 hover:bg-green-200',
medium: 'bg-yellow-100 hover:bg-yellow-200',
high: 'bg-orange-100 hover:bg-orange-200',
critical: 'bg-red-100 hover:bg-red-200',
very_high: 'bg-red-100 hover:bg-red-200',
}
const getRiskColor = (prob: number, impact: number) => {
const score = prob * impact
if (score <= 4) return cellColors.low
if (score <= 9) return cellColors.medium
if (score <= 16) return cellColors.high
return cellColors.critical
const getRiskColor = (likelihood: 'low' | 'medium' | 'high', impact: 'low' | 'medium' | 'high') => {
const matrix: Record<string, Record<string, string>> = {
low: { low: 'low', medium: 'low', high: 'medium' },
medium: { low: 'low', medium: 'medium', high: 'high' },
high: { low: 'medium', medium: 'high', high: 'very_high' },
}
return cellColors[matrix[likelihood]?.[impact] || 'medium']
}
const handleCellClick = (likelihood: 'low' | 'medium' | 'high', impact: 'low' | 'medium' | 'high') => {
const cellRisks = risks.filter(r => r.likelihood === likelihood && r.impact === impact)
if (cellRisks.length > 0 && onRiskSelect) {
onRiskSelect(cellRisks[0])
} else if (cellRisks.length > 0 && onRiskClick) {
onRiskClick(cellRisks[0].id)
} else if (!readOnly && onAddRisk) {
onAddRisk(likelihood, impact)
}
}
return React.createElement('div', { className: 'bg-white rounded-xl border border-slate-200 p-5' },
React.createElement('h3', { className: 'font-semibold text-slate-900 mb-4' }, 'Risikomatrix'),
React.createElement('div', { className: 'grid grid-cols-6 gap-1' },
React.createElement('div', { className: 'text-xs text-slate-500 mb-2' }, 'Eintrittswahrscheinlichkeit ↑ | Schwere →'),
React.createElement('div', { className: 'grid grid-cols-4 gap-1' },
// Header row
React.createElement('div'),
...levels.map(l => React.createElement('div', {
key: `h-${l}`,
...impactLevels.map(i => React.createElement('div', {
key: `h-${i}`,
className: 'text-center text-xs text-slate-500 py-1'
}, levelLabels[l - 1])),
...levels.reverse().map(prob =>
}, levelLabels[i])),
// Grid rows
...likelihoodLevels.map(likelihood =>
[
React.createElement('div', {
key: `l-${prob}`,
key: `l-${likelihood}`,
className: 'text-right text-xs text-slate-500 pr-2 flex items-center justify-end'
}, levelLabels[prob - 1]),
...levels.map(impact => {
const cellRisks = risks.filter(r => r.probability === prob && r.impact === impact)
}, levelLabels[likelihood]),
...impactLevels.map(impact => {
const cellRisks = risks.filter(r => r.likelihood === likelihood && r.impact === impact)
const isSelected = cellRisks.some(r => r.id === selectedRiskId)
return React.createElement('div', {
key: `${prob}-${impact}`,
className: `aspect-square rounded ${getRiskColor(prob, impact)} flex items-center justify-center text-xs font-medium cursor-pointer`,
onClick: () => cellRisks[0] && onRiskClick?.(cellRisks[0].id)
}, cellRisks.length > 0 ? String(cellRisks.length) : '')
key: `${likelihood}-${impact}`,
className: `aspect-square rounded ${getRiskColor(likelihood, impact)} flex items-center justify-center text-xs font-medium cursor-pointer ${isSelected ? 'ring-2 ring-purple-500' : ''}`,
onClick: () => handleCellClick(likelihood, impact)
}, cellRisks.length > 0 ? String(cellRisks.length) : (readOnly ? '' : '+'))
})
]
).flat()