Initial commit: breakpilot-compliance - Compliance SDK Platform
Services: Admin-Compliance, Backend-Compliance, AI-Compliance-SDK, Consent-SDK, Developer-Portal, PCA-Platform, DSMS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
372
admin-compliance/components/sdk/dsfa/Art36Warning.tsx
Normal file
372
admin-compliance/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-compliance/components/sdk/dsfa/DSFASidebar.tsx
Normal file
268
admin-compliance/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-compliance/components/sdk/dsfa/ReviewScheduleSection.tsx
Normal file
323
admin-compliance/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>
|
||||
)
|
||||
}
|
||||
320
admin-compliance/components/sdk/dsfa/SourceAttribution.tsx
Normal file
320
admin-compliance/components/sdk/dsfa/SourceAttribution.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { BookOpen, ExternalLink, Scale, ChevronDown, ChevronUp, Info } from 'lucide-react'
|
||||
import {
|
||||
DSFALicenseCode,
|
||||
DSFA_LICENSE_LABELS,
|
||||
SourceAttributionProps
|
||||
} from '@/lib/sdk/types'
|
||||
|
||||
/**
|
||||
* Get license badge color based on license type
|
||||
*/
|
||||
function getLicenseBadgeColor(licenseCode: DSFALicenseCode): string {
|
||||
switch (licenseCode) {
|
||||
case 'DL-DE-BY-2.0':
|
||||
case 'DL-DE-ZERO-2.0':
|
||||
return 'bg-blue-100 text-blue-700 border-blue-200'
|
||||
case 'CC-BY-4.0':
|
||||
return 'bg-green-100 text-green-700 border-green-200'
|
||||
case 'EDPB-LICENSE':
|
||||
return 'bg-purple-100 text-purple-700 border-purple-200'
|
||||
case 'PUBLIC_DOMAIN':
|
||||
return 'bg-gray-100 text-gray-700 border-gray-200'
|
||||
case 'PROPRIETARY':
|
||||
return 'bg-amber-100 text-amber-700 border-amber-200'
|
||||
default:
|
||||
return 'bg-slate-100 text-slate-700 border-slate-200'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get license URL based on license code
|
||||
*/
|
||||
function getLicenseUrl(licenseCode: DSFALicenseCode): string | null {
|
||||
switch (licenseCode) {
|
||||
case 'DL-DE-BY-2.0':
|
||||
return 'https://www.govdata.de/dl-de/by-2-0'
|
||||
case 'DL-DE-ZERO-2.0':
|
||||
return 'https://www.govdata.de/dl-de/zero-2-0'
|
||||
case 'CC-BY-4.0':
|
||||
return 'https://creativecommons.org/licenses/by/4.0/'
|
||||
case 'EDPB-LICENSE':
|
||||
return 'https://edpb.europa.eu/about-edpb/legal-notice_en'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* License badge component
|
||||
*/
|
||||
function LicenseBadge({ licenseCode }: { licenseCode: DSFALicenseCode }) {
|
||||
const licenseUrl = getLicenseUrl(licenseCode)
|
||||
const colorClass = getLicenseBadgeColor(licenseCode)
|
||||
const label = DSFA_LICENSE_LABELS[licenseCode] || licenseCode
|
||||
|
||||
if (licenseUrl) {
|
||||
return (
|
||||
<a
|
||||
href={licenseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorClass} hover:opacity-80 transition-opacity`}
|
||||
>
|
||||
<Scale className="w-3 h-3" />
|
||||
{label}
|
||||
<ExternalLink className="w-2.5 h-2.5" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorClass}`}>
|
||||
<Scale className="w-3 h-3" />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source item in the attribution list
|
||||
*/
|
||||
function SourceItem({
|
||||
source,
|
||||
index,
|
||||
showScore
|
||||
}: {
|
||||
source: SourceAttributionProps['sources'][0]
|
||||
index: number
|
||||
showScore: boolean
|
||||
}) {
|
||||
return (
|
||||
<li className="text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-slate-400 font-mono text-xs mt-0.5 min-w-[1.5rem]">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{source.sourceUrl ? (
|
||||
<a
|
||||
href={source.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline font-medium truncate"
|
||||
>
|
||||
{source.sourceName}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-slate-700 font-medium truncate">
|
||||
{source.sourceName}
|
||||
</span>
|
||||
)}
|
||||
{showScore && source.score !== undefined && (
|
||||
<span className="text-xs text-slate-400 font-mono">
|
||||
({(source.score * 100).toFixed(0)}%)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-0.5 leading-relaxed">
|
||||
{source.attributionText}
|
||||
</p>
|
||||
<div className="mt-1.5">
|
||||
<LicenseBadge licenseCode={source.licenseCode} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact source badge for inline display
|
||||
*/
|
||||
function CompactSourceBadge({
|
||||
source
|
||||
}: {
|
||||
source: SourceAttributionProps['sources'][0]
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-slate-100 text-slate-600 border border-slate-200">
|
||||
<BookOpen className="w-3 h-3" />
|
||||
{source.sourceCode}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SourceAttribution component - displays source/license information for DSFA RAG results
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SourceAttribution
|
||||
* sources={[
|
||||
* {
|
||||
* sourceCode: "WP248",
|
||||
* sourceName: "WP248 rev.01 - Leitlinien zur DSFA",
|
||||
* attributionText: "Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)",
|
||||
* licenseCode: "EDPB-LICENSE",
|
||||
* sourceUrl: "https://ec.europa.eu/newsroom/article29/items/611236/en",
|
||||
* score: 0.87
|
||||
* }
|
||||
* ]}
|
||||
* showScores
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function SourceAttribution({
|
||||
sources,
|
||||
compact = false,
|
||||
showScores = false
|
||||
}: SourceAttributionProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(!compact)
|
||||
|
||||
if (!sources || sources.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Compact mode - just show badges
|
||||
if (compact && !isExpanded) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
<Info className="w-3 h-3" />
|
||||
Quellen ({sources.length})
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
{sources.slice(0, 3).map((source, i) => (
|
||||
<CompactSourceBadge key={i} source={source} />
|
||||
))}
|
||||
{sources.length > 3 && (
|
||||
<span className="text-xs text-slate-400">+{sources.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-slate-700 flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
Quellen & Lizenzen
|
||||
</h4>
|
||||
{compact && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className="text-xs text-slate-400 hover:text-slate-600 flex items-center gap-1"
|
||||
>
|
||||
Einklappen
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="mt-3 space-y-3">
|
||||
{sources.map((source, i) => (
|
||||
<SourceItem
|
||||
key={i}
|
||||
source={source}
|
||||
index={i}
|
||||
showScore={showScores}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Aggregated license notice */}
|
||||
{sources.length > 1 && (
|
||||
<div className="mt-4 pt-3 border-t border-slate-200">
|
||||
<p className="text-xs text-slate-500">
|
||||
<strong>Hinweis:</strong> Die angezeigten Inhalte stammen aus {sources.length} verschiedenen Quellen
|
||||
mit unterschiedlichen Lizenzen. Bitte beachten Sie die jeweiligen Attributionsanforderungen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline source reference for use within text
|
||||
*/
|
||||
export function InlineSourceRef({
|
||||
sourceCode,
|
||||
sourceName,
|
||||
sourceUrl
|
||||
}: {
|
||||
sourceCode: string
|
||||
sourceName: string
|
||||
sourceUrl?: string
|
||||
}) {
|
||||
if (sourceUrl) {
|
||||
return (
|
||||
<a
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 text-blue-600 hover:text-blue-800 text-sm"
|
||||
title={sourceName}
|
||||
>
|
||||
[{sourceCode}]
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-slate-600 text-sm" title={sourceName}>
|
||||
[{sourceCode}]
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribution footer for generated documents
|
||||
*/
|
||||
export function AttributionFooter({
|
||||
sources,
|
||||
generatedAt
|
||||
}: {
|
||||
sources: SourceAttributionProps['sources']
|
||||
generatedAt?: Date
|
||||
}) {
|
||||
if (!sources || sources.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Group by license
|
||||
const byLicense = sources.reduce((acc, source) => {
|
||||
const key = source.licenseCode
|
||||
if (!acc[key]) acc[key] = []
|
||||
acc[key].push(source)
|
||||
return acc
|
||||
}, {} as Record<string, typeof sources>)
|
||||
|
||||
return (
|
||||
<footer className="mt-8 pt-4 border-t border-slate-200 text-xs text-slate-500">
|
||||
<h5 className="font-medium text-slate-600 mb-2">Quellennachweis</h5>
|
||||
<ul className="space-y-1">
|
||||
{Object.entries(byLicense).map(([licenseCode, licenseSources]) => (
|
||||
<li key={licenseCode}>
|
||||
<span className="font-medium">{DSFA_LICENSE_LABELS[licenseCode as DSFALicenseCode]}:</span>{' '}
|
||||
{licenseSources.map(s => s.sourceName).join(', ')}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{generatedAt && (
|
||||
<p className="mt-2 text-slate-400">
|
||||
Generiert am {generatedAt.toLocaleDateString('de-DE')} um {generatedAt.toLocaleTimeString('de-DE')}
|
||||
</p>
|
||||
)}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export default SourceAttribution
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
'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>
|
||||
|
||||
{/* Annex-Trigger: Empfehlung bei >= 2 WP248 Kriterien */}
|
||||
{wp248Selected.length >= 2 && (
|
||||
<div className="mt-4 p-4 rounded-xl border bg-indigo-50 border-indigo-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-4 h-4 text-indigo-600" 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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-indigo-800 text-sm">Annex mit separater Risikobewertung empfohlen</p>
|
||||
<p className="text-sm text-indigo-700 mt-1">
|
||||
Bei {wp248Selected.length} erfuellten WP248-Kriterien wird ein Annex mit detaillierter Risikobewertung empfohlen.
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<p className="text-xs font-medium text-indigo-700 mb-1">Vorgeschlagene Annex-Scopes basierend auf Ihren Kriterien:</p>
|
||||
<ul className="text-xs text-indigo-600 space-y-1">
|
||||
{wp248Selected.includes('scoring_profiling') && (
|
||||
<li>- Annex: Profiling & Scoring — Detailanalyse der Bewertungslogik</li>
|
||||
)}
|
||||
{wp248Selected.includes('automated_decision') && (
|
||||
<li>- Annex: Automatisierte Einzelentscheidung — Art. 22 Pruefung</li>
|
||||
)}
|
||||
{wp248Selected.includes('systematic_monitoring') && (
|
||||
<li>- Annex: Systematische Ueberwachung — Verhaeltnismaessigkeitspruefung</li>
|
||||
)}
|
||||
{wp248Selected.includes('sensitive_data') && (
|
||||
<li>- Annex: Besondere Datenkategorien — Schutzbedarfsanalyse Art. 9</li>
|
||||
)}
|
||||
{wp248Selected.includes('large_scale') && (
|
||||
<li>- Annex: Umfangsanalyse — Quantitative Bewertung der Verarbeitung</li>
|
||||
)}
|
||||
{wp248Selected.includes('matching_combining') && (
|
||||
<li>- Annex: Datenzusammenfuehrung — Zweckbindungspruefung</li>
|
||||
)}
|
||||
{wp248Selected.includes('vulnerable_subjects') && (
|
||||
<li>- Annex: Schutzbeduerftige Betroffene — Verstaerkte Schutzmassnahmen</li>
|
||||
)}
|
||||
{wp248Selected.includes('innovative_technology') && (
|
||||
<li>- Annex: Innovative Technologie — Technikfolgenabschaetzung</li>
|
||||
)}
|
||||
{wp248Selected.includes('preventing_rights') && (
|
||||
<li>- Annex: Rechteausuebung — Barrierefreiheit der Betroffenenrechte</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{aiTriggersSelected.length > 0 && (
|
||||
<p className="text-xs text-indigo-500 mt-2">
|
||||
+ KI-Trigger aktiv: Zusaetzlicher Annex fuer KI-Risikobewertung empfohlen (AI Act Konformitaet).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
222
admin-compliance/components/sdk/dsfa/index.ts
Normal file
222
admin-compliance/components/sdk/dsfa/index.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
'use client'
|
||||
|
||||
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'
|
||||
export { SourceAttribution, InlineSourceRef, AttributionFooter } from './SourceAttribution'
|
||||
|
||||
// =============================================================================
|
||||
// DSFA Card Component
|
||||
// =============================================================================
|
||||
|
||||
interface DSFACardProps {
|
||||
dsfa: {
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
risk_level?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
processing_description?: string
|
||||
}
|
||||
onDelete?: (id: string) => void
|
||||
onExport?: (id: string) => void
|
||||
}
|
||||
|
||||
export function DSFACard({ dsfa, onDelete, onExport }: DSFACardProps) {
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: 'bg-slate-100 text-slate-700',
|
||||
in_progress: 'bg-blue-100 text-blue-700',
|
||||
review: 'bg-amber-100 text-amber-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
return React.createElement('div', {
|
||||
className: 'bg-white rounded-xl border border-slate-200 p-5 hover:shadow-md transition-shadow'
|
||||
},
|
||||
React.createElement('div', { className: 'flex items-start justify-between mb-3' },
|
||||
React.createElement('h3', { className: 'font-semibold text-slate-900 text-lg' }, dsfa.title),
|
||||
React.createElement('span', {
|
||||
className: `px-2.5 py-1 rounded-full text-xs font-medium ${statusColors[dsfa.status] || statusColors.draft}`
|
||||
}, dsfa.status)
|
||||
),
|
||||
dsfa.processing_description && React.createElement('p', {
|
||||
className: 'text-sm text-slate-500 mb-4 line-clamp-2'
|
||||
}, dsfa.processing_description),
|
||||
React.createElement('div', { className: 'flex items-center gap-2' },
|
||||
React.createElement('a', {
|
||||
href: `/sdk/dsfa/${dsfa.id}`,
|
||||
className: 'px-3 py-1.5 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700 transition-colors'
|
||||
}, 'Bearbeiten'),
|
||||
onExport && React.createElement('button', {
|
||||
onClick: () => onExport(dsfa.id),
|
||||
className: 'px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-sm hover:bg-slate-200 transition-colors'
|
||||
}, 'Export'),
|
||||
onDelete && React.createElement('button', {
|
||||
onClick: () => onDelete(dsfa.id),
|
||||
className: 'px-3 py-1.5 text-red-600 rounded-lg text-sm hover:bg-red-50 transition-colors'
|
||||
}, 'Loeschen')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Risk Matrix Component
|
||||
// =============================================================================
|
||||
|
||||
// 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[]
|
||||
}
|
||||
|
||||
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',
|
||||
very_high: 'bg-red-100 hover:bg-red-200',
|
||||
}
|
||||
|
||||
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: 'text-xs text-slate-500 mb-2' }, 'Eintrittswahrscheinlichkeit ↑ | Schwere →'),
|
||||
React.createElement('div', { className: 'grid grid-cols-4 gap-1' },
|
||||
// Header row
|
||||
React.createElement('div'),
|
||||
...impactLevels.map(i => React.createElement('div', {
|
||||
key: `h-${i}`,
|
||||
className: 'text-center text-xs text-slate-500 py-1'
|
||||
}, levelLabels[i])),
|
||||
// Grid rows
|
||||
...likelihoodLevels.map(likelihood =>
|
||||
[
|
||||
React.createElement('div', {
|
||||
key: `l-${likelihood}`,
|
||||
className: 'text-right text-xs text-slate-500 pr-2 flex items-center justify-end'
|
||||
}, 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: `${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()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Approval Panel Component
|
||||
// =============================================================================
|
||||
|
||||
interface ApprovalPanelProps {
|
||||
dsfa: {
|
||||
id: string
|
||||
status: string
|
||||
approved_by?: string
|
||||
approved_at?: string
|
||||
rejection_reason?: string
|
||||
}
|
||||
onApprove?: () => void
|
||||
onReject?: (reason: string) => void
|
||||
}
|
||||
|
||||
export function ApprovalPanel({ dsfa, onApprove, onReject }: ApprovalPanelProps) {
|
||||
const [rejectionReason, setRejectionReason] = React.useState('')
|
||||
const [showRejectForm, setShowRejectForm] = React.useState(false)
|
||||
|
||||
if (dsfa.status === 'approved') {
|
||||
return React.createElement('div', {
|
||||
className: 'bg-green-50 border border-green-200 rounded-xl p-5'
|
||||
},
|
||||
React.createElement('div', { className: 'flex items-center gap-2 mb-2' },
|
||||
React.createElement('span', { className: 'text-green-600 text-lg' }, '\u2713'),
|
||||
React.createElement('h3', { className: 'font-semibold text-green-800' }, 'DSFA genehmigt')
|
||||
),
|
||||
dsfa.approved_by && React.createElement('p', { className: 'text-sm text-green-700' },
|
||||
`Genehmigt von: ${dsfa.approved_by}`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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' }, 'Freigabe'),
|
||||
React.createElement('div', { className: 'flex gap-3' },
|
||||
onApprove && React.createElement('button', {
|
||||
onClick: onApprove,
|
||||
className: 'px-4 py-2 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 transition-colors'
|
||||
}, 'Genehmigen'),
|
||||
onReject && React.createElement('button', {
|
||||
onClick: () => setShowRejectForm(true),
|
||||
className: 'px-4 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700 transition-colors'
|
||||
}, 'Ablehnen')
|
||||
),
|
||||
showRejectForm && React.createElement('div', { className: 'mt-4' },
|
||||
React.createElement('textarea', {
|
||||
value: rejectionReason,
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => setRejectionReason(e.target.value),
|
||||
placeholder: 'Ablehnungsgrund...',
|
||||
className: 'w-full p-3 border border-slate-200 rounded-lg text-sm resize-none',
|
||||
rows: 3
|
||||
}),
|
||||
React.createElement('button', {
|
||||
onClick: () => { onReject?.(rejectionReason); setShowRejectForm(false) },
|
||||
className: 'mt-2 px-4 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700'
|
||||
}, 'Ablehnung senden')
|
||||
)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user