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:
@@ -370,18 +370,18 @@ export function VVTLinkSection({
|
||||
Verknuepfen Sie diese Loeschfrist mit einer Verarbeitungstaetigkeit aus Ihrem VVT.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{policy.linkedVvtIds && policy.linkedVvtIds.length > 0 && (
|
||||
{policy.linkedVVTActivityIds && policy.linkedVVTActivityIds.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Verknuepfte Taetigkeiten:</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{policy.linkedVvtIds.map((vvtId: string) => {
|
||||
{policy.linkedVVTActivityIds.map((vvtId: string) => {
|
||||
const activity = vvtActivities.find((a: any) => a.id === vvtId)
|
||||
return (
|
||||
<span key={vvtId} className="inline-flex items-center gap-1 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded-full">
|
||||
{activity?.name || vvtId}
|
||||
<button type="button"
|
||||
onClick={() => updatePolicy(pid, (p) => ({
|
||||
...p, linkedVvtIds: (p.linkedVvtIds || []).filter((id: string) => id !== vvtId),
|
||||
...p, linkedVVTActivityIds: (p.linkedVVTActivityIds || []).filter((id: string) => id !== vvtId),
|
||||
}))}
|
||||
className="text-blue-600 hover:text-blue-900">x</button>
|
||||
</span>
|
||||
@@ -393,15 +393,15 @@ export function VVTLinkSection({
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
if (val && !(policy.linkedVvtIds || []).includes(val)) {
|
||||
updatePolicy(pid, (p) => ({ ...p, linkedVvtIds: [...(p.linkedVvtIds || []), val] }))
|
||||
if (val && !(policy.linkedVVTActivityIds || []).includes(val)) {
|
||||
updatePolicy(pid, (p) => ({ ...p, linkedVVTActivityIds: [...(p.linkedVVTActivityIds || []), val] }))
|
||||
}
|
||||
e.target.value = ''
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
|
||||
<option value="">Verarbeitungstaetigkeit verknuepfen...</option>
|
||||
{vvtActivities
|
||||
.filter((a: any) => !(policy.linkedVvtIds || []).includes(a.id))
|
||||
.filter((a: any) => !(policy.linkedVVTActivityIds || []).includes(a.id))
|
||||
.map((a: any) => (<option key={a.id} value={a.id}>{a.name || a.id}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -415,6 +415,70 @@ export function VVTLinkSection({
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sektion 5b: Auftragsverarbeiter-Verknuepfung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function VendorLinkSection({
|
||||
policy, pid, vendorList, updatePolicy,
|
||||
}: {
|
||||
policy: LoeschfristPolicy; pid: string; vendorList: Array<{id: string; name: string}>
|
||||
updatePolicy: (id: string, updater: (p: LoeschfristPolicy) => LoeschfristPolicy) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">5b. Verknuepfte Auftragsverarbeiter</h3>
|
||||
{vendorList.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Verknuepfen Sie diese Loeschfrist mit relevanten Auftragsverarbeitern.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{policy.linkedVendorIds && policy.linkedVendorIds.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Verknuepfte Auftragsverarbeiter:</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{policy.linkedVendorIds.map((vendorId: string) => {
|
||||
const vendor = vendorList.find((v) => v.id === vendorId)
|
||||
return (
|
||||
<span key={vendorId} className="inline-flex items-center gap-1 bg-orange-100 text-orange-800 text-xs font-medium px-2 py-0.5 rounded-full">
|
||||
{vendor?.name || vendorId}
|
||||
<button type="button"
|
||||
onClick={() => updatePolicy(pid, (p) => ({
|
||||
...p, linkedVendorIds: (p.linkedVendorIds || []).filter((id: string) => id !== vendorId),
|
||||
}))}
|
||||
className="text-orange-600 hover:text-orange-900">x</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
if (val && !(policy.linkedVendorIds || []).includes(val)) {
|
||||
updatePolicy(pid, (p) => ({ ...p, linkedVendorIds: [...(p.linkedVendorIds || []), val] }))
|
||||
}
|
||||
e.target.value = ''
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
|
||||
<option value="">Auftragsverarbeiter verknuepfen...</option>
|
||||
{vendorList
|
||||
.filter((v) => !(policy.linkedVendorIds || []).includes(v.id))
|
||||
.map((v) => (<option key={v.id} value={v.id}>{v.name || v.id}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">
|
||||
Keine Auftragsverarbeiter gefunden. Erstellen Sie zuerst Auftragsverarbeiter im Vendor-Compliance-Modul, um hier Verknuepfungen herstellen zu koennen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sektion 6: Review-Einstellungen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { renderStatusBadge } from './UebersichtTab'
|
||||
import {
|
||||
DataObjectSection, DeletionLogicSection, StorageSection,
|
||||
ResponsibilitySection, VVTLinkSection, ReviewSection,
|
||||
ResponsibilitySection, VVTLinkSection, VendorLinkSection, ReviewSection,
|
||||
} from './EditorSections'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -19,6 +19,7 @@ interface EditorTabProps {
|
||||
editingId: string | null
|
||||
editingPolicy: LoeschfristPolicy | null
|
||||
vvtActivities: any[]
|
||||
vendorList: Array<{id: string; name: string}>
|
||||
saving: boolean
|
||||
setEditingId: (id: string | null) => void
|
||||
setTab: (tab: 'uebersicht' | 'editor' | 'generator' | 'export') => void
|
||||
@@ -79,7 +80,7 @@ function EditorNoSelection({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EditorForm({
|
||||
policy, vvtActivities, saving, setEditingId, setTab,
|
||||
policy, vvtActivities, vendorList, saving, setEditingId, setTab,
|
||||
updatePolicy, deletePolicy, addLegalHold, removeLegalHold,
|
||||
addStorageLocation, removeStorageLocation, handleSaveAndClose,
|
||||
}: Omit<EditorTabProps, 'policies' | 'editingId' | 'editingPolicy' | 'createNewPolicy'> & {
|
||||
@@ -127,6 +128,7 @@ function EditorForm({
|
||||
addStorageLocation={addStorageLocation} removeStorageLocation={removeStorageLocation} />
|
||||
<ResponsibilitySection policy={policy} set={set} />
|
||||
<VVTLinkSection policy={policy} pid={pid} vvtActivities={vvtActivities} updatePolicy={updatePolicy} />
|
||||
<VendorLinkSection policy={policy} pid={pid} vendorList={vendorList} updatePolicy={updatePolicy} />
|
||||
<ReviewSection policy={policy} set={set} />
|
||||
|
||||
{/* Action buttons */}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { LoeschfristPolicy } from '@/lib/sdk/loeschfristen-types'
|
||||
import { ComplianceCheckResult } from '@/lib/sdk/loeschfristen-compliance'
|
||||
import {
|
||||
buildLoeschkonzeptHtml,
|
||||
type LoeschkonzeptOrgHeader,
|
||||
type LoeschkonzeptRevision,
|
||||
} from '@/lib/sdk/loeschfristen-document'
|
||||
import { downloadFile } from '@/lib/sdk/loeschfristen-export'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LoeschkonzeptTabProps {
|
||||
policies: LoeschfristPolicy[]
|
||||
orgHeader: LoeschkonzeptOrgHeader
|
||||
revisions: LoeschkonzeptRevision[]
|
||||
complianceResult: ComplianceCheckResult | null
|
||||
vvtActivities: any[]
|
||||
onOrgHeaderChange: (field: keyof LoeschkonzeptOrgHeader, value: string | string[]) => void
|
||||
onAddRevision: () => void
|
||||
onUpdateRevision: (index: number, field: keyof LoeschkonzeptRevision, value: string) => void
|
||||
onRemoveRevision: (index: number) => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function LoeschkonzeptTab({
|
||||
policies,
|
||||
orgHeader,
|
||||
revisions,
|
||||
complianceResult,
|
||||
vvtActivities,
|
||||
onOrgHeaderChange,
|
||||
onAddRevision,
|
||||
onUpdateRevision,
|
||||
onRemoveRevision,
|
||||
}: LoeschkonzeptTabProps) {
|
||||
const activePolicies = policies.filter(p => p.status !== 'ARCHIVED')
|
||||
|
||||
function handlePrintLoeschkonzept() {
|
||||
const htmlContent = buildLoeschkonzeptHtml(policies, orgHeader, vvtActivities, complianceResult, revisions)
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (printWindow) {
|
||||
printWindow.document.write(htmlContent)
|
||||
printWindow.document.close()
|
||||
printWindow.focus()
|
||||
setTimeout(() => printWindow.print(), 300)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownloadLoeschkonzeptHtml() {
|
||||
const htmlContent = buildLoeschkonzeptHtml(policies, orgHeader, vvtActivities, complianceResult, revisions)
|
||||
downloadFile(htmlContent, `loeschkonzept-${new Date().toISOString().split('T')[0]}.html`, 'text/html;charset=utf-8')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Action bar */}
|
||||
<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">
|
||||
Loeschkonzept (Art. 5/17/30 DSGVO)
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Druckfertiges Loeschkonzept mit Deckblatt, Loeschregeln, VVT-Verknuepfung und Compliance-Status.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDownloadLoeschkonzeptHtml}
|
||||
disabled={activePolicies.length === 0}
|
||||
className="bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg px-4 py-2 text-sm font-medium transition flex items-center gap-1.5"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
|
||||
HTML herunterladen
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePrintLoeschkonzept}
|
||||
disabled={activePolicies.length === 0}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg px-4 py-2 text-sm font-medium transition flex items-center gap-1.5"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><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>
|
||||
|
||||
{activePolicies.length === 0 && (
|
||||
<div className="bg-yellow-50 text-yellow-700 text-sm rounded-lg p-3 border border-yellow-200">
|
||||
Keine aktiven Policies vorhanden. Erstellen Sie mindestens eine Policy, um das Loeschkonzept zu generieren.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Org Header Form */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-4">Organisationsdaten (Deckblatt)</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Organisation</label>
|
||||
<input type="text" value={orgHeader.organizationName}
|
||||
onChange={e => onOrgHeaderChange('organizationName', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="Name der Organisation" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Branche</label>
|
||||
<input type="text" value={orgHeader.industry}
|
||||
onChange={e => onOrgHeaderChange('industry', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="z.B. IT / Software" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Datenschutzbeauftragter</label>
|
||||
<input type="text" value={orgHeader.dpoName}
|
||||
onChange={e => onOrgHeaderChange('dpoName', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="Name des DSB" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">DSB-Kontakt</label>
|
||||
<input type="text" value={orgHeader.dpoContact}
|
||||
onChange={e => onOrgHeaderChange('dpoContact', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="E-Mail oder Telefon" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Verantwortlicher (Art. 4 Nr. 7)</label>
|
||||
<input type="text" value={orgHeader.responsiblePerson}
|
||||
onChange={e => onOrgHeaderChange('responsiblePerson', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="Name des Verantwortlichen" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Mitarbeiter</label>
|
||||
<input type="text" value={orgHeader.employeeCount}
|
||||
onChange={e => onOrgHeaderChange('employeeCount', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="z.B. 50-249" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Version</label>
|
||||
<input type="text" value={orgHeader.loeschkonzeptVersion}
|
||||
onChange={e => onOrgHeaderChange('loeschkonzeptVersion', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="1.0" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Pruefintervall</label>
|
||||
<select value={orgHeader.reviewInterval}
|
||||
onChange={e => onOrgHeaderChange('reviewInterval', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500">
|
||||
<option value="Vierteljaehrlich">Vierteljaehrlich</option>
|
||||
<option value="Halbjaehrlich">Halbjaehrlich</option>
|
||||
<option value="Jaehrlich">Jaehrlich</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Letzte Pruefung</label>
|
||||
<input type="date" value={orgHeader.lastReviewDate}
|
||||
onChange={e => onOrgHeaderChange('lastReviewDate', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Naechste Pruefung</label>
|
||||
<input type="date" value={orgHeader.nextReviewDate}
|
||||
onChange={e => onOrgHeaderChange('nextReviewDate', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revisions */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900">Aenderungshistorie</h4>
|
||||
<button onClick={onAddRevision}
|
||||
className="text-xs bg-purple-50 text-purple-700 hover:bg-purple-100 rounded-lg px-3 py-1.5 font-medium transition">
|
||||
+ Revision hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
{revisions.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">
|
||||
Noch keine Revisionen. Die Erstversion wird automatisch im Dokument eingefuegt.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{revisions.map((rev, idx) => (
|
||||
<div key={idx} className="grid grid-cols-[80px_120px_1fr_1fr_32px] gap-2 items-start">
|
||||
<input type="text" value={rev.version}
|
||||
onChange={e => onUpdateRevision(idx, 'version', e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs" placeholder="1.1" />
|
||||
<input type="date" value={rev.date}
|
||||
onChange={e => onUpdateRevision(idx, 'date', e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs" />
|
||||
<input type="text" value={rev.author}
|
||||
onChange={e => onUpdateRevision(idx, 'author', e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs" placeholder="Autor" />
|
||||
<input type="text" value={rev.changes}
|
||||
onChange={e => onUpdateRevision(idx, 'changes', e.target.value)}
|
||||
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs" placeholder="Beschreibung der Aenderungen" />
|
||||
<button onClick={() => onRemoveRevision(idx)}
|
||||
className="text-red-400 hover:text-red-600 p-1" title="Revision entfernen">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document Preview */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-4">Dokument-Vorschau</h4>
|
||||
<div className="bg-gray-50 rounded-lg p-6 border border-gray-200">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-2xl font-bold text-purple-700 mb-1">Loeschkonzept</div>
|
||||
<div className="text-sm text-purple-500 mb-4">gemaess Art. 5/17/30 DSGVO</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{orgHeader.organizationName || <span className="text-gray-400 italic">Organisation nicht angegeben</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
Version {orgHeader.loeschkonzeptVersion} | {new Date().toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">12 Sektionen</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-xs text-gray-600">
|
||||
<div>1. Ziel und Zweck</div><div>7. Auftragsverarbeiter</div>
|
||||
<div>2. Geltungsbereich</div><div>8. Legal Hold Verfahren</div>
|
||||
<div>3. Grundprinzipien</div><div>9. Verantwortlichkeiten</div>
|
||||
<div>4. Loeschregeln-Uebersicht</div><div>10. Pruef-/Revisionszyklus</div>
|
||||
<div>5. Detaillierte Loeschregeln</div><div>11. Compliance-Status</div>
|
||||
<div>6. VVT-Verknuepfung</div><div>12. Aenderungshistorie</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-4 mt-4 flex gap-6 text-xs text-gray-500">
|
||||
<span><strong className="text-gray-700">{activePolicies.length}</strong> Loeschregeln</span>
|
||||
<span><strong className="text-gray-700">{policies.filter(p => p.linkedVVTActivityIds.length > 0).length}</strong> VVT-Verknuepfungen</span>
|
||||
<span><strong className="text-gray-700">{policies.filter(p => p.linkedVendorIds.length > 0).length}</strong> Vendor-Verknuepfungen</span>
|
||||
<span><strong className="text-gray-700">{revisions.length}</strong> Revisionen</span>
|
||||
{complianceResult && (
|
||||
<span>Compliance-Score: <strong className={complianceResult.score >= 75 ? 'text-green-600' : complianceResult.score >= 50 ? 'text-yellow-600' : 'text-red-600'}>{complianceResult.score}/100</strong></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
admin-compliance/app/sdk/loeschfristen/_components/api.ts
Normal file
74
admin-compliance/app/sdk/loeschfristen/_components/api.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { LoeschfristPolicy, createEmptyPolicy } from '@/lib/sdk/loeschfristen-types'
|
||||
|
||||
export const LOESCHFRISTEN_API = '/api/sdk/v1/compliance/loeschfristen'
|
||||
|
||||
export function apiToPolicy(raw: any): LoeschfristPolicy {
|
||||
const base = createEmptyPolicy()
|
||||
return {
|
||||
...base,
|
||||
id: raw.id,
|
||||
policyId: raw.policy_id || base.policyId,
|
||||
dataObjectName: raw.data_object_name || '',
|
||||
description: raw.description || '',
|
||||
affectedGroups: raw.affected_groups || [],
|
||||
dataCategories: raw.data_categories || [],
|
||||
primaryPurpose: raw.primary_purpose || '',
|
||||
deletionTrigger: raw.deletion_trigger || 'PURPOSE_END',
|
||||
retentionDriver: raw.retention_driver || null,
|
||||
retentionDriverDetail: raw.retention_driver_detail || '',
|
||||
retentionDuration: raw.retention_duration ?? null,
|
||||
retentionUnit: raw.retention_unit || null,
|
||||
retentionDescription: raw.retention_description || '',
|
||||
startEvent: raw.start_event || '',
|
||||
hasActiveLegalHold: raw.has_active_legal_hold || false,
|
||||
legalHolds: raw.legal_holds || [],
|
||||
storageLocations: raw.storage_locations || [],
|
||||
deletionMethod: raw.deletion_method || 'MANUAL_REVIEW_DELETE',
|
||||
deletionMethodDetail: raw.deletion_method_detail || '',
|
||||
responsibleRole: raw.responsible_role || '',
|
||||
responsiblePerson: raw.responsible_person || '',
|
||||
releaseProcess: raw.release_process || '',
|
||||
linkedVVTActivityIds: raw.linked_vvt_activity_ids || [],
|
||||
linkedVendorIds: raw.linked_vendor_ids || [],
|
||||
status: raw.status || 'DRAFT',
|
||||
lastReviewDate: raw.last_review_date || base.lastReviewDate,
|
||||
nextReviewDate: raw.next_review_date || base.nextReviewDate,
|
||||
reviewInterval: raw.review_interval || 'ANNUAL',
|
||||
tags: raw.tags || [],
|
||||
createdAt: raw.created_at || base.createdAt,
|
||||
updatedAt: raw.updated_at || base.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function policyToPayload(p: LoeschfristPolicy): any {
|
||||
return {
|
||||
policy_id: p.policyId,
|
||||
data_object_name: p.dataObjectName,
|
||||
description: p.description,
|
||||
affected_groups: p.affectedGroups,
|
||||
data_categories: p.dataCategories,
|
||||
primary_purpose: p.primaryPurpose,
|
||||
deletion_trigger: p.deletionTrigger,
|
||||
retention_driver: p.retentionDriver || null,
|
||||
retention_driver_detail: p.retentionDriverDetail,
|
||||
retention_duration: p.retentionDuration || null,
|
||||
retention_unit: p.retentionUnit || null,
|
||||
retention_description: p.retentionDescription,
|
||||
start_event: p.startEvent,
|
||||
has_active_legal_hold: p.hasActiveLegalHold,
|
||||
legal_holds: p.legalHolds,
|
||||
storage_locations: p.storageLocations,
|
||||
deletion_method: p.deletionMethod,
|
||||
deletion_method_detail: p.deletionMethodDetail,
|
||||
responsible_role: p.responsibleRole,
|
||||
responsible_person: p.responsiblePerson,
|
||||
release_process: p.releaseProcess,
|
||||
linked_vvt_activity_ids: p.linkedVVTActivityIds,
|
||||
linked_vendor_ids: p.linkedVendorIds,
|
||||
status: p.status,
|
||||
last_review_date: p.lastReviewDate || null,
|
||||
next_review_date: p.nextReviewDate || null,
|
||||
review_interval: p.reviewInterval,
|
||||
tags: p.tags,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
284
admin-compliance/app/sdk/vvt/_components/TabDokument.tsx
Normal file
284
admin-compliance/app/sdk/vvt/_components/TabDokument.tsx
Normal 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 "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>
|
||||
)
|
||||
}
|
||||
336
admin-compliance/app/sdk/vvt/_components/TabProcessor.tsx
Normal file
336
admin-compliance/app/sdk/vvt/_components/TabProcessor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user