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>
This commit is contained in:
Sharang Parnerkar
2026-04-16 17:11:45 +02:00
parent 2ade65431a
commit e0c1d21879
10 changed files with 1279 additions and 4424 deletions

View File

@@ -0,0 +1,284 @@
'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>
)
}

View File

@@ -0,0 +1,336 @@
'use client'
import { useState, useEffect } from 'react'
import type { VVTOrganizationHeader } from '@/lib/sdk/vvt-types'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface VendorForProcessor {
id: string
name: string
role: string
serviceDescription: string
country: string
processingLocations: { country: string; region?: string; isEU: boolean; isAdequate: boolean }[]
transferMechanisms: string[]
certifications: { type: string; expirationDate?: string }[]
status: string
primaryContact: { name: string; email: string; phone?: string }
dpoContact?: { name: string; email: string }
contractTypes: string[]
inherentRiskScore: number
residualRiskScore: number
nextReviewDate?: string
processingActivityIds: string[]
notes?: string
createdAt: string
updatedAt: string
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const VENDOR_STATUS_LABELS: Record<string, string> = {
ACTIVE: 'Aktiv', PENDING_REVIEW: 'In Pruefung', APPROVED: 'Genehmigt',
SUSPENDED: 'Ausgesetzt', ARCHIVED: 'Archiviert', DRAFT: 'Entwurf', REVIEW: 'In Pruefung',
}
const VENDOR_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'bg-green-100 text-green-700', PENDING_REVIEW: 'bg-yellow-100 text-yellow-700',
APPROVED: 'bg-green-100 text-green-800', SUSPENDED: 'bg-red-100 text-red-700',
ARCHIVED: 'bg-gray-100 text-gray-600', DRAFT: 'bg-gray-100 text-gray-600',
REVIEW: 'bg-yellow-100 text-yellow-700',
}
const ROLE_LABELS: Record<string, string> = {
PROCESSOR: 'Auftragsverarbeiter', SUB_PROCESSOR: 'Unterauftragsverarbeiter',
}
function riskColor(score: number): string {
if (score <= 3) return 'bg-green-100 text-green-700'
if (score <= 6) return 'bg-yellow-100 text-yellow-700'
return 'bg-red-100 text-red-700'
}
// ---------------------------------------------------------------------------
// API
// ---------------------------------------------------------------------------
async function apiListProcessorVendors(): Promise<VendorForProcessor[]> {
const res = await fetch('/api/sdk/v1/vendor-compliance/vendors?limit=500')
if (!res.ok) throw new Error(`Vendor API error: ${res.status}`)
const data = await res.json()
const items: any[] = data?.data?.items ?? []
return items
.filter((v: any) => v.role === 'PROCESSOR' || v.role === 'SUB_PROCESSOR')
.map((v: any) => ({
id: v.id, name: v.name ?? '', role: v.role ?? '',
serviceDescription: v.serviceDescription ?? v.service_description ?? '',
country: v.country ?? '',
processingLocations: (v.processingLocations ?? v.processing_locations ?? []).map((l: any) => ({
country: l.country ?? '', region: l.region,
isEU: l.isEU ?? l.is_eu ?? false, isAdequate: l.isAdequate ?? l.is_adequate ?? false,
})),
transferMechanisms: v.transferMechanisms ?? v.transfer_mechanisms ?? [],
certifications: (v.certifications ?? []).map((c: any) => ({
type: c.type ?? '', expirationDate: c.expirationDate ?? c.expiration_date,
})),
status: v.status ?? 'ACTIVE',
primaryContact: {
name: v.primaryContact?.name ?? v.primary_contact?.name ?? '',
email: v.primaryContact?.email ?? v.primary_contact?.email ?? '',
phone: v.primaryContact?.phone ?? v.primary_contact?.phone,
},
dpoContact: (v.dpoContact ?? v.dpo_contact) ? {
name: (v.dpoContact ?? v.dpo_contact).name ?? '',
email: (v.dpoContact ?? v.dpo_contact).email ?? '',
} : undefined,
contractTypes: v.contractTypes ?? v.contract_types ?? [],
inherentRiskScore: v.inherentRiskScore ?? v.inherent_risk_score ?? 0,
residualRiskScore: v.residualRiskScore ?? v.residual_risk_score ?? 0,
nextReviewDate: v.nextReviewDate ?? v.next_review_date,
processingActivityIds: v.processingActivityIds ?? v.processing_activity_ids ?? [],
notes: v.notes, createdAt: v.createdAt ?? v.created_at ?? '',
updatedAt: v.updatedAt ?? v.updated_at ?? '',
}))
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function TabProcessor({ orgHeader }: { orgHeader: VVTOrganizationHeader }) {
const [vendors, setVendors] = useState<VendorForProcessor[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const loadVendors = () => {
setLoading(true)
setError(null)
apiListProcessorVendors()
.then(data => setVendors(data))
.catch(err => setError(err.message ?? 'Fehler beim Laden der Auftragsverarbeiter'))
.finally(() => setLoading(false))
}
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
apiListProcessorVendors()
.then(data => { if (!cancelled) setVendors(data) })
.catch(err => { if (!cancelled) setError(err.message ?? 'Fehler beim Laden der Auftragsverarbeiter') })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [])
const handlePrintProcessorDoc = () => {
const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const activeVendors = vendors.filter(v => v.status !== 'ARCHIVED')
const subProcessors = vendors.filter(v => v.role === 'SUB_PROCESSOR')
let html = `<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8">
<title>Verzeichnis Auftragsverarbeiter — Art. 30 Abs. 2 DSGVO</title>
<style>* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 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: 22pt; color: #5b21b6; margin-bottom: 8px; } .cover .subtitle { font-size: 13pt; color: #6b7280; margin-bottom: 40px; } .cover .org-info { font-size: 11pt; color: #374151; line-height: 2; } .record { page-break-inside: avoid; margin-bottom: 30px; border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; } .record-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #7c3aed; } .record-header .vvt-id { font-family: monospace; font-size: 10pt; color: #6b7280; background: #f3f4f6; padding: 2px 8px; border-radius: 4px; } .record-header h3 { font-size: 13pt; color: #1f2937; } 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; } .record { page-break-inside: avoid; } }</style>
</head><body>
<div class="cover">
<h1>Verzeichnis aller Verarbeitungstaetigkeiten</h1>
<div class="subtitle">als Auftragsverarbeiter gemaess Art. 30 Abs. 2 DSGVO</div>
<div class="org-info"><strong>${orgHeader.organizationName || '(Organisation eintragen)'}</strong><br/>${orgHeader.dpoName ? `Datenschutzbeauftragter: ${orgHeader.dpoName}<br/>` : ''}Stand: ${today}</div>
</div>`
for (const v of activeVendors) {
const thirdCountryLocations = v.processingLocations.filter(l => !l.isEU && !l.isAdequate)
const thirdCountryHtml = thirdCountryLocations.length > 0
? thirdCountryLocations.map(l => `${l.country}${l.region ? ` (${l.region})` : ''}`).join(', ') +
(v.transferMechanisms.length > 0 ? `<br/>Garantien: ${v.transferMechanisms.join(', ')}` : '')
: 'Keine Drittlanduebermittlung'
const subProcessorHtml = subProcessors.length > 0
? subProcessors.map(s => `${s.name}${s.serviceDescription || s.country}`).join('<br/>')
: 'Keine'
html += `<div class="record"><div class="record-header"><span class="vvt-id">${ROLE_LABELS[v.role] ?? v.role}</span><h3>${v.name}</h3></div>
<table><tr><th style="width:35%">Pflichtfeld (Art. 30 Abs. 2)</th><th>Inhalt</th></tr>
<tr><td><strong>Name/Kontaktdaten des Auftragsverarbeiters</strong></td><td>${orgHeader.organizationName}${orgHeader.dpoContact ? `<br/>Kontakt: ${orgHeader.dpoContact}` : ''}</td></tr>
<tr><td><strong>Name/Kontaktdaten des Verantwortlichen</strong></td><td>${v.name}${v.primaryContact.email ? `<br/>Kontakt: ${v.primaryContact.email}` : ''}</td></tr>
<tr><td><strong>Kategorien von Verarbeitungen</strong></td><td>${v.serviceDescription || '<em style="color:#9ca3af;">nicht angegeben</em>'}</td></tr>
<tr><td><strong>Unterauftragsverarbeiter</strong></td><td>${subProcessorHtml}</td></tr>
<tr><td><strong>Uebermittlung an Drittlaender</strong></td><td>${thirdCountryHtml}</td></tr>
<tr><td><strong>TOM (Art. 32 DSGVO)</strong></td><td>Siehe TOM-Dokumentation im Vendor-Compliance-Modul</td></tr>
</table></div>`
}
html += `<div class="page-footer"><span>Auftragsverarbeiter-Verzeichnis — ${orgHeader.organizationName}</span><span>Stand: ${today}</span></div></body></html>`
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.write(html)
printWindow.document.close()
printWindow.focus()
setTimeout(() => printWindow.print(), 300)
}
}
return (
<div className="space-y-4">
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4 flex items-start gap-3">
<svg className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" 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="flex-1">
<p className="text-sm text-purple-800">
Dieses Verzeichnis zeigt alle Auftragsverarbeiter aus dem Vendor Register.
Neue Auftragsverarbeiter hinzufuegen oder bestehende bearbeiten:
</p>
<a href="/sdk/vendor-compliance"
className="inline-flex items-center gap-1.5 mt-2 px-3 py-1.5 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 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>
Zum Vendor Register
</a>
</div>
</div>
<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">Auftragsverarbeiter-Verzeichnis (Art. 30 Abs. 2)</h3>
<p className="text-sm text-gray-500 mt-0.5">
Auftragsverarbeiter und Unterauftragsverarbeiter aus dem Vendor-Compliance-Modul (nur lesen).
</p>
</div>
{vendors.length > 0 && (
<button onClick={handlePrintProcessorDoc}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 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>
{loading && (
<div className="p-8 text-center">
<div className="w-8 h-8 mx-auto border-2 border-purple-200 border-t-purple-600 rounded-full animate-spin mb-3" />
<p className="text-sm text-gray-500">Auftragsverarbeiter werden geladen...</p>
</div>
)}
{!loading && error && (
<div className="p-6 bg-red-50 border border-red-200 rounded-lg text-center">
<p className="text-sm text-red-700 mb-2">{error}</p>
<button onClick={loadVendors} className="text-sm text-red-600 underline hover:text-red-800">
Erneut versuchen
</button>
</div>
)}
{!loading && !error && vendors.length === 0 && (
<div className="p-8 bg-gray-50 rounded-lg text-center">
<div className="w-12 h-12 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-gray-400" 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>
<h4 className="font-medium text-gray-700 mb-1">Keine Auftragsverarbeiter im Vendor Register</h4>
<p className="text-sm text-gray-500 max-w-md mx-auto">
Legen Sie Auftragsverarbeiter im Vendor Register an, damit sie hier automatisch erscheinen.
</p>
<a href="/sdk/vendor-compliance"
className="inline-flex items-center gap-1.5 mt-3 px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors">
Zum Vendor Register
</a>
</div>
)}
{!loading && !error && vendors.length > 0 && (
<div className="space-y-3">
{vendors.map(v => {
const thirdCountryLocations = v.processingLocations.filter(l => !l.isEU && !l.isAdequate)
return (
<div key={v.id} className="bg-white rounded-xl border border-gray-200 p-5 hover:border-purple-200 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h4 className="text-base font-semibold text-gray-900">{v.name}</h4>
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-700">{ROLE_LABELS[v.role] ?? v.role}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${VENDOR_STATUS_COLORS[v.status] ?? 'bg-gray-100 text-gray-600'}`}>
{VENDOR_STATUS_LABELS[v.status] ?? v.status}
</span>
</div>
{v.serviceDescription && <p className="text-sm text-gray-600 mt-1">{v.serviceDescription}</p>}
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
{v.primaryContact.name && (
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{v.primaryContact.name}
</span>
)}
{v.primaryContact.email && (
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{v.primaryContact.email}
</span>
)}
</div>
<div className="flex items-center gap-3 mt-2 flex-wrap">
<span className={`px-2 py-0.5 text-xs rounded-full ${riskColor(v.inherentRiskScore)}`}>Inherent: {v.inherentRiskScore}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${riskColor(v.residualRiskScore)}`}>Residual: {v.residualRiskScore}</span>
{v.updatedAt && <span className="text-xs text-gray-400">Aktualisiert: {new Date(v.updatedAt).toLocaleDateString('de-DE')}</span>}
</div>
{thirdCountryLocations.length > 0 && (
<div className="mt-2">
<span className="text-xs font-medium text-amber-700 bg-amber-50 px-2 py-0.5 rounded">Drittlandtransfers:</span>
<span className="text-xs text-gray-600 ml-1">
{thirdCountryLocations.map(l => `${l.country}${l.region ? ` (${l.region})` : ''}`).join(', ')}
</span>
</div>
)}
{v.certifications.length > 0 && (
<div className="flex items-center gap-2 mt-2 flex-wrap">
{v.certifications.map((c, i) => (
<span key={i} className="px-2 py-0.5 text-xs rounded-full bg-blue-50 text-blue-700 border border-blue-200">
{c.type}{c.expirationDate ? ` (bis ${new Date(c.expirationDate).toLocaleDateString('de-DE')})` : ''}
</span>
))}
</div>
)}
</div>
<div className="ml-4 flex-shrink-0">
<a href="/sdk/vendor-compliance"
className="px-3 py-1.5 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors inline-flex items-center gap-1">
<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>
Im Vendor Register oeffnen
</a>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<h4 className="text-sm font-medium text-amber-800 mb-1">Art. 30 Abs. 2 DSGVO Pflichtangaben</h4>
<ul className="text-sm text-amber-700 space-y-1 ml-4 list-disc">
<li>Name und Kontaktdaten des/der Auftragsverarbeiter(s) und jedes Verantwortlichen</li>
<li>Kategorien von Verarbeitungen, die im Auftrag jedes Verantwortlichen durchgefuehrt werden</li>
<li>Uebermittlungen an Drittlaender einschliesslich Dokumentierung geeigneter Garantien</li>
<li>Allgemeine Beschreibung der technischen und organisatorischen Massnahmen (Art. 32 Abs. 1)</li>
</ul>
</div>
</div>
)
}

View File

@@ -12,11 +12,14 @@ import {
generateActivities,
prefillFromScopeAnswers,
} from '@/lib/sdk/vvt-profiling'
import { apiListTemplates, type ProcessTemplate } from './api'
const PROTECTION_LEVEL_LABELS: Record<string, string> = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch', VERY_HIGH: 'Sehr hoch' }
export function TabVerzeichnis({
activities, allActivities, activeCount, draftCount, thirdCountryCount, art9Count,
filter, setFilter, searchQuery, setSearchQuery, sortBy, setSortBy,
scopeAnswers, onEdit, onNew, onDelete, onAdoptGenerated,
scopeAnswers, onEdit, onNew, onDelete, onAdoptGenerated, onNewFromTemplate,
}: {
activities: VVTActivity[]
allActivities: VVTActivity[]
@@ -35,9 +38,14 @@ export function TabVerzeichnis({
onNew: () => void
onDelete: (id: string) => void
onAdoptGenerated: (activities: VVTActivity[]) => void
onNewFromTemplate: (templateId: string) => void
}) {
const [scopePreview, setScopePreview] = useState<VVTActivity[] | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
const [showTemplatePicker, setShowTemplatePicker] = useState(false)
const [templates, setTemplates] = useState<ProcessTemplate[]>([])
const [templateFilter, setTemplateFilter] = useState<string>('all')
const [templatesLoading, setTemplatesLoading] = useState(false)
const handleGenerateFromScope = useCallback(() => {
if (!scopeAnswers) return
@@ -176,6 +184,26 @@ export function TabVerzeichnis({
<option value="date">Datum</option>
<option value="status">Status</option>
</select>
<button
onClick={async () => {
setShowTemplatePicker(true)
if (templates.length === 0) {
setTemplatesLoading(true)
try {
const t = await apiListTemplates()
setTemplates(t)
} finally {
setTemplatesLoading(false)
}
}
}}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-sm whitespace-nowrap"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7v8a2 2 0 002 2h6M8 7V5a2 2 0 012-2h4.586a1 1 0 01.707.293l4.414 4.414A1 1 0 0121 8.414V15a2 2 0 01-2 2h-2M8 7H6a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2v-2" />
</svg>
Aus Vorlage
</button>
<button
onClick={onNew}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm whitespace-nowrap"
@@ -207,6 +235,79 @@ export function TabVerzeichnis({
</p>
</div>
)}
{/* Template Picker Modal */}
{showTemplatePicker && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Vorlage auswaehlen</h3>
<button onClick={() => setShowTemplatePicker(false)} className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex gap-2 mt-4 flex-wrap">
{['all', 'hr', 'it', 'marketing', 'finance', 'legal', 'operations'].map(f => (
<button key={f} onClick={() => setTemplateFilter(f)}
className={`px-3 py-1 text-xs rounded-full ${templateFilter === f ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
{f === 'all' ? 'Alle' : f.toUpperCase()}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
{templatesLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
</div>
) : (
<div className="space-y-2">
{templates
.filter(t => templateFilter === 'all' || t.business_function === templateFilter)
.map(t => (
<button key={t.id} onClick={() => { onNewFromTemplate(t.id); setShowTemplatePicker(false) }}
className="w-full text-left p-4 border border-gray-200 rounded-xl hover:border-indigo-300 hover:bg-indigo-50 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-sm font-medium text-gray-900">{t.name}</span>
<span className="px-2 py-0.5 text-xs bg-indigo-100 text-indigo-700 rounded-full">{t.business_function.toUpperCase()}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${PROTECTION_LEVEL_LABELS[t.protection_level] ? 'bg-gray-100 text-gray-600' : 'bg-gray-100 text-gray-600'}`}>
{PROTECTION_LEVEL_LABELS[t.protection_level] || t.protection_level}
</span>
{t.dpia_required && (
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">DSFA</span>
)}
</div>
{t.description && <p className="text-xs text-gray-500 line-clamp-1">{t.description}</p>}
{t.tags.length > 0 && (
<div className="flex gap-1 mt-1 flex-wrap">
{t.tags.slice(0, 3).map(tag => (
<span key={tag} className="px-1.5 py-0.5 text-xs bg-gray-100 text-gray-500 rounded">{tag}</span>
))}
</div>
)}
</div>
{t.risk_score !== undefined && (
<span className={`text-xs font-medium px-2 py-1 rounded-lg whitespace-nowrap ${
t.risk_score >= 7 ? 'bg-red-100 text-red-700' : t.risk_score >= 4 ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'
}`}>Risiko {t.risk_score}</span>
)}
</div>
</button>
))}
{templates.filter(t => templateFilter === 'all' || t.business_function === templateFilter).length === 0 && (
<p className="text-center text-gray-500 py-8 text-sm">Keine Vorlagen fuer diesen Bereich gefunden.</p>
)}
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}
@@ -248,6 +349,9 @@ function ActivityCard({ activity, onEdit, onDelete }: { activity: VVTActivity; o
{activity.dpiaRequired && (
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">DSFA</span>
)}
{(activity as any).sourceTemplateId && (
<span className="px-2 py-0.5 text-xs bg-indigo-100 text-indigo-700 rounded-full">Vorlage</span>
)}
</div>
<h3 className="text-base font-semibold text-gray-900 truncate">{activity.name || '(Ohne Namen)'}</h3>
{activity.description && (
@@ -257,6 +361,12 @@ function ActivityCard({ activity, onEdit, onDelete }: { activity: VVTActivity; o
<span>{BUSINESS_FUNCTION_LABELS[activity.businessFunction]}</span>
<span>{activity.responsible || 'Kein Verantwortlicher'}</span>
<span>Aktualisiert: {new Date(activity.updatedAt).toLocaleDateString('de-DE')}</span>
{(activity as any).art30Completeness !== undefined && (
<span className={`font-medium ${
(activity as any).art30Completeness >= 80 ? 'text-green-600' :
(activity as any).art30Completeness >= 50 ? 'text-yellow-600' : 'text-red-500'
}`}>Art.30: {(activity as any).art30Completeness}%</span>
)}
</div>
</div>
<div className="flex items-center gap-1 ml-4">

View File

@@ -138,3 +138,25 @@ export async function apiUpsertOrganization(org: VVTOrganizationHeader): Promise
if (!res.ok) throw new Error(`PUT organization failed: ${res.status}`)
return orgHeaderFromApi(await res.json())
}
export interface ProcessTemplate {
id: string; name: string; description?: string; business_function: string
purpose_refs: string[]; legal_basis_refs: string[]; data_subject_refs: string[]
data_category_refs: string[]; recipient_refs: string[]; tom_refs: string[]
retention_rule_ref?: string; typical_systems: string[]
protection_level: string; dpia_required: boolean; risk_score?: number
tags: string[]; sort_order: number
}
export async function apiListTemplates(businessFunction?: string): Promise<ProcessTemplate[]> {
const params = businessFunction ? `?business_function=${businessFunction}` : ''
const res = await fetch(`${VVT_API_BASE}/templates${params}`)
if (!res.ok) return []
return res.json()
}
export async function apiInstantiateTemplate(templateId: string): Promise<VVTActivity> {
const res = await fetch(`${VVT_API_BASE}/templates/${templateId}/instantiate`, { method: 'POST' })
if (!res.ok) throw new Error(`POST instantiate failed: ${res.status}`)
return activityFromApi(await res.json())
}