refactor(admin): split vvt page.tsx into colocated components
Split the 1371-line VVT page into _components/ extractions (FormPrimitives, api, TabVerzeichnis, TabEditor, TabExport) to bring page.tsx under the 300 LOC soft target. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
402
admin-compliance/app/sdk/vvt/_components/TabEditor.tsx
Normal file
402
admin-compliance/app/sdk/vvt/_components/TabEditor.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
DATA_SUBJECT_CATEGORY_META,
|
||||
PERSONAL_DATA_CATEGORY_META,
|
||||
LEGAL_BASIS_META,
|
||||
TRANSFER_MECHANISM_META,
|
||||
ART9_CATEGORIES,
|
||||
BUSINESS_FUNCTION_LABELS,
|
||||
STATUS_LABELS,
|
||||
STATUS_COLORS,
|
||||
PROTECTION_LEVEL_LABELS,
|
||||
DEPLOYMENT_LABELS,
|
||||
} from '@/lib/sdk/vvt-types'
|
||||
import type { VVTActivity, BusinessFunction } from '@/lib/sdk/vvt-types'
|
||||
import { FormSection, FormField, MultiTextInput, CheckboxGrid } from './FormPrimitives'
|
||||
|
||||
export function TabEditor({
|
||||
activity, activities, onSave, onBack, onSelectActivity,
|
||||
}: {
|
||||
activity: VVTActivity | null | undefined
|
||||
activities: VVTActivity[]
|
||||
onSave: (updated: VVTActivity) => void
|
||||
onBack: () => void
|
||||
onSelectActivity: (id: string) => void
|
||||
}) {
|
||||
const [local, setLocal] = useState<VVTActivity | null>(null)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(activity ? { ...activity } : null)
|
||||
}, [activity])
|
||||
|
||||
if (!local) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Keine Verarbeitung ausgewaehlt</h3>
|
||||
<p className="text-gray-500 mb-4">Waehlen Sie eine Verarbeitung aus dem Verzeichnis oder erstellen Sie eine neue.</p>
|
||||
<button onClick={onBack} className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
Zum Verzeichnis
|
||||
</button>
|
||||
</div>
|
||||
{activities.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<h4 className="font-medium text-gray-700 mb-3">Verarbeitungen zum Bearbeiten:</h4>
|
||||
<div className="space-y-1">
|
||||
{activities.map(a => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => onSelectActivity(a.id)}
|
||||
className="w-full text-left px-3 py-2 rounded-lg hover:bg-purple-50 text-sm flex items-center justify-between"
|
||||
>
|
||||
<span><span className="font-mono text-gray-400 mr-2">{a.vvtId}</span>{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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const update = (patch: Partial<VVTActivity>) => setLocal(prev => prev ? { ...prev, ...patch } : prev)
|
||||
|
||||
const handleSave = () => {
|
||||
if (local) onSave(local)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onBack} className="p-2 hover:bg-gray-100 rounded-lg">
|
||||
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<span className="text-sm font-mono text-gray-400">{local.vvtId}</span>
|
||||
<h2 className="text-lg font-bold text-gray-900">{local.name || 'Neue Verarbeitung'}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={local.status}
|
||||
onChange={(e) => update({ status: e.target.value as VVTActivity['status'] })}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="DRAFT">Entwurf</option>
|
||||
<option value="REVIEW">In Pruefung</option>
|
||||
<option value="APPROVED">Genehmigt</option>
|
||||
<option value="ARCHIVED">Archiviert</option>
|
||||
</select>
|
||||
<button onClick={handleSave} className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
|
||||
{/* Bezeichnung + Beschreibung */}
|
||||
<FormSection title="Grunddaten">
|
||||
<FormField label="Bezeichnung *">
|
||||
<input type="text" value={local.name} onChange={(e) => update({ name: 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-transparent"
|
||||
placeholder="z.B. Mitarbeiterverwaltung" />
|
||||
</FormField>
|
||||
<FormField label="Beschreibung">
|
||||
<textarea value={local.description} onChange={(e) => update({ description: e.target.value })}
|
||||
rows={2} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Kurze Beschreibung der Verarbeitung" />
|
||||
</FormField>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField label="Verantwortlich">
|
||||
<input type="text" value={local.responsible} onChange={(e) => update({ responsible: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="z.B. HR-Abteilung" />
|
||||
</FormField>
|
||||
<FormField label="Geschaeftsbereich">
|
||||
<select value={local.businessFunction} onChange={(e) => update({ businessFunction: e.target.value as BusinessFunction })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
{Object.entries(BUSINESS_FUNCTION_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Zwecke */}
|
||||
<FormSection title="Zwecke der Verarbeitung *">
|
||||
<MultiTextInput
|
||||
values={local.purposes}
|
||||
onChange={(purposes) => update({ purposes })}
|
||||
placeholder="Zweck eingeben und Enter druecken"
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{/* Rechtsgrundlagen */}
|
||||
<FormSection title="Rechtsgrundlagen *">
|
||||
<div className="space-y-2">
|
||||
{local.legalBases.map((lb, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<select
|
||||
value={lb.type}
|
||||
onChange={(e) => {
|
||||
const copy = [...local.legalBases]
|
||||
copy[i] = { ...copy[i], type: e.target.value }
|
||||
update({ legalBases: copy })
|
||||
}}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">-- Rechtsgrundlage waehlen --</option>
|
||||
{Object.entries(LEGAL_BASIS_META).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label.de} ({v.article})</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={lb.reference || ''}
|
||||
onChange={(e) => {
|
||||
const copy = [...local.legalBases]
|
||||
copy[i] = { ...copy[i], reference: e.target.value }
|
||||
update({ legalBases: copy })
|
||||
}}
|
||||
className="w-48 px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
placeholder="Referenz"
|
||||
/>
|
||||
<button onClick={() => update({ legalBases: local.legalBases.filter((_, j) => j !== i) })}
|
||||
className="p-2 text-gray-400 hover:text-red-500">
|
||||
<svg className="w-4 h-4" 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>
|
||||
))}
|
||||
<button onClick={() => update({ legalBases: [...local.legalBases, { type: '', description: '', reference: '' }] })}
|
||||
className="text-sm text-purple-600 hover:text-purple-700">
|
||||
+ Rechtsgrundlage hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Betroffenenkategorien */}
|
||||
<FormSection title="Betroffenenkategorien *">
|
||||
<CheckboxGrid
|
||||
options={Object.entries(DATA_SUBJECT_CATEGORY_META).map(([k, v]) => ({ value: k, label: v.de }))}
|
||||
selected={local.dataSubjectCategories}
|
||||
onChange={(dataSubjectCategories) => update({ dataSubjectCategories })}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{/* Datenkategorien */}
|
||||
<FormSection title="Datenkategorien *">
|
||||
<CheckboxGrid
|
||||
options={Object.entries(PERSONAL_DATA_CATEGORY_META).map(([k, v]) => ({
|
||||
value: k,
|
||||
label: v.label.de,
|
||||
highlight: v.isSpecial,
|
||||
}))}
|
||||
selected={local.personalDataCategories}
|
||||
onChange={(personalDataCategories) => update({ personalDataCategories })}
|
||||
/>
|
||||
{local.personalDataCategories.some(c => ART9_CATEGORIES.includes(c)) && (
|
||||
<div className="mt-2 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
<strong>Hinweis:</strong> Sie verarbeiten besondere Datenkategorien nach Art. 9 DSGVO. Stellen Sie sicher, dass eine Art.-9-Rechtsgrundlage vorliegt.
|
||||
</div>
|
||||
)}
|
||||
</FormSection>
|
||||
|
||||
{/* Empfaenger */}
|
||||
<FormSection title="Empfaengerkategorien">
|
||||
<div className="space-y-2">
|
||||
{local.recipientCategories.map((rc, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<select
|
||||
value={rc.type}
|
||||
onChange={(e) => {
|
||||
const copy = [...local.recipientCategories]
|
||||
copy[i] = { ...copy[i], type: e.target.value }
|
||||
update({ recipientCategories: copy })
|
||||
}}
|
||||
className="w-40 px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="INTERNAL">Intern</option>
|
||||
<option value="PROCESSOR">Auftragsverarbeiter</option>
|
||||
<option value="CONTROLLER">Verantwortlicher</option>
|
||||
<option value="AUTHORITY">Behoerde</option>
|
||||
<option value="GROUP_COMPANY">Konzern</option>
|
||||
<option value="OTHER">Sonstige</option>
|
||||
</select>
|
||||
<input type="text" value={rc.name}
|
||||
onChange={(e) => {
|
||||
const copy = [...local.recipientCategories]
|
||||
copy[i] = { ...copy[i], name: e.target.value }
|
||||
update({ recipientCategories: copy })
|
||||
}}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="Name des Empfaengers" />
|
||||
<button onClick={() => update({ recipientCategories: local.recipientCategories.filter((_, j) => j !== i) })}
|
||||
className="p-2 text-gray-400 hover:text-red-500">
|
||||
<svg className="w-4 h-4" 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>
|
||||
))}
|
||||
<button onClick={() => update({ recipientCategories: [...local.recipientCategories, { type: 'INTERNAL', name: '' }] })}
|
||||
className="text-sm text-purple-600 hover:text-purple-700">
|
||||
+ Empfaenger hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Drittlandtransfers */}
|
||||
<FormSection title="Drittlandtransfers">
|
||||
<div className="space-y-2">
|
||||
{local.thirdCountryTransfers.map((tc, i) => (
|
||||
<div key={i} className="flex items-center gap-2 flex-wrap">
|
||||
<input type="text" value={tc.country}
|
||||
onChange={(e) => {
|
||||
const copy = [...local.thirdCountryTransfers]
|
||||
copy[i] = { ...copy[i], country: e.target.value }
|
||||
update({ thirdCountryTransfers: copy })
|
||||
}}
|
||||
className="w-20 px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="Land" />
|
||||
<input type="text" value={tc.recipient}
|
||||
onChange={(e) => {
|
||||
const copy = [...local.thirdCountryTransfers]
|
||||
copy[i] = { ...copy[i], recipient: e.target.value }
|
||||
update({ thirdCountryTransfers: copy })
|
||||
}}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="Empfaenger" />
|
||||
<select value={tc.transferMechanism}
|
||||
onChange={(e) => {
|
||||
const copy = [...local.thirdCountryTransfers]
|
||||
copy[i] = { ...copy[i], transferMechanism: e.target.value }
|
||||
update({ thirdCountryTransfers: copy })
|
||||
}}
|
||||
className="w-56 px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<option value="">-- Mechanismus --</option>
|
||||
{Object.entries(TRANSFER_MECHANISM_META).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.de}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={() => update({ thirdCountryTransfers: local.thirdCountryTransfers.filter((_, j) => j !== i) })}
|
||||
className="p-2 text-gray-400 hover:text-red-500">
|
||||
<svg className="w-4 h-4" 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>
|
||||
))}
|
||||
<button onClick={() => update({ thirdCountryTransfers: [...local.thirdCountryTransfers, { country: '', recipient: '', transferMechanism: '' }] })}
|
||||
className="text-sm text-purple-600 hover:text-purple-700">
|
||||
+ Drittlandtransfer hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Aufbewahrungsfristen */}
|
||||
<FormSection title="Aufbewahrungsfristen *">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<FormField label="Dauer">
|
||||
<div className="flex gap-2">
|
||||
<input type="number" value={local.retentionPeriod.duration || ''}
|
||||
onChange={(e) => update({ retentionPeriod: { ...local.retentionPeriod, duration: parseInt(e.target.value) || undefined } })}
|
||||
className="w-20 px-3 py-2 border border-gray-300 rounded-lg" placeholder="z.B. 10" />
|
||||
<select value={local.retentionPeriod.durationUnit || 'YEARS'}
|
||||
onChange={(e) => update({ retentionPeriod: { ...local.retentionPeriod, durationUnit: e.target.value } })}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg">
|
||||
<option value="DAYS">Tage</option>
|
||||
<option value="MONTHS">Monate</option>
|
||||
<option value="YEARS">Jahre</option>
|
||||
</select>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Rechtsgrundlage">
|
||||
<input type="text" value={local.retentionPeriod.legalBasis || ''}
|
||||
onChange={(e) => update({ retentionPeriod: { ...local.retentionPeriod, legalBasis: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="z.B. HGB § 257" />
|
||||
</FormField>
|
||||
<FormField label="Loeschverfahren">
|
||||
<input type="text" value={local.retentionPeriod.deletionProcedure || ''}
|
||||
onChange={(e) => update({ retentionPeriod: { ...local.retentionPeriod, deletionProcedure: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="z.B. Automatische Loeschung" />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Beschreibung">
|
||||
<input type="text" value={local.retentionPeriod.description}
|
||||
onChange={(e) => update({ retentionPeriod: { ...local.retentionPeriod, description: e.target.value } })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="Freitextbeschreibung der Aufbewahrungsfrist" />
|
||||
</FormField>
|
||||
</FormSection>
|
||||
|
||||
{/* TOM-Beschreibung */}
|
||||
<FormSection title="TOM-Beschreibung (Art. 32)">
|
||||
<textarea value={local.tomDescription} onChange={(e) => update({ tomDescription: e.target.value })}
|
||||
rows={3} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Beschreiben Sie die technischen und organisatorischen Massnahmen zum Schutz der Daten" />
|
||||
</FormSection>
|
||||
|
||||
{/* Advanced (collapsible) */}
|
||||
<div className="p-4">
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<svg className={`w-4 h-4 transition-transform ${showAdvanced ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Generator-Felder (Schutzniveau, Systeme, DSFA)
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField label="Schutzniveau">
|
||||
<select value={local.protectionLevel} onChange={(e) => update({ protectionLevel: e.target.value as VVTActivity['protectionLevel'] })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
{Object.entries(PROTECTION_LEVEL_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Deployment">
|
||||
<select value={local.deploymentModel} onChange={(e) => update({ deploymentModel: e.target.value as VVTActivity['deploymentModel'] })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
{Object.entries(DEPLOYMENT_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="DSFA erforderlich">
|
||||
<label className="flex items-center gap-2 mt-2">
|
||||
<input type="checkbox" checked={local.dpiaRequired}
|
||||
onChange={(e) => update({ dpiaRequired: e.target.checked })}
|
||||
className="w-4 h-4 text-purple-600 rounded" />
|
||||
<span className="text-sm text-gray-700">Ja, DSFA nach Art. 35 DSGVO erforderlich</span>
|
||||
</label>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save button at bottom */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button onClick={onBack} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
|
||||
Zurueck zum Verzeichnis
|
||||
</button>
|
||||
<button onClick={handleSave} className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user