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:
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()
|
||||
|
||||
Reference in New Issue
Block a user