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