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>
419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
'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>
|
|
)
|
|
}
|