Split the 1371-line VVT page into _components/ extractions (FormPrimitives, api, TabVerzeichnis, TabEditor, TabExport) to bring page.tsx under the 300 LOC soft target. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
231 lines
11 KiB
TypeScript
231 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
DATA_SUBJECT_CATEGORY_META,
|
|
PERSONAL_DATA_CATEGORY_META,
|
|
ART9_CATEGORIES,
|
|
STATUS_LABELS,
|
|
REVIEW_INTERVAL_LABELS,
|
|
} from '@/lib/sdk/vvt-types'
|
|
import type { VVTActivity, VVTOrganizationHeader } from '@/lib/sdk/vvt-types'
|
|
import { FormField } from './FormPrimitives'
|
|
|
|
export function TabExport({
|
|
activities, orgHeader, onUpdateOrgHeader,
|
|
}: {
|
|
activities: VVTActivity[]
|
|
orgHeader: VVTOrganizationHeader
|
|
onUpdateOrgHeader: (org: VVTOrganizationHeader) => void
|
|
}) {
|
|
// Compliance check
|
|
const issues: { activityId: string; vvtId: string; name: string; issues: string[] }[] = []
|
|
for (const a of activities) {
|
|
const actIssues: string[] = []
|
|
if (!a.name) actIssues.push('Bezeichnung fehlt')
|
|
if (a.purposes.length === 0) actIssues.push('Zweck(e) fehlen')
|
|
if (a.legalBases.length === 0) actIssues.push('Rechtsgrundlage fehlt')
|
|
if (a.dataSubjectCategories.length === 0) actIssues.push('Betroffenenkategorien fehlen')
|
|
if (a.personalDataCategories.length === 0) actIssues.push('Datenkategorien fehlen')
|
|
if (!a.retentionPeriod.description) actIssues.push('Aufbewahrungsfrist fehlt')
|
|
if (!a.tomDescription && a.structuredToms.accessControl.length === 0) actIssues.push('TOM-Beschreibung fehlt')
|
|
|
|
// Art. 9 without Art. 9 legal basis
|
|
const hasArt9Data = a.personalDataCategories.some(c => ART9_CATEGORIES.includes(c))
|
|
const hasArt9Basis = a.legalBases.some(lb => lb.type.startsWith('ART9_'))
|
|
if (hasArt9Data && !hasArt9Basis) actIssues.push('Art.-9-Daten ohne Art.-9-Rechtsgrundlage')
|
|
|
|
// Third country without mechanism
|
|
for (const tc of a.thirdCountryTransfers) {
|
|
if (!tc.transferMechanism) actIssues.push(`Drittland ${tc.country}: Transfer-Mechanismus fehlt`)
|
|
}
|
|
|
|
if (actIssues.length > 0) {
|
|
issues.push({ activityId: a.id, vvtId: a.vvtId, name: a.name || '(Ohne Namen)', issues: actIssues })
|
|
}
|
|
}
|
|
|
|
const compliantCount = activities.length - issues.length
|
|
const compliancePercent = activities.length > 0 ? Math.round((compliantCount / activities.length) * 100) : 0
|
|
|
|
const handleExportJSON = () => {
|
|
const data = {
|
|
version: '1.0',
|
|
exportDate: new Date().toISOString(),
|
|
organization: orgHeader,
|
|
activities: activities,
|
|
}
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `vvt-export-${new Date().toISOString().split('T')[0]}.json`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
const handleExportCSV = () => {
|
|
const headers = ['VVT-ID', 'Name', 'Beschreibung', 'Zwecke', 'Rechtsgrundlagen', 'Betroffene', 'Datenkategorien', 'Empfaenger', 'Drittlandtransfers', 'Aufbewahrungsfrist', 'TOM', 'Status', 'Verantwortlich']
|
|
const rows = activities.map(a => [
|
|
a.vvtId,
|
|
a.name,
|
|
a.description,
|
|
a.purposes.join('; '),
|
|
a.legalBases.map(lb => `${lb.type}${lb.reference ? ' (' + lb.reference + ')' : ''}`).join('; '),
|
|
a.dataSubjectCategories.map(c => DATA_SUBJECT_CATEGORY_META[c as keyof typeof DATA_SUBJECT_CATEGORY_META]?.de || c).join('; '),
|
|
a.personalDataCategories.map(c => PERSONAL_DATA_CATEGORY_META[c as keyof typeof PERSONAL_DATA_CATEGORY_META]?.label?.de || c).join('; '),
|
|
a.recipientCategories.map(r => `${r.name} (${r.type})`).join('; '),
|
|
a.thirdCountryTransfers.map(t => `${t.country}: ${t.recipient}`).join('; '),
|
|
a.retentionPeriod.description,
|
|
a.tomDescription,
|
|
STATUS_LABELS[a.status],
|
|
a.responsible,
|
|
])
|
|
|
|
const csvContent = [headers, ...rows].map(row =>
|
|
row.map(cell => `"${String(cell || '').replace(/"/g, '""')}"`).join(',')
|
|
).join('\n')
|
|
|
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `vvt-export-${new Date().toISOString().split('T')[0]}.csv`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Compliance Overview */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Check</h3>
|
|
<div className="flex items-center gap-6 mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-16 h-16 rounded-full flex items-center justify-center text-xl font-bold ${
|
|
compliancePercent === 100 ? 'bg-green-100 text-green-700' :
|
|
compliancePercent >= 70 ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'
|
|
}`}>
|
|
{compliancePercent}%
|
|
</div>
|
|
<div>
|
|
<div className="font-medium text-gray-900">{compliantCount} von {activities.length} vollstaendig</div>
|
|
<div className="text-sm text-gray-500">{issues.length} Eintraege mit Maengeln</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{issues.length > 0 && (
|
|
<div className="space-y-2 mt-4">
|
|
{issues.map(issue => (
|
|
<div key={issue.activityId} className="p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="font-mono text-xs text-amber-600">{issue.vvtId}</span>
|
|
<span className="text-sm font-medium text-amber-800">{issue.name}</span>
|
|
</div>
|
|
<ul className="text-sm text-amber-700 space-y-0.5">
|
|
{issue.issues.map((iss, i) => (
|
|
<li key={i} className="flex items-center gap-1">
|
|
<svg className="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01" />
|
|
</svg>
|
|
{iss}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{issues.length === 0 && activities.length > 0 && (
|
|
<div className="p-4 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">
|
|
Alle Verarbeitungen enthalten die erforderlichen Pflichtangaben nach Art. 30 DSGVO.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Organisation Header */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">VVT-Metadaten (Organisation)</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<FormField label="Organisationsname">
|
|
<input type="text" value={orgHeader.organizationName}
|
|
onChange={(e) => onUpdateOrgHeader({ ...orgHeader, organizationName: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="Firma GmbH" />
|
|
</FormField>
|
|
<FormField label="Branche">
|
|
<input type="text" value={orgHeader.industry}
|
|
onChange={(e) => onUpdateOrgHeader({ ...orgHeader, industry: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="z.B. IT & Software" />
|
|
</FormField>
|
|
<FormField label="DSB Name">
|
|
<input type="text" value={orgHeader.dpoName}
|
|
onChange={(e) => onUpdateOrgHeader({ ...orgHeader, dpoName: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="Name des Datenschutzbeauftragten" />
|
|
</FormField>
|
|
<FormField label="DSB Kontakt">
|
|
<input type="text" value={orgHeader.dpoContact}
|
|
onChange={(e) => onUpdateOrgHeader({ ...orgHeader, dpoContact: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="E-Mail oder Telefon" />
|
|
</FormField>
|
|
<FormField label="Mitarbeiterzahl">
|
|
<input type="number" value={orgHeader.employeeCount || ''}
|
|
onChange={(e) => onUpdateOrgHeader({ ...orgHeader, employeeCount: parseInt(e.target.value) || 0 })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
|
|
</FormField>
|
|
<FormField label="Pruefintervall">
|
|
<select value={orgHeader.reviewInterval}
|
|
onChange={(e) => onUpdateOrgHeader({ ...orgHeader, reviewInterval: e.target.value as VVTOrganizationHeader['reviewInterval'] })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
|
{Object.entries(REVIEW_INTERVAL_LABELS).map(([k, v]) => (
|
|
<option key={k} value={k}>{v}</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Export */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Export</h3>
|
|
<div className="flex items-center gap-3">
|
|
<button onClick={handleExportJSON} disabled={activities.length === 0}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm">
|
|
JSON exportieren
|
|
</button>
|
|
<button onClick={handleExportCSV} disabled={activities.length === 0}
|
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm">
|
|
CSV (Excel) exportieren
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-gray-400 mt-2">
|
|
Der Export enthaelt alle {activities.length} Verarbeitungstaetigkeiten inkl. Organisations-Metadaten.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Statistik</h3>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div>
|
|
<div className="text-2xl font-bold text-gray-900">{activities.length}</div>
|
|
<div className="text-sm text-gray-500">Verarbeitungen</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold text-gray-900">{[...new Set(activities.map(a => a.businessFunction))].length}</div>
|
|
<div className="text-sm text-gray-500">Geschaeftsbereiche</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold text-gray-900">{[...new Set(activities.flatMap(a => a.dataSubjectCategories))].length}</div>
|
|
<div className="text-sm text-gray-500">Betroffenenkategorien</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold text-gray-900">{[...new Set(activities.flatMap(a => a.personalDataCategories))].length}</div>
|
|
<div className="text-sm text-gray-500">Datenkategorien</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|