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>
285 lines
18 KiB
TypeScript
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 "Verzeichnis".
|
|
</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 "Als PDF drucken" fuer das vollstaendige, formatierte Dokument mit allen
|
|
Pflichtfeldern nach Art. 30 DSGVO — inklusive Deckblatt und Inhaltsverzeichnis.
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|