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

@@ -21,8 +21,17 @@ import {
removeDSFARisk,
addDSFAMitigation,
updateDSFAMitigationStatus,
updateDSFA,
} from '@/lib/sdk/dsfa/api'
import { RiskMatrix, ApprovalPanel } from '@/components/sdk/dsfa'
import {
RiskMatrix,
ApprovalPanel,
DSFASidebar,
ThresholdAnalysisSection,
StakeholderConsultationSection,
Art36Warning,
ReviewScheduleSection,
} from '@/components/sdk/dsfa'
// =============================================================================
// SECTION EDITORS
@@ -1013,7 +1022,7 @@ export default function DSFAEditorPage() {
const [dsfa, setDSFA] = useState<DSFA | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [activeSection, setActiveSection] = useState(1)
const [activeSection, setActiveSection] = useState(0) // Start at Section 0: Threshold Analysis
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
// Load DSFA data
@@ -1111,6 +1120,26 @@ export default function DSFAEditorPage() {
needs_update: 'bg-orange-100 text-orange-700',
}
// Handler for generic DSFA updates (used by new section components)
const handleGenericUpdate = useCallback(async (data: Record<string, unknown>) => {
if (!dsfa) return
setIsSaving(true)
setSaveMessage(null)
try {
const updated = await updateDSFA(dsfaId, data as Partial<DSFA>)
setDSFA(updated)
setSaveMessage({ type: 'success', text: 'Abschnitt gespeichert' })
setTimeout(() => setSaveMessage(null), 3000)
} catch (error) {
console.error('Failed to update DSFA:', error)
setSaveMessage({ type: 'error', text: 'Fehler beim Speichern' })
} finally {
setIsSaving(false)
}
}, [dsfa, dsfaId])
return (
<div className="space-y-6">
{/* Header */}
@@ -1162,64 +1191,24 @@ export default function DSFAEditorPage() {
)}
</div>
{/* Main Content: 2/3 + 1/3 Layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - 2/3 */}
<div className="lg:col-span-2 space-y-6">
{/* Section Tabs */}
<div className="bg-white rounded-xl border border-gray-200">
<div className="border-b border-gray-200">
<nav className="flex -mb-px overflow-x-auto">
{DSFA_SECTIONS.map((section) => {
const progress = dsfa.section_progress
const isComplete = section.number === 1 ? progress.section_1_complete :
section.number === 2 ? progress.section_2_complete :
section.number === 3 ? progress.section_3_complete :
section.number === 4 ? progress.section_4_complete :
progress.section_5_complete
return (
<button
key={section.number}
onClick={() => setActiveSection(section.number)}
className={`
flex items-center gap-2 px-4 py-4 text-sm font-medium border-b-2 transition-colors whitespace-nowrap
${activeSection === section.number
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<span className={`
w-6 h-6 rounded-full flex items-center justify-center text-xs
${isComplete
? 'bg-green-100 text-green-600'
: activeSection === section.number
? 'bg-purple-100 text-purple-600'
: 'bg-gray-100 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>
) : (
section.number
)}
</span>
{section.titleDE}
{!section.required && (
<span className="text-xs text-gray-400">(optional)</span>
)}
</button>
)
})}
</nav>
{/* Main Content: Sidebar + Content Layout */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Left Column - Sidebar (1/4) */}
<div className="lg:col-span-1">
<DSFASidebar
dsfa={dsfa}
activeSection={activeSection}
onSectionChange={setActiveSection}
/>
</div>
{/* Right Column - Content (3/4) */}
<div className="lg:col-span-3 space-y-6">
{/* Section Content Card */}
<div className="bg-white rounded-xl border border-gray-200">
{/* Section Header */}
{sectionConfig && (
<div className="p-4 bg-gray-50 border-b border-gray-200">
<div className="p-4 bg-gray-50 border-b border-gray-200 rounded-t-xl">
<div className="flex items-start justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900">
@@ -1236,6 +1225,16 @@ export default function DSFAEditorPage() {
{/* Section Content */}
<div className="p-6">
{/* Section 0: Threshold Analysis (NEW) */}
{activeSection === 0 && (
<ThresholdAnalysisSection
dsfa={dsfa}
onUpdate={handleGenericUpdate}
isSubmitting={isSaving}
/>
)}
{/* Sections 1-4: Existing */}
{activeSection === 1 && (
<Section1Editor
dsfa={dsfa}
@@ -1264,72 +1263,100 @@ export default function DSFAEditorPage() {
isSubmitting={isSaving}
/>
)}
{/* Section 5: Stakeholder Consultation (NEW) */}
{activeSection === 5 && (
<StakeholderConsultationSection
dsfa={dsfa}
onUpdate={handleGenericUpdate}
isSubmitting={isSaving}
/>
)}
{/* Section 6: DPO & Authority Consultation */}
{activeSection === 6 && (
<div className="space-y-6">
{/* Original Section 5 Editor (DPO Opinion) */}
<Section5Editor
dsfa={dsfa}
onUpdate={(data) => handleSectionUpdate(5, data)}
isSubmitting={isSaving}
/>
{/* Art. 36 Warning (NEW) */}
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Art. 36 Behoerdenkonsultation
</h3>
<Art36Warning
dsfa={dsfa}
onUpdate={handleGenericUpdate}
isSubmitting={isSaving}
/>
</div>
</div>
)}
{/* Section 7: Review & Maintenance (NEW) */}
{activeSection === 7 && (
<ReviewScheduleSection
dsfa={dsfa}
onUpdate={handleGenericUpdate}
isSubmitting={isSaving}
/>
)}
</div>
</div>
</div>
{/* Right Column - 1/3 Sidebar */}
<div className="space-y-6">
{/* Approval Panel */}
<ApprovalPanel
dsfa={dsfa}
onSubmitForReview={handleSubmitForReview}
onApprove={handleApprove}
onReject={handleReject}
isSubmitting={isSaving}
userRole="editor"
/>
{/* Bottom Actions Row */}
<div className="flex items-center justify-between bg-white rounded-xl border border-gray-200 p-4">
{/* Navigation */}
<div className="flex items-center gap-2">
{activeSection > 0 && (
<button
onClick={() => setActiveSection(activeSection - 1)}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg 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="M15 19l-7-7 7-7" />
</svg>
Zurueck
</button>
)}
{activeSection < 7 && (
<button
onClick={() => setActiveSection(activeSection + 1)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Weiter
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
</div>
{/* Quick Info */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-medium text-gray-700 mb-4">Informationen</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Erstellt am</span>
<span className="text-gray-900">
{new Date(dsfa.created_at).toLocaleDateString('de-DE')}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Zuletzt aktualisiert</span>
<span className="text-gray-900">
{new Date(dsfa.updated_at).toLocaleDateString('de-DE')}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Risiken</span>
<span className="text-gray-900">{(dsfa.risks || []).length}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Massnahmen</span>
<span className="text-gray-900">{(dsfa.mitigations || []).length}</span>
</div>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>Risiken: {(dsfa.risks || []).length}</span>
<span>Massnahmen: {(dsfa.mitigations || []).length}</span>
<span>Version: {dsfa.version || 1}</span>
</div>
{/* Export Options */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-medium text-gray-700 mb-4">Export</h3>
<div className="space-y-2">
<button className="w-full flex items-center gap-3 px-4 py-2 text-left text-gray-700 hover:bg-gray-50 rounded-lg transition-colors">
{/* Export */}
<div className="flex items-center gap-2">
<button className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors">
<svg className="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"/>
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/>
</svg>
Als PDF exportieren
PDF
</button>
<button className="w-full flex items-center gap-3 px-4 py-2 text-left text-gray-700 hover:bg-gray-50 rounded-lg transition-colors">
<button className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors">
<svg className="w-5 h-5 text-blue-500" 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>
Als JSON exportieren
JSON
</button>
</div>
</div>

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<{
// DSFARisk type matching lib/sdk/dsfa/types.ts
interface DSFARiskInput {
id: string
title: string
probability: number
impact: number
category?: string
description: string
likelihood: 'low' | 'medium' | 'high'
impact: 'low' | 'medium' | 'high'
risk_level?: string
}>
onRiskClick?: (riskId: string) => void
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()

View File

@@ -58,6 +58,355 @@ export const DSFA_AFFECTED_RIGHTS = [
{ id: 'data_security', label: 'Datensicherheit' },
]
// =============================================================================
// WP248 REV.01 KRITERIEN (Schwellwertanalyse)
// Quelle: Artikel-29-Datenschutzgruppe, bestätigt durch EDSA
// =============================================================================
export interface WP248Criterion {
id: string
code: string
title: string
description: string
examples: string[]
gdprRef?: string
}
/**
* WP248 rev.01 Kriterien zur Bestimmung der DSFA-Pflicht
* Regel: Bei >= 2 erfüllten Kriterien ist DSFA in den meisten Fällen erforderlich
*/
export const WP248_CRITERIA: WP248Criterion[] = [
{
id: 'scoring_profiling',
code: 'K1',
title: 'Bewertung oder Scoring',
description: 'Einschließlich Profiling und Prognosen, insbesondere zu Arbeitsleistung, wirtschaftlicher Lage, Gesundheit, persönlichen Vorlieben, Zuverlässigkeit, Verhalten, Aufenthaltsort oder Ortswechsel.',
examples: ['Bonitätsprüfung', 'Leistungsbeurteilung', 'Verhaltensanalyse'],
gdprRef: 'Art. 35 Abs. 3 lit. a DSGVO',
},
{
id: 'automated_decision',
code: 'K2',
title: 'Automatisierte Entscheidungsfindung mit Rechtswirkung',
description: 'Automatisierte Verarbeitung, die als Grundlage für Entscheidungen dient, die Rechtswirkung gegenüber natürlichen Personen entfalten oder diese erheblich beeinträchtigen.',
examples: ['Automatische Kreditvergabe', 'Automatische Bewerbungsablehnung', 'Algorithmenbasierte Preisgestaltung'],
gdprRef: 'Art. 22 DSGVO',
},
{
id: 'systematic_monitoring',
code: 'K3',
title: 'Systematische Überwachung',
description: 'Verarbeitung zur Beobachtung, Überwachung oder Kontrolle von betroffenen Personen, einschließlich Datenerhebung über Netzwerke oder systematische Überwachung öffentlicher Bereiche.',
examples: ['Videoüberwachung', 'WLAN-Tracking', 'GPS-Ortung', 'Mitarbeiterüberwachung'],
gdprRef: 'Art. 35 Abs. 3 lit. c DSGVO',
},
{
id: 'sensitive_data',
code: 'K4',
title: 'Sensible Daten oder höchst persönliche Daten',
description: 'Verarbeitung besonderer Kategorien personenbezogener Daten (Art. 9), strafrechtlicher Daten (Art. 10) oder anderer höchst persönlicher Daten wie Kommunikationsinhalte, Standortdaten, Finanzinformationen.',
examples: ['Gesundheitsdaten', 'Biometrische Daten', 'Genetische Daten', 'Politische Meinungen', 'Gewerkschaftszugehörigkeit'],
gdprRef: 'Art. 9, Art. 10 DSGVO',
},
{
id: 'large_scale',
code: 'K5',
title: 'Datenverarbeitung in großem Umfang',
description: 'Berücksichtigt werden: Zahl der Betroffenen, Datenmenge, Dauer der Verarbeitung, geografische Reichweite.',
examples: ['Landesweite Datenbanken', 'Millionen von Nutzern', 'Mehrjährige Speicherung'],
gdprRef: 'Erwägungsgrund 91 DSGVO',
},
{
id: 'matching_combining',
code: 'K6',
title: 'Abgleichen oder Zusammenführen von Datensätzen',
description: 'Datensätze aus verschiedenen Quellen, die für unterschiedliche Zwecke und/oder von verschiedenen Verantwortlichen erhoben wurden, werden abgeglichen oder zusammengeführt.',
examples: ['Data Warehousing', 'Big Data Analytics', 'Zusammenführung von Online-/Offline-Daten'],
},
{
id: 'vulnerable_subjects',
code: 'K7',
title: 'Daten zu schutzbedürftigen Betroffenen',
description: 'Verarbeitung von Daten schutzbedürftiger Personen, bei denen ein Ungleichgewicht zwischen Betroffenem und Verantwortlichem besteht.',
examples: ['Kinder/Minderjährige', 'Arbeitnehmer', 'Patienten', 'Ältere Menschen', 'Asylbewerber'],
gdprRef: 'Erwägungsgrund 75 DSGVO',
},
{
id: 'innovative_technology',
code: 'K8',
title: 'Innovative Nutzung oder Anwendung neuer technologischer oder organisatorischer Lösungen',
description: 'Einsatz neuer Technologien kann neue Formen der Datenerhebung und -nutzung mit sich bringen, möglicherweise mit hohem Risiko für Rechte und Freiheiten.',
examples: ['Künstliche Intelligenz', 'Machine Learning', 'IoT-Geräte', 'Biometrische Erkennung', 'Blockchain'],
gdprRef: 'Erwägungsgrund 89, 91 DSGVO',
},
{
id: 'preventing_rights',
code: 'K9',
title: 'Verarbeitung, die Betroffene an der Ausübung eines Rechts oder der Nutzung einer Dienstleistung hindert',
description: 'Verarbeitungsvorgänge, die darauf abzielen, einer Person den Zugang zu einer Dienstleistung oder den Abschluss eines Vertrags zu ermöglichen oder zu verweigern.',
examples: ['Zugang zu Sozialleistungen', 'Kreditvergabe', 'Versicherungsabschluss'],
gdprRef: 'Art. 22 DSGVO',
},
]
// =============================================================================
// DSFA MUSS-LISTEN NACH BUNDESLÄNDERN
// Quellen: Jeweilige Landesdatenschutzbeauftragte
// =============================================================================
export interface DSFAAuthorityResource {
id: string
name: string
shortName: string
state: string // Bundesland oder 'Bund'
overviewUrl: string
publicSectorListUrl?: string
privateSectorListUrl?: string
templateUrl?: string
additionalResources?: Array<{ title: string; url: string }>
}
export const DSFA_AUTHORITY_RESOURCES: DSFAAuthorityResource[] = [
{
id: 'bund',
name: 'Bundesbeauftragter für den Datenschutz und die Informationsfreiheit',
shortName: 'BfDI',
state: 'Bund',
overviewUrl: 'https://www.bfdi.bund.de/DE/Fachthemen/Inhalte/Technik/Datenschutz-Folgenabschaetzungen.html',
publicSectorListUrl: 'https://www.bfdi.bund.de/SharedDocs/Downloads/DE/Muster/Liste_VerarbeitungsvorgaengeArt35.pdf',
templateUrl: 'https://www.bfdi.bund.de/SharedDocs/Downloads/DE/Muster/Muster_Hinweise_DSFA.html',
},
{
id: 'bw',
name: 'Landesbeauftragter für den Datenschutz und die Informationsfreiheit Baden-Württemberg',
shortName: 'LfDI BW',
state: 'Baden-Württemberg',
overviewUrl: 'https://www.baden-wuerttemberg.datenschutz.de/datenschutz-folgenabschaetzung/',
privateSectorListUrl: 'https://www.baden-wuerttemberg.datenschutz.de/wp-content/uploads/2018/05/Liste-von-Verarbeitungsvorg%C3%A4ngen-nach-Art.-35-Abs.-4-DS-GVO-LfDI-BW.pdf',
},
{
id: 'by',
name: 'Bayerischer Landesbeauftragter für den Datenschutz',
shortName: 'BayLfD',
state: 'Bayern',
overviewUrl: 'https://www.datenschutz-bayern.de/dsfa/',
additionalResources: [
{ title: 'DSFA-Module und Formulare', url: 'https://www.datenschutz-bayern.de/dsfa/' },
],
},
{
id: 'be',
name: 'Berliner Beauftragte für Datenschutz und Informationsfreiheit',
shortName: 'BlnBDI',
state: 'Berlin',
overviewUrl: 'https://www.datenschutz-berlin.de/themen/unternehmen/datenschutz-folgenabschaetzung/',
publicSectorListUrl: 'https://www.datenschutz-berlin.de/fileadmin/user_upload/pdf/dokumente/2018-BlnBDI_DSFA-oeffentlich.pdf',
privateSectorListUrl: 'https://www.datenschutz-berlin.de/fileadmin/user_upload/pdf/dokumente/2018-BlnBDI_DSFA-nicht-oeffentlich.pdf',
},
{
id: 'bb',
name: 'Landesbeauftragte für den Datenschutz und für das Recht auf Akteneinsicht Brandenburg',
shortName: 'LDA BB',
state: 'Brandenburg',
overviewUrl: 'https://www.lda.brandenburg.de/lda/de/datenschutz/datenschutz-folgenabschaetzung/',
publicSectorListUrl: 'https://www.lda.brandenburg.de/sixcms/media.php/9/DSFA-Liste_%C3%B6ffentlicher_Bereich.pdf',
privateSectorListUrl: 'https://www.lda.brandenburg.de/sixcms/media.php/9/DSFA-Liste_nicht_%C3%B6ffentlicher_Bereich.pdf',
},
{
id: 'hb',
name: 'Landesbeauftragte für Datenschutz und Informationsfreiheit Bremen',
shortName: 'LfDI HB',
state: 'Bremen',
overviewUrl: 'https://www.datenschutz.bremen.de/datenschutz/datenschutz-folgenabschaetzung-3884',
publicSectorListUrl: 'https://www.datenschutz.bremen.de/sixcms/media.php/13/Liste%20von%20Verarbeitungsvorg%C3%A4ngen%20nach%20Artikel%2035.pdf',
privateSectorListUrl: 'https://www.datenschutz.bremen.de/sixcms/media.php/13/DSFA%20Muss-Liste%20LfDI%20HB.pdf',
},
{
id: 'hh',
name: 'Hamburgischer Beauftragter für Datenschutz und Informationsfreiheit',
shortName: 'HmbBfDI',
state: 'Hamburg',
overviewUrl: 'https://datenschutz-hamburg.de/datenschutz-folgenabschaetzung',
publicSectorListUrl: 'https://datenschutz-hamburg.de/fileadmin/user_upload/HmbBfDI/Datenschutz/Informationen/Liste_Art_35-4_DSGVO_HmbBfDI-oeffentlicher_Bereich_v2.0a.pdf',
privateSectorListUrl: 'https://datenschutz-hamburg.de/fileadmin/user_upload/HmbBfDI/Datenschutz/Informationen/DSFA_Muss-Liste_fuer_den_nicht-oeffentlicher_Bereich_-_Stand_17.10.2018.pdf',
},
{
id: 'he',
name: 'Hessischer Beauftragter für Datenschutz und Informationsfreiheit',
shortName: 'HBDI',
state: 'Hessen',
overviewUrl: 'https://datenschutz.hessen.de/datenschutz/it-und-datenschutz/datenschutz-folgenabschaetzung',
},
{
id: 'mv',
name: 'Landesbeauftragter für Datenschutz und Informationsfreiheit Mecklenburg-Vorpommern',
shortName: 'LfDI MV',
state: 'Mecklenburg-Vorpommern',
overviewUrl: 'https://www.datenschutz-mv.de/datenschutz/DSGVO/Hilfsmittel-zur-Umsetzung/',
publicSectorListUrl: 'https://www.datenschutz-mv.de/static/DS/Dateien/DS-GVO/HilfsmittelzurUmsetzung/MV-DSFA-Muss-Liste-Oeffentlicher-Bereich.pdf',
},
{
id: 'ni',
name: 'Die Landesbeauftragte für den Datenschutz Niedersachsen',
shortName: 'LfD NI',
state: 'Niedersachsen',
overviewUrl: 'https://www.lfd.niedersachsen.de/dsgvo/liste_von_verarbeitungsvorgangen_nach_art_35_abs_4_ds_gvo/muss-listen-zur-datenschutz-folgenabschatzung-179663.html',
publicSectorListUrl: 'https://www.lfd.niedersachsen.de/download/134414/DSFA_Muss-Liste_fuer_den_oeffentlichen_Bereich.pdf',
privateSectorListUrl: 'https://www.lfd.niedersachsen.de/download/131098/Liste_von_Verarbeitungsvorgaengen_nach_Art._35_Abs._4_DS-GVO.pdf',
},
{
id: 'nw',
name: 'Landesbeauftragte für Datenschutz und Informationsfreiheit Nordrhein-Westfalen',
shortName: 'LDI NRW',
state: 'Nordrhein-Westfalen',
overviewUrl: 'https://www.ldi.nrw.de/datenschutz/wirtschaft/datenschutz-folgenabschaetzung',
publicSectorListUrl: 'https://www.ldi.nrw.de/liste-von-verarbeitungsvorgaengen-nach-art-35-abs-4-ds-gvo-fuer-den-oeffentlichen-bereich',
},
{
id: 'rp',
name: 'Landesbeauftragter für den Datenschutz und die Informationsfreiheit Rheinland-Pfalz',
shortName: 'LfDI RP',
state: 'Rheinland-Pfalz',
overviewUrl: 'https://www.datenschutz.rlp.de/themen/datenschutz-folgenabschaetzung',
},
{
id: 'sl',
name: 'Unabhängiges Datenschutzzentrum Saarland',
shortName: 'UDZ SL',
state: 'Saarland',
overviewUrl: 'https://www.datenschutz.saarland.de/themen/datenschutz-folgenabschaetzung',
privateSectorListUrl: 'https://www.datenschutz.saarland.de/fileadmin/user_upload/uds/alle_Dateien_und_Ordner_bis_2025/Download/dsfa_muss_liste_dsk_de.pdf',
},
{
id: 'sn',
name: 'Sächsische Datenschutz- und Transparenzbeauftragte',
shortName: 'SDTB',
state: 'Sachsen',
overviewUrl: 'https://www.datenschutz.sachsen.de/datenschutz-folgenabschaetzung.html',
additionalResources: [
{ title: 'Erforderlichkeit der DSFA', url: 'https://www.datenschutz.sachsen.de/erforderlichkeit.html' },
],
},
{
id: 'st',
name: 'Landesbeauftragter für den Datenschutz Sachsen-Anhalt',
shortName: 'LfD ST',
state: 'Sachsen-Anhalt',
overviewUrl: 'https://datenschutz.sachsen-anhalt.de/informationen/datenschutz-grundverordnung/liste-datenschutz-folgenabschaetzung',
publicSectorListUrl: 'https://datenschutz.sachsen-anhalt.de/fileadmin/Bibliothek/Landesaemter/LfD/Informationen/Internationales/Datenschutz-Grundverordnung/Liste_DSFA/Art-35-Liste-oeffentlicher_Bereich.pdf',
privateSectorListUrl: 'https://datenschutz.sachsen-anhalt.de/fileadmin/Bibliothek/Landesaemter/LfD/Informationen/Internationales/Datenschutz-Grundverordnung/Liste_DSFA/Art-35-Liste-nichtoeffentlicher_Bereich.pdf',
},
{
id: 'sh',
name: 'Unabhängiges Landeszentrum für Datenschutz Schleswig-Holstein',
shortName: 'ULD SH',
state: 'Schleswig-Holstein',
overviewUrl: 'https://www.datenschutzzentrum.de/datenschutzfolgenabschaetzung/',
privateSectorListUrl: 'https://www.datenschutzzentrum.de/uploads/datenschutzfolgenabschaetzung/20180525_LfD-SH_DSFA_Muss-Liste_V1.0.pdf',
additionalResources: [
{ title: 'Begleittext zur DSFA-Liste', url: 'https://www.datenschutzzentrum.de/uploads/dsgvo/2018_0807_LfD-SH_DSFA_Begleittext_V1.0a.pdf' },
],
},
{
id: 'th',
name: 'Thüringer Landesbeauftragter für den Datenschutz und die Informationsfreiheit',
shortName: 'TLfDI',
state: 'Thüringen',
overviewUrl: 'https://www.tlfdi.de/datenschutz/datenschutz-folgenabschaetzung/',
privateSectorListUrl: 'https://tlfdi.de/fileadmin/tlfdi/datenschutz/dsfa_muss-liste_04_07_18.pdf',
additionalResources: [
{ title: 'Handreichung DS-FA (nicht-öffentlich)', url: 'https://tlfdi.de/fileadmin/tlfdi/datenschutz/handreichung_ds-fa.pdf' },
{ title: 'Handreichung DS-FA (öffentlich)', url: 'https://tlfdi.de/fileadmin/tlfdi/Europa/Handreichung_zur_Datenschutz-Folgenabschaetzung_oeffentlicher_Bereich.pdf' },
],
},
]
// =============================================================================
// DSK KURZPAPIER NR. 5 REFERENZEN
// =============================================================================
export const DSK_KURZPAPIER_5 = {
title: 'Kurzpapier Nr. 5: Datenschutz-Folgenabschätzung nach Art. 35 DS-GVO',
source: 'Datenschutzkonferenz (DSK)',
url: 'https://www.datenschutzkonferenz-online.de/media/kp/dsk_kpnr_5.pdf',
license: 'Datenlizenz Deutschland Namensnennung Version 2.0 (DL-DE BY 2.0)',
licenseUrl: 'https://www.govdata.de/dl-de/by-2-0',
processSteps: [
{ step: 1, title: 'Projektteam bilden', description: 'Interdisziplinäres Team aus Datenschutz, Fachprozess, IT/Sicherheit' },
{ step: 2, title: 'Verarbeitung abgrenzen', description: 'Scope definieren, Datenflüsse und Zwecke beschreiben' },
{ step: 3, title: 'Prüfung der Notwendigkeit', description: 'Alternativen prüfen, Datenminimierung bewerten' },
{ step: 4, title: 'Risiken identifizieren', description: 'Risikoquellen ermitteln, Schäden bewerten' },
{ step: 5, title: 'Maßnahmen festlegen', description: 'TOM definieren, Restrisiko bewerten' },
{ step: 6, title: 'Bericht erstellen', description: 'DSFA-Bericht dokumentieren, ggf. veröffentlichen' },
{ step: 7, title: 'Fortschreibung', description: 'DSFA bei Änderungen aktualisieren' },
],
}
// =============================================================================
// ART. 35 ABS. 3 DSGVO - REGELBEISPIELE
// =============================================================================
export const ART35_ABS3_CASES = [
{
id: 'profiling_legal_effects',
lit: 'a',
title: 'Profiling mit Rechtswirkung',
description: 'Systematische und umfassende Bewertung persönlicher Aspekte natürlicher Personen, die sich auf automatisierte Verarbeitung einschließlich Profiling gründet und die ihrerseits als Grundlage für Entscheidungen dient, die Rechtswirkung gegenüber natürlichen Personen entfalten oder diese in ähnlich erheblicher Weise beeinträchtigen.',
gdprRef: 'Art. 35 Abs. 3 lit. a DSGVO',
},
{
id: 'special_categories',
lit: 'b',
title: 'Besondere Datenkategorien in großem Umfang',
description: 'Umfangreiche Verarbeitung besonderer Kategorien von personenbezogenen Daten gemäß Artikel 9 Absatz 1 oder von personenbezogenen Daten über strafrechtliche Verurteilungen und Straftaten gemäß Artikel 10.',
gdprRef: 'Art. 35 Abs. 3 lit. b DSGVO',
},
{
id: 'public_monitoring',
lit: 'c',
title: 'Systematische Überwachung öffentlicher Bereiche',
description: 'Systematische umfangreiche Überwachung öffentlich zugänglicher Bereiche.',
gdprRef: 'Art. 35 Abs. 3 lit. c DSGVO',
},
]
// =============================================================================
// KI-SPEZIFISCHE DSFA-TRIGGER
// Quelle: Deutsche DSFA-Liste (nicht-öffentlicher Bereich)
// =============================================================================
export const AI_DSFA_TRIGGERS = [
{
id: 'ai_interaction',
title: 'KI zur Steuerung der Interaktion mit Betroffenen',
description: 'Einsatz von künstlicher Intelligenz zur Steuerung der Interaktion mit betroffenen Personen.',
examples: ['KI-gestützter Kundensupport', 'Chatbots mit personenbezogener Verarbeitung', 'Automatisierte Kommunikation'],
requiresDSFA: true,
},
{
id: 'ai_personal_aspects',
title: 'KI zur Bewertung persönlicher Aspekte',
description: 'Einsatz von künstlicher Intelligenz zur Bewertung persönlicher Aspekte natürlicher Personen.',
examples: ['Automatisierte Stimmungsanalyse', 'Verhaltensvorhersagen', 'Persönlichkeitsprofile'],
requiresDSFA: true,
},
{
id: 'ai_decision_making',
title: 'KI-basierte automatisierte Entscheidungen',
description: 'Automatisierte Entscheidungsfindung auf Basis von KI mit erheblicher Auswirkung auf Betroffene.',
examples: ['Automatische Kreditvergabe', 'KI-basiertes Recruiting', 'Algorithmenbasierte Preisgestaltung'],
requiresDSFA: true,
},
{
id: 'ai_training_personal_data',
title: 'KI-Training mit personenbezogenen Daten',
description: 'Training von KI-Modellen mit personenbezogenen Daten, insbesondere sensiblen Daten.',
examples: ['Training mit Gesundheitsdaten', 'Fine-Tuning mit Kundendaten', 'ML mit biometrischen Daten'],
requiresDSFA: true,
},
]
// =============================================================================
// SUB-TYPES
// =============================================================================
@@ -95,11 +444,101 @@ export interface DSFAReviewComment {
}
export interface DSFASectionProgress {
section_1_complete: boolean
section_2_complete: boolean
section_3_complete: boolean
section_4_complete: boolean
section_5_complete: boolean
section_0_complete: boolean // Schwellwertanalyse
section_1_complete: boolean // Systematische Beschreibung
section_2_complete: boolean // Notwendigkeit & Verhältnismäßigkeit
section_3_complete: boolean // Risikobewertung
section_4_complete: boolean // Abhilfemaßnahmen
section_5_complete: boolean // Betroffenenperspektive (optional)
section_6_complete: boolean // DSB & Behördenkonsultation
section_7_complete: boolean // Fortschreibung & Review
}
// =============================================================================
// SCHWELLWERTANALYSE / VORABPRÜFUNG (Art. 35 Abs. 1 DSGVO)
// =============================================================================
export interface DSFAThresholdAnalysis {
id: string
dsfa_id?: string
performed_at: string
performed_by: string
// WP248 Kriterien-Bewertung
criteria_assessment: Array<{
criterion_id: string // K1-K9
applies: boolean
justification: string
}>
// Art. 35 Abs. 3 Prüfung
art35_abs3_assessment: Array<{
case_id: string // a, b, c
applies: boolean
justification: string
}>
// Ergebnis
dsfa_required: boolean
decision_justification: string
// Dokumentation der Entscheidung (gem. DSK Kurzpapier Nr. 5)
documented: boolean
documentation_reference?: string
}
// =============================================================================
// BETROFFENENPERSPEKTIVE (Art. 35 Abs. 9 DSGVO)
// =============================================================================
export interface DSFAStakeholderConsultation {
id: string
stakeholder_type: 'data_subjects' | 'representatives' | 'works_council' | 'other'
stakeholder_description: string
consultation_date?: string
consultation_method: 'survey' | 'interview' | 'workshop' | 'written' | 'other'
summary: string
concerns_raised: string[]
addressed_in_dsfa: boolean
response_documentation?: string
}
// =============================================================================
// ART. 36 KONSULTATIONSPFLICHT
// =============================================================================
export interface DSFAConsultationRequirement {
high_residual_risk: boolean
consultation_required: boolean // Art. 36 Abs. 1 DSGVO
consultation_reason?: string
authority_notified: boolean
notification_date?: string
authority_response?: string
authority_recommendations?: string[]
waiting_period_observed: boolean // 8 Wochen gem. Art. 36 Abs. 2
}
// =============================================================================
// FORTSCHREIBUNG / REVIEW (Art. 35 Abs. 11 DSGVO)
// =============================================================================
export interface DSFAReviewTrigger {
id: string
trigger_type: 'scheduled' | 'risk_change' | 'new_technology' | 'new_purpose' | 'incident' | 'regulatory' | 'other'
description: string
detected_at: string
detected_by: string
review_required: boolean
review_completed: boolean
review_date?: string
changes_made: string[]
}
export interface DSFAReviewSchedule {
next_review_date: string
review_frequency_months: number
last_review_date?: string
review_responsible: string
}
// =============================================================================
@@ -115,6 +554,11 @@ export interface DSFA {
name: string
description: string
// Section 0: Schwellwertanalyse / Vorabprüfung (NEU - Art. 35 Abs. 1)
threshold_analysis?: DSFAThresholdAnalysis
wp248_criteria_met?: string[] // IDs der erfüllten WP248-Kriterien (K1-K9)
art35_abs3_triggered?: string[] // IDs der ausgelösten Art. 35 Abs. 3 Fälle
// Section 1: Systematische Beschreibung (Art. 35 Abs. 7 lit. a)
processing_description: string
processing_purpose: string
@@ -138,9 +582,14 @@ export interface DSFA {
affected_rights?: string[]
triggered_rule_codes?: string[]
// KI-spezifische Trigger (NEU)
involves_ai?: boolean
ai_trigger_ids?: string[] // IDs der ausgelösten KI-Trigger
// Section 4: Abhilfemaßnahmen (Art. 35 Abs. 7 lit. d)
mitigations: DSFAMitigation[]
tom_references?: string[]
residual_risk_level?: DSFARiskLevel // Restrisiko nach Maßnahmen
// Section 5: Stellungnahme DSB (Art. 35 Abs. 2 + Art. 36)
dpo_consulted: boolean
@@ -153,6 +602,14 @@ export interface DSFA {
authority_reference?: string
authority_decision?: string
// Art. 36 Konsultationspflicht (NEU)
consultation_requirement?: DSFAConsultationRequirement
// Betroffenenperspektive (NEU - Art. 35 Abs. 9)
stakeholder_consultations?: DSFAStakeholderConsultation[]
stakeholder_consultation_not_appropriate?: boolean
stakeholder_consultation_not_appropriate_reason?: string
// Workflow & Approval
status: DSFAStatus
submitted_for_review_at?: string
@@ -163,6 +620,16 @@ export interface DSFA {
// Section Progress Tracking
section_progress: DSFASectionProgress
// Fortschreibung / Review (NEU - Art. 35 Abs. 11)
review_schedule?: DSFAReviewSchedule
review_triggers?: DSFAReviewTrigger[]
version: number // DSFA-Version für Fortschreibung
previous_version_id?: string
// Referenzen zu behördlichen Ressourcen
federal_state?: string // Bundesland für zuständige Aufsichtsbehörde
authority_resource_id?: string // ID aus DSFA_AUTHORITY_RESOURCES
// Metadata & Audit
metadata?: Record<string, unknown>
created_at: string
@@ -283,6 +750,15 @@ export interface DSFASectionConfig {
}
export const DSFA_SECTIONS: DSFASectionConfig[] = [
{
number: 0,
title: 'Threshold Analysis',
titleDE: 'Schwellwertanalyse',
description: 'Prüfen Sie anhand der WP248-Kriterien und Art. 35 Abs. 3, ob eine DSFA erforderlich ist. Die Entscheidung ist zu dokumentieren.',
gdprRef: 'Art. 35 Abs. 1 DSGVO, WP248 rev.01',
fields: ['threshold_analysis', 'wp248_criteria_met', 'art35_abs3_triggered'],
required: true,
},
{
number: 1,
title: 'Processing Description',
@@ -307,27 +783,45 @@ export const DSFA_SECTIONS: DSFASectionConfig[] = [
titleDE: 'Risikobewertung',
description: 'Identifizieren und bewerten Sie die Risiken für die Rechte und Freiheiten der Betroffenen.',
gdprRef: 'Art. 35 Abs. 7 lit. c DSGVO',
fields: ['risks', 'overall_risk_level', 'risk_score', 'affected_rights'],
fields: ['risks', 'overall_risk_level', 'risk_score', 'affected_rights', 'involves_ai', 'ai_trigger_ids'],
required: true,
},
{
number: 4,
title: 'Mitigation Measures',
titleDE: 'Abhilfemaßnahmen',
description: 'Definieren Sie technische und organisatorische Maßnahmen zur Risikominimierung.',
description: 'Definieren Sie technische und organisatorische Maßnahmen zur Risikominimierung und bewerten Sie das Restrisiko.',
gdprRef: 'Art. 35 Abs. 7 lit. d DSGVO',
fields: ['mitigations', 'tom_references'],
fields: ['mitigations', 'tom_references', 'residual_risk_level'],
required: true,
},
{
number: 5,
title: 'DPO Opinion',
titleDE: 'Stellungnahme DSB',
description: 'Dokumentieren Sie die Konsultation des Datenschutzbeauftragten und ggf. der Aufsichtsbehörde.',
gdprRef: 'Art. 35 Abs. 2 + Art. 36 DSGVO',
fields: ['dpo_consulted', 'dpo_opinion', 'authority_consulted', 'authority_reference'],
title: 'Stakeholder Consultation',
titleDE: 'Betroffenenperspektive',
description: 'Dokumentieren Sie, ob und wie die Standpunkte der Betroffenen eingeholt wurden (z.B. Betriebsrat, Nutzerumfragen).',
gdprRef: 'Art. 35 Abs. 9 DSGVO',
fields: ['stakeholder_consultations', 'stakeholder_consultation_not_appropriate', 'stakeholder_consultation_not_appropriate_reason'],
required: false,
},
{
number: 6,
title: 'DPO Opinion & Authority Consultation',
titleDE: 'DSB-Stellungnahme & Behördenkonsultation',
description: 'Dokumentieren Sie die Konsultation des DSB und prüfen Sie, ob bei hohem Restrisiko eine Behördenkonsultation erforderlich ist.',
gdprRef: 'Art. 35 Abs. 2, Art. 36 DSGVO',
fields: ['dpo_consulted', 'dpo_opinion', 'consultation_requirement', 'authority_consulted', 'authority_reference'],
required: true,
},
{
number: 7,
title: 'Review & Maintenance',
titleDE: 'Fortschreibung & Review',
description: 'Planen Sie regelmäßige Überprüfungen und dokumentieren Sie Änderungen, die eine Aktualisierung der DSFA erfordern.',
gdprRef: 'Art. 35 Abs. 11 DSGVO',
fields: ['review_schedule', 'review_triggers', 'version'],
required: true,
},
]
// =============================================================================
@@ -363,3 +857,159 @@ export function calculateRiskLevel(
const cell = RISK_MATRIX.find(c => c.likelihood === likelihood && c.impact === impact)
return cell ? { level: cell.level, score: cell.score } : { level: 'medium', score: 50 }
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
/**
* Prüft anhand der WP248-Kriterien, ob eine DSFA erforderlich ist.
* Regel: Bei >= 2 erfüllten Kriterien ist DSFA in den meisten Fällen erforderlich.
* @param criteriaIds Array der erfüllten Kriterien-IDs (z.B. ['K1', 'K4'])
* @returns Objekt mit Ergebnis und Begründung
*/
export function checkDSFARequiredByWP248(criteriaIds: string[]): {
required: boolean
confidence: 'definite' | 'likely' | 'possible' | 'unlikely'
reason: string
} {
const count = criteriaIds.length
if (count >= 2) {
return {
required: true,
confidence: 'definite',
reason: `${count} WP248-Kriterien erfüllt (>= 2). DSFA ist in den meisten Fällen erforderlich.`,
}
}
if (count === 1) {
return {
required: false,
confidence: 'possible',
reason: '1 WP248-Kriterium erfüllt. DSFA kann je nach Risiko dennoch erforderlich sein. Einzelfallprüfung empfohlen.',
}
}
return {
required: false,
confidence: 'unlikely',
reason: 'Keine WP248-Kriterien erfüllt. DSFA wahrscheinlich nicht erforderlich, sofern kein Art. 35 Abs. 3 Fall vorliegt.',
}
}
/**
* Prüft, ob eine Konsultation der Aufsichtsbehörde gem. Art. 36 DSGVO erforderlich ist.
* Erforderlich wenn: Hohes Restrisiko trotz geplanter Maßnahmen.
*/
export function checkArt36ConsultationRequired(
residualRiskLevel: DSFARiskLevel,
mitigationsImplemented: boolean
): DSFAConsultationRequirement {
const highResidual = residualRiskLevel === 'high' || residualRiskLevel === 'very_high'
const consultationRequired = highResidual && mitigationsImplemented
return {
high_residual_risk: highResidual,
consultation_required: consultationRequired,
consultation_reason: consultationRequired
? 'Trotz geplanter Maßnahmen verbleibt ein hohes Restrisiko. Gem. Art. 36 Abs. 1 DSGVO ist vor der Verarbeitung die Aufsichtsbehörde zu konsultieren.'
: highResidual
? 'Hohes Restrisiko festgestellt, aber Maßnahmen noch nicht vollständig umgesetzt.'
: undefined,
authority_notified: false,
waiting_period_observed: false,
}
}
/**
* Gibt die zuständige Aufsichtsbehörde für ein Bundesland zurück.
*/
export function getAuthorityResource(stateId: string): DSFAAuthorityResource | undefined {
return DSFA_AUTHORITY_RESOURCES.find(r => r.id === stateId)
}
/**
* Gibt alle Bundesländer als Auswahlliste zurück.
*/
export function getFederalStateOptions(): Array<{ value: string; label: string }> {
return DSFA_AUTHORITY_RESOURCES.map(r => ({
value: r.id,
label: r.state,
}))
}
/**
* Prüft, ob ein Review-Trigger eine Aktualisierung der DSFA erfordert.
*/
export function checkReviewRequired(triggers: DSFAReviewTrigger[]): {
required: boolean
pendingTriggers: DSFAReviewTrigger[]
} {
const pendingTriggers = triggers.filter(t => t.review_required && !t.review_completed)
return {
required: pendingTriggers.length > 0,
pendingTriggers,
}
}
/**
* Berechnet das nächste Review-Datum basierend auf dem Schedule.
*/
export function calculateNextReviewDate(schedule: DSFAReviewSchedule): Date {
const lastReview = schedule.last_review_date
? new Date(schedule.last_review_date)
: new Date()
const nextReview = new Date(lastReview)
nextReview.setMonth(nextReview.getMonth() + schedule.review_frequency_months)
return nextReview
}
/**
* Prüft, ob KI-spezifische DSFA-Trigger erfüllt sind.
*/
export function checkAIDSFATriggers(
aiTriggerIds: string[]
): { triggered: boolean; triggers: typeof AI_DSFA_TRIGGERS } {
const triggered = AI_DSFA_TRIGGERS.filter(t => aiTriggerIds.includes(t.id))
return {
triggered: triggered.length > 0,
triggers: triggered,
}
}
/**
* Generiert eine Checkliste für die Schwellwertanalyse.
*/
export function generateThresholdAnalysisChecklist(): Array<{
category: string
items: Array<{ id: string; label: string; description: string }>
}> {
return [
{
category: 'WP248 Kriterien (Art.-29-Datenschutzgruppe)',
items: WP248_CRITERIA.map(c => ({
id: c.id,
label: `${c.code}: ${c.title}`,
description: c.description,
})),
},
{
category: 'Art. 35 Abs. 3 DSGVO Regelbeispiele',
items: ART35_ABS3_CASES.map(c => ({
id: c.id,
label: `lit. ${c.lit}: ${c.title}`,
description: c.description,
})),
},
{
category: 'KI-spezifische Trigger (Deutsche DSFA-Liste)',
items: AI_DSFA_TRIGGERS.map(t => ({
id: t.id,
label: t.title,
description: t.description,
})),
},
]
}