Files
breakpilot-compliance/admin-compliance/app/sdk/vvt/_components/TabDokument.tsx
Sharang Parnerkar e0c1d21879 refactor(admin): split loeschfristen and vvt pages
Reduce both page.tsx files below the 500-LOC hard cap by extracting
all inline tab components and API helpers into colocated _components/.
- loeschfristen/page.tsx: 2720 → 467 LOC
- vvt/page.tsx: 2297 → 256 LOC
New files: LoeschkonzeptTab, loeschfristen/api, TabDokument, TabProcessor
Updated: TabVerzeichnis (template picker + badge), vvt/api (template helpers)
Fixed: VVTLinkSection wrong field name (linkedVVTActivityIds), VendorLinkSection added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:11:45 +02:00

285 lines
18 KiB
TypeScript

'use client'
import {
ART9_CATEGORIES, STATUS_COLORS, STATUS_LABELS,
BUSINESS_FUNCTION_LABELS, PROTECTION_LEVEL_LABELS, DEPLOYMENT_LABELS,
REVIEW_INTERVAL_LABELS,
} from '@/lib/sdk/vvt-types'
import type { VVTActivity, VVTOrganizationHeader } from '@/lib/sdk/vvt-types'
import {
DATA_SUBJECT_CATEGORY_META, PERSONAL_DATA_CATEGORY_META,
LEGAL_BASIS_META, TRANSFER_MECHANISM_META,
} from '@/lib/sdk/vvt-types'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function resolveDataSubjects(cats: string[]) {
return cats.map(c => DATA_SUBJECT_CATEGORY_META[c as keyof typeof DATA_SUBJECT_CATEGORY_META]?.de || c).join(', ')
}
function resolveDataCategories(cats: string[]) {
return cats.map(c => PERSONAL_DATA_CATEGORY_META[c as keyof typeof PERSONAL_DATA_CATEGORY_META]?.label?.de || c).join(', ')
}
function resolveLegalBasis(lb: { type: string; description?: string; reference?: string }) {
const meta = LEGAL_BASIS_META[lb.type as keyof typeof LEGAL_BASIS_META]
const label = meta ? `${meta.label.de} (${meta.article})` : lb.type
return lb.reference ? `${label}${lb.reference}` : label
}
function resolveTransferMechanism(m: string) {
const meta = TRANSFER_MECHANISM_META[m as keyof typeof TRANSFER_MECHANISM_META]
return meta?.de || m
}
function buildDocumentHtml(activities: VVTActivity[], orgHeader: VVTOrganizationHeader, today: string): string {
const approvedActivities = activities.filter(a => a.status !== 'ARCHIVED')
let html = `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Verzeichnis von Verarbeitungstaetigkeiten — ${orgHeader.organizationName || 'Organisation'}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; max-width: 900px; margin: 0 auto; padding: 40px 30px; line-height: 1.6; color: #1a202c; font-size: 11pt; }
.cover { text-align: center; padding: 80px 0 60px; page-break-after: always; }
.cover h1 { font-size: 24pt; color: #5b21b6; margin-bottom: 8px; }
.cover .subtitle { font-size: 14pt; color: #6b7280; margin-bottom: 40px; }
.cover .org-info { font-size: 11pt; color: #374151; line-height: 2; }
.cover .legal-ref { margin-top: 40px; padding: 16px; background: #f5f3ff; border-radius: 8px; font-size: 10pt; color: #5b21b6; }
.toc { page-break-after: always; }
.toc h2 { font-size: 16pt; color: #5b21b6; border-bottom: 2px solid #5b21b6; padding-bottom: 6px; margin-bottom: 16px; }
.toc-entry { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px dotted #d1d5db; font-size: 10pt; }
.toc-entry .toc-id { color: #6b7280; font-family: monospace; }
.activity { page-break-inside: avoid; margin-bottom: 30px; border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; }
.activity-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #7c3aed; }
.activity-header .vvt-id { font-family: monospace; font-size: 10pt; color: #6b7280; background: #f3f4f6; padding: 2px 8px; border-radius: 4px; }
.activity-header h3 { font-size: 14pt; color: #1f2937; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 8pt; font-weight: 600; margin-left: 6px; }
.badge-status { background: #dbeafe; color: #1e40af; }
.badge-art9 { background: #fee2e2; color: #991b1b; }
.badge-dpia { background: #f3e8ff; color: #6b21a8; }
.badge-thirdcountry { background: #ffedd5; color: #9a3412; }
.field-group { margin-bottom: 12px; }
.field-label { font-size: 9pt; font-weight: 600; color: #5b21b6; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 2px; }
.field-value { font-size: 10pt; color: #374151; }
.field-value.empty { color: #9ca3af; font-style: italic; }
table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 10pt; }
th { background: #f5f3ff; color: #5b21b6; font-weight: 600; text-align: left; padding: 8px 10px; border: 1px solid #e5e7eb; }
td { padding: 6px 10px; border: 1px solid #e5e7eb; vertical-align: top; }
.page-footer { margin-top: 40px; padding-top: 16px; border-top: 2px solid #e5e7eb; font-size: 9pt; color: #9ca3af; display: flex; justify-content: space-between; }
@media print { body { margin: 15mm; padding: 0; max-width: none; } .activity { page-break-inside: avoid; } .cover { page-break-after: always; } .toc { page-break-after: always; } h2, h3 { page-break-after: avoid; } table { page-break-inside: avoid; } .no-print { display: none; } }
</style>
</head>
<body>
<div class="cover">
<h1>Verzeichnis von Verarbeitungstaetigkeiten</h1>
<div class="subtitle">gemaess Art. 30 Abs. 1 DSGVO</div>
<div class="org-info">
<strong>${orgHeader.organizationName || '(Organisation eintragen)'}</strong><br/>
${orgHeader.industry ? `Branche: ${orgHeader.industry}<br/>` : ''}
${orgHeader.employeeCount ? `Mitarbeiter: ${orgHeader.employeeCount}<br/>` : ''}
${orgHeader.locations && orgHeader.locations.length > 0 ? `Standorte: ${orgHeader.locations.join(', ')}<br/>` : ''}
${orgHeader.dpoName ? `<br/>Datenschutzbeauftragter: ${orgHeader.dpoName}<br/>` : ''}
${orgHeader.dpoContact ? `Kontakt DSB: ${orgHeader.dpoContact}<br/>` : ''}
</div>
<div class="legal-ref">
VVT-Version: ${orgHeader.vvtVersion} | Stand: ${today}
${orgHeader.lastReviewDate ? ` | Letzte Pruefung: ${new Date(orgHeader.lastReviewDate).toLocaleDateString('de-DE')}` : ''}
${orgHeader.nextReviewDate ? ` | Naechste Pruefung: ${new Date(orgHeader.nextReviewDate).toLocaleDateString('de-DE')}` : ''}
| Pruefintervall: ${REVIEW_INTERVAL_LABELS[orgHeader.reviewInterval] || orgHeader.reviewInterval}
</div>
</div>
<div class="toc">
<h2>Inhaltsverzeichnis</h2>
<p style="margin-bottom:12px;font-size:10pt;color:#6b7280;">
${approvedActivities.length} Verarbeitungstaetigkeiten in ${[...new Set(approvedActivities.map(a => a.businessFunction))].length} Geschaeftsbereichen
</p>
${approvedActivities.map((a) => `
<div class="toc-entry">
<span><span class="toc-id">${a.vvtId}</span> ${a.name || '(Ohne Namen)'}</span>
<span style="color:#6b7280;">${BUSINESS_FUNCTION_LABELS[a.businessFunction]}${STATUS_LABELS[a.status]}</span>
</div>
`).join('')}
</div>
`
for (const a of approvedActivities) {
const hasArt9 = a.personalDataCategories.some(c => ART9_CATEGORIES.includes(c))
const hasThirdCountry = a.thirdCountryTransfers.length > 0
html += `
<div class="activity">
<div class="activity-header">
<span class="vvt-id">${a.vvtId}</span>
<h3>${a.name || '(Ohne Namen)'}</h3>
<span class="badge badge-status">${STATUS_LABELS[a.status]}</span>
${hasArt9 ? '<span class="badge badge-art9">Art. 9</span>' : ''}
${a.dpiaRequired ? '<span class="badge badge-dpia">DSFA</span>' : ''}
${hasThirdCountry ? '<span class="badge badge-thirdcountry">Drittland</span>' : ''}
</div>
${a.description ? `<div class="field-group"><div class="field-label">Beschreibung</div><div class="field-value">${a.description}</div></div>` : ''}
<table>
<tr><th style="width:35%">Pflichtfeld (Art. 30)</th><th>Inhalt</th></tr>
<tr><td><strong>Verantwortlicher</strong></td><td>${a.responsible || `<span class="field-value empty">nicht angegeben</span>`}</td></tr>
<tr><td><strong>Geschaeftsbereich</strong></td><td>${BUSINESS_FUNCTION_LABELS[a.businessFunction]}</td></tr>
<tr><td><strong>Zwecke der Verarbeitung</strong></td><td>${a.purposes.length > 0 ? a.purposes.join('; ') : `<span class="field-value empty">nicht angegeben</span>`}</td></tr>
<tr><td><strong>Rechtsgrundlage(n)</strong></td><td>${a.legalBases.length > 0 ? a.legalBases.map(resolveLegalBasis).join('<br/>') : `<span class="field-value empty">nicht angegeben</span>`}</td></tr>
<tr><td><strong>Kategorien betroffener Personen</strong></td><td>${a.dataSubjectCategories.length > 0 ? resolveDataSubjects(a.dataSubjectCategories) : `<span class="field-value empty">nicht angegeben</span>`}</td></tr>
<tr><td><strong>Kategorien personenbezogener Daten</strong></td><td>${a.personalDataCategories.length > 0 ? resolveDataCategories(a.personalDataCategories) : `<span class="field-value empty">nicht angegeben</span>`}${hasArt9 ? '<br/><em style="color:#991b1b;">Enthalt besondere Kategorien nach Art. 9 DSGVO</em>' : ''}</td></tr>
<tr><td><strong>Empfaengerkategorien</strong></td><td>${a.recipientCategories.length > 0 ? a.recipientCategories.map(r => `${r.name} (${r.type})`).join('; ') : `<span class="field-value empty">keine</span>`}</td></tr>
<tr><td><strong>Uebermittlung an Drittlaender</strong></td><td>${hasThirdCountry ? a.thirdCountryTransfers.map(t => `${t.country}: ${t.recipient}${resolveTransferMechanism(t.transferMechanism)}`).join('<br/>') : 'Keine Drittlanduebermittlung'}</td></tr>
<tr><td><strong>Loeschfristen</strong></td><td>${a.retentionPeriod.description || `<span class="field-value empty">nicht angegeben</span>`}${a.retentionPeriod.legalBasis ? `<br/><em>Rechtsgrundlage: ${a.retentionPeriod.legalBasis}</em>` : ''}${a.retentionPeriod.deletionProcedure ? `<br/><em>Verfahren: ${a.retentionPeriod.deletionProcedure}</em>` : ''}</td></tr>
<tr><td><strong>TOM (Art. 32 DSGVO)</strong></td><td>${a.tomDescription || `<span class="field-value empty">nicht beschrieben</span>`}</td></tr>
</table>
${a.structuredToms && (a.structuredToms.accessControl.length > 0 || a.structuredToms.confidentiality.length > 0 || a.structuredToms.integrity.length > 0 || a.structuredToms.availability.length > 0 || a.structuredToms.separation.length > 0) ? `
<div class="field-group" style="margin-top:10px;">
<div class="field-label">Strukturierte TOMs</div>
<table>
<tr><th>Kategorie</th><th>Massnahmen</th></tr>
${a.structuredToms.accessControl.length > 0 ? `<tr><td>Zugriffskontrolle</td><td>${a.structuredToms.accessControl.join(', ')}</td></tr>` : ''}
${a.structuredToms.confidentiality.length > 0 ? `<tr><td>Vertraulichkeit</td><td>${a.structuredToms.confidentiality.join(', ')}</td></tr>` : ''}
${a.structuredToms.integrity.length > 0 ? `<tr><td>Integritaet</td><td>${a.structuredToms.integrity.join(', ')}</td></tr>` : ''}
${a.structuredToms.availability.length > 0 ? `<tr><td>Verfuegbarkeit</td><td>${a.structuredToms.availability.join(', ')}</td></tr>` : ''}
${a.structuredToms.separation.length > 0 ? `<tr><td>Trennbarkeit</td><td>${a.structuredToms.separation.join(', ')}</td></tr>` : ''}
</table>
</div>
` : ''}
<div style="margin-top:8px;font-size:9pt;color:#9ca3af;">
Erstellt: ${new Date(a.createdAt).toLocaleDateString('de-DE')} | Aktualisiert: ${new Date(a.updatedAt).toLocaleDateString('de-DE')}
${a.dpiaRequired ? ' | DSFA erforderlich' : ''}
| Schutzniveau: ${PROTECTION_LEVEL_LABELS[a.protectionLevel]}
| Deployment: ${DEPLOYMENT_LABELS[a.deploymentModel]}
</div>
</div>
`
}
html += `
<div class="page-footer">
<span>Verzeichnis von Verarbeitungstaetigkeiten — ${orgHeader.organizationName}</span>
<span>Stand: ${today} | Version ${orgHeader.vvtVersion}</span>
</div>
</body>
</html>`
return html
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function TabDokument({ activities, orgHeader }: { activities: VVTActivity[]; orgHeader: VVTOrganizationHeader }) {
const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const nonArchivedActivities = activities.filter(a => a.status !== 'ARCHIVED')
const handlePrintDocument = () => {
const htmlContent = buildDocumentHtml(activities, orgHeader, today)
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.write(htmlContent)
printWindow.document.close()
printWindow.focus()
setTimeout(() => printWindow.print(), 300)
}
}
const handleDownloadHtml = () => {
const htmlContent = buildDocumentHtml(activities, orgHeader, today)
const blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `vvt-dokument-${new Date().toISOString().split('T')[0]}.html`
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="space-y-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">VVT-Dokument (Art. 30 DSGVO)</h3>
<p className="text-sm text-gray-500 mt-0.5">
Druckfertiges Verarbeitungsverzeichnis mit Deckblatt, Inhaltsverzeichnis und allen {nonArchivedActivities.length} Verarbeitungstaetigkeiten.
</p>
</div>
<div className="flex items-center gap-2">
<button onClick={handleDownloadHtml} disabled={nonArchivedActivities.length === 0}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed text-sm">
<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>
HTML herunterladen
</button>
<button onClick={handlePrintDocument} disabled={nonArchivedActivities.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
Als PDF drucken
</button>
</div>
</div>
{nonArchivedActivities.length === 0 && (
<div className="p-6 bg-gray-50 rounded-lg text-center text-gray-500">
Keine Verarbeitungstaetigkeiten vorhanden. Erstellen Sie zuerst Eintraege im Tab &quot;Verzeichnis&quot;.
</div>
)}
</div>
{nonArchivedActivities.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-sm font-medium text-gray-700 mb-4">Vorschau Inhalt des Dokuments</h4>
<div className="border border-purple-200 rounded-lg p-6 mb-4 text-center bg-purple-50/30">
<div className="text-xs text-purple-500 uppercase tracking-widest mb-2">Deckblatt</div>
<div className="text-xl font-bold text-purple-800 mb-1">Verzeichnis von Verarbeitungstaetigkeiten</div>
<div className="text-sm text-gray-500 mb-3">gemaess Art. 30 Abs. 1 DSGVO</div>
<div className="text-sm text-gray-700">
<strong>{orgHeader.organizationName || '(Organisation eintragen)'}</strong>
{orgHeader.dpoName && <><br />DSB: {orgHeader.dpoName}</>}
{orgHeader.dpoContact && <> ({orgHeader.dpoContact})</>}
</div>
<div className="text-xs text-gray-400 mt-3">
Version {orgHeader.vvtVersion} | Stand: {today}
</div>
</div>
<div className="space-y-3">
{nonArchivedActivities.map((a) => {
const hasArt9 = a.personalDataCategories.some(c => ART9_CATEGORIES.includes(c))
return (
<div key={a.id} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<span className="font-mono text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">{a.vvtId}</span>
<span className="text-sm font-semibold text-gray-900">{a.name || '(Ohne Namen)'}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[a.status]}`}>{STATUS_LABELS[a.status]}</span>
{hasArt9 && <span className="px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded-full">Art. 9</span>}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-1 text-xs text-gray-600">
<div><span className="font-medium text-gray-500">Zweck:</span> {a.purposes.join(', ') || '—'}</div>
<div><span className="font-medium text-gray-500">Rechtsgrundlage:</span> {a.legalBases.map(lb => lb.type).join(', ') || '—'}</div>
<div><span className="font-medium text-gray-500">Betroffene:</span> {a.dataSubjectCategories.length || 0} Kategorien</div>
<div><span className="font-medium text-gray-500">Datenkategorien:</span> {a.personalDataCategories.length || 0}</div>
<div><span className="font-medium text-gray-500">Empfaenger:</span> {a.recipientCategories.length || 0}</div>
<div><span className="font-medium text-gray-500">Loeschfrist:</span> {a.retentionPeriod.description || '—'}</div>
</div>
</div>
)
})}
</div>
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">
<strong>Tipp:</strong> Klicken Sie auf &quot;Als PDF drucken&quot; fuer das vollstaendige, formatierte Dokument mit allen
Pflichtfeldern nach Art. 30 DSGVO inklusive Deckblatt und Inhaltsverzeichnis.
</div>
</div>
)}
</div>
)
}