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:
96
admin-compliance/app/sdk/vvt/_components/FormPrimitives.tsx
Normal file
96
admin-compliance/app/sdk/vvt/_components/FormPrimitives.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export function FormSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<h4 className="font-medium text-gray-800 text-sm">{title}</h4>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FormField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MultiTextInput({ values, onChange, placeholder }: { values: string[]; onChange: (v: string[]) => void; placeholder?: string }) {
|
||||
const [input, setInput] = useState('')
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && input.trim()) {
|
||||
e.preventDefault()
|
||||
onChange([...values, input.trim()])
|
||||
setInput('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{values.map((v, i) => (
|
||||
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-700 rounded text-sm">
|
||||
{v}
|
||||
<button onClick={() => onChange(values.filter((_, j) => j !== i))} className="text-purple-400 hover:text-purple-600">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CheckboxGrid({ options, selected, onChange }: {
|
||||
options: { value: string; label: string; highlight?: boolean }[]
|
||||
selected: string[]
|
||||
onChange: (v: string[]) => void
|
||||
}) {
|
||||
const toggle = (value: string) => {
|
||||
if (selected.includes(value)) {
|
||||
onChange(selected.filter(v => v !== value))
|
||||
} else {
|
||||
onChange([...selected, value])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1.5">
|
||||
{options.map(opt => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer text-sm transition-colors ${
|
||||
selected.includes(opt.value)
|
||||
? opt.highlight ? 'bg-red-50 border border-red-300' : 'bg-purple-50 border border-purple-300'
|
||||
: opt.highlight ? 'bg-red-50/30 border border-gray-200' : 'border border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(opt.value)}
|
||||
onChange={() => toggle(opt.value)}
|
||||
className="w-3.5 h-3.5 text-purple-600 rounded"
|
||||
/>
|
||||
<span className={opt.highlight ? 'text-red-700' : 'text-gray-700'}>{opt.label}</span>
|
||||
{opt.highlight && <span className="text-xs text-red-400">Art.9</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
230
admin-compliance/app/sdk/vvt/_components/TabExport.tsx
Normal file
230
admin-compliance/app/sdk/vvt/_components/TabExport.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
DATA_SUBJECT_CATEGORY_META,
|
||||
PERSONAL_DATA_CATEGORY_META,
|
||||
ART9_CATEGORIES,
|
||||
STATUS_LABELS,
|
||||
REVIEW_INTERVAL_LABELS,
|
||||
} from '@/lib/sdk/vvt-types'
|
||||
import type { VVTActivity, VVTOrganizationHeader } from '@/lib/sdk/vvt-types'
|
||||
import { FormField } from './FormPrimitives'
|
||||
|
||||
export function TabExport({
|
||||
activities, orgHeader, onUpdateOrgHeader,
|
||||
}: {
|
||||
activities: VVTActivity[]
|
||||
orgHeader: VVTOrganizationHeader
|
||||
onUpdateOrgHeader: (org: VVTOrganizationHeader) => void
|
||||
}) {
|
||||
// Compliance check
|
||||
const issues: { activityId: string; vvtId: string; name: string; issues: string[] }[] = []
|
||||
for (const a of activities) {
|
||||
const actIssues: string[] = []
|
||||
if (!a.name) actIssues.push('Bezeichnung fehlt')
|
||||
if (a.purposes.length === 0) actIssues.push('Zweck(e) fehlen')
|
||||
if (a.legalBases.length === 0) actIssues.push('Rechtsgrundlage fehlt')
|
||||
if (a.dataSubjectCategories.length === 0) actIssues.push('Betroffenenkategorien fehlen')
|
||||
if (a.personalDataCategories.length === 0) actIssues.push('Datenkategorien fehlen')
|
||||
if (!a.retentionPeriod.description) actIssues.push('Aufbewahrungsfrist fehlt')
|
||||
if (!a.tomDescription && a.structuredToms.accessControl.length === 0) actIssues.push('TOM-Beschreibung fehlt')
|
||||
|
||||
// Art. 9 without Art. 9 legal basis
|
||||
const hasArt9Data = a.personalDataCategories.some(c => ART9_CATEGORIES.includes(c))
|
||||
const hasArt9Basis = a.legalBases.some(lb => lb.type.startsWith('ART9_'))
|
||||
if (hasArt9Data && !hasArt9Basis) actIssues.push('Art.-9-Daten ohne Art.-9-Rechtsgrundlage')
|
||||
|
||||
// Third country without mechanism
|
||||
for (const tc of a.thirdCountryTransfers) {
|
||||
if (!tc.transferMechanism) actIssues.push(`Drittland ${tc.country}: Transfer-Mechanismus fehlt`)
|
||||
}
|
||||
|
||||
if (actIssues.length > 0) {
|
||||
issues.push({ activityId: a.id, vvtId: a.vvtId, name: a.name || '(Ohne Namen)', issues: actIssues })
|
||||
}
|
||||
}
|
||||
|
||||
const compliantCount = activities.length - issues.length
|
||||
const compliancePercent = activities.length > 0 ? Math.round((compliantCount / activities.length) * 100) : 0
|
||||
|
||||
const handleExportJSON = () => {
|
||||
const data = {
|
||||
version: '1.0',
|
||||
exportDate: new Date().toISOString(),
|
||||
organization: orgHeader,
|
||||
activities: activities,
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `vvt-export-${new Date().toISOString().split('T')[0]}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleExportCSV = () => {
|
||||
const headers = ['VVT-ID', 'Name', 'Beschreibung', 'Zwecke', 'Rechtsgrundlagen', 'Betroffene', 'Datenkategorien', 'Empfaenger', 'Drittlandtransfers', 'Aufbewahrungsfrist', 'TOM', 'Status', 'Verantwortlich']
|
||||
const rows = activities.map(a => [
|
||||
a.vvtId,
|
||||
a.name,
|
||||
a.description,
|
||||
a.purposes.join('; '),
|
||||
a.legalBases.map(lb => `${lb.type}${lb.reference ? ' (' + lb.reference + ')' : ''}`).join('; '),
|
||||
a.dataSubjectCategories.map(c => DATA_SUBJECT_CATEGORY_META[c as keyof typeof DATA_SUBJECT_CATEGORY_META]?.de || c).join('; '),
|
||||
a.personalDataCategories.map(c => PERSONAL_DATA_CATEGORY_META[c as keyof typeof PERSONAL_DATA_CATEGORY_META]?.label?.de || c).join('; '),
|
||||
a.recipientCategories.map(r => `${r.name} (${r.type})`).join('; '),
|
||||
a.thirdCountryTransfers.map(t => `${t.country}: ${t.recipient}`).join('; '),
|
||||
a.retentionPeriod.description,
|
||||
a.tomDescription,
|
||||
STATUS_LABELS[a.status],
|
||||
a.responsible,
|
||||
])
|
||||
|
||||
const csvContent = [headers, ...rows].map(row =>
|
||||
row.map(cell => `"${String(cell || '').replace(/"/g, '""')}"`).join(',')
|
||||
).join('\n')
|
||||
|
||||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `vvt-export-${new Date().toISOString().split('T')[0]}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Compliance Overview */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Check</h3>
|
||||
<div className="flex items-center gap-6 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-16 h-16 rounded-full flex items-center justify-center text-xl font-bold ${
|
||||
compliancePercent === 100 ? 'bg-green-100 text-green-700' :
|
||||
compliancePercent >= 70 ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{compliancePercent}%
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{compliantCount} von {activities.length} vollstaendig</div>
|
||||
<div className="text-sm text-gray-500">{issues.length} Eintraege mit Maengeln</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{issues.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
{issues.map(issue => (
|
||||
<div key={issue.activityId} className="p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-mono text-xs text-amber-600">{issue.vvtId}</span>
|
||||
<span className="text-sm font-medium text-amber-800">{issue.name}</span>
|
||||
</div>
|
||||
<ul className="text-sm text-amber-700 space-y-0.5">
|
||||
{issue.issues.map((iss, i) => (
|
||||
<li key={i} className="flex items-center gap-1">
|
||||
<svg className="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01" />
|
||||
</svg>
|
||||
{iss}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{issues.length === 0 && activities.length > 0 && (
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">
|
||||
Alle Verarbeitungen enthalten die erforderlichen Pflichtangaben nach Art. 30 DSGVO.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Organisation Header */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">VVT-Metadaten (Organisation)</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField label="Organisationsname">
|
||||
<input type="text" value={orgHeader.organizationName}
|
||||
onChange={(e) => onUpdateOrgHeader({ ...orgHeader, organizationName: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="Firma GmbH" />
|
||||
</FormField>
|
||||
<FormField label="Branche">
|
||||
<input type="text" value={orgHeader.industry}
|
||||
onChange={(e) => onUpdateOrgHeader({ ...orgHeader, industry: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="z.B. IT & Software" />
|
||||
</FormField>
|
||||
<FormField label="DSB Name">
|
||||
<input type="text" value={orgHeader.dpoName}
|
||||
onChange={(e) => onUpdateOrgHeader({ ...orgHeader, dpoName: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="Name des Datenschutzbeauftragten" />
|
||||
</FormField>
|
||||
<FormField label="DSB Kontakt">
|
||||
<input type="text" value={orgHeader.dpoContact}
|
||||
onChange={(e) => onUpdateOrgHeader({ ...orgHeader, dpoContact: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg" placeholder="E-Mail oder Telefon" />
|
||||
</FormField>
|
||||
<FormField label="Mitarbeiterzahl">
|
||||
<input type="number" value={orgHeader.employeeCount || ''}
|
||||
onChange={(e) => onUpdateOrgHeader({ ...orgHeader, employeeCount: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
|
||||
</FormField>
|
||||
<FormField label="Pruefintervall">
|
||||
<select value={orgHeader.reviewInterval}
|
||||
onChange={(e) => onUpdateOrgHeader({ ...orgHeader, reviewInterval: e.target.value as VVTOrganizationHeader['reviewInterval'] })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
{Object.entries(REVIEW_INTERVAL_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Export</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={handleExportJSON} disabled={activities.length === 0}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm">
|
||||
JSON exportieren
|
||||
</button>
|
||||
<button onClick={handleExportCSV} disabled={activities.length === 0}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm">
|
||||
CSV (Excel) exportieren
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Der Export enthaelt alle {activities.length} Verarbeitungstaetigkeiten inkl. Organisations-Metadaten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Statistik</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">{activities.length}</div>
|
||||
<div className="text-sm text-gray-500">Verarbeitungen</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">{[...new Set(activities.map(a => a.businessFunction))].length}</div>
|
||||
<div className="text-sm text-gray-500">Geschaeftsbereiche</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">{[...new Set(activities.flatMap(a => a.dataSubjectCategories))].length}</div>
|
||||
<div className="text-sm text-gray-500">Betroffenenkategorien</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">{[...new Set(activities.flatMap(a => a.personalDataCategories))].length}</div>
|
||||
<div className="text-sm text-gray-500">Datenkategorien</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
281
admin-compliance/app/sdk/vvt/_components/TabVerzeichnis.tsx
Normal file
281
admin-compliance/app/sdk/vvt/_components/TabVerzeichnis.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import {
|
||||
ART9_CATEGORIES,
|
||||
BUSINESS_FUNCTION_LABELS,
|
||||
STATUS_LABELS,
|
||||
STATUS_COLORS,
|
||||
} from '@/lib/sdk/vvt-types'
|
||||
import type { VVTActivity } from '@/lib/sdk/vvt-types'
|
||||
import {
|
||||
generateActivities,
|
||||
prefillFromScopeAnswers,
|
||||
} from '@/lib/sdk/vvt-profiling'
|
||||
|
||||
export function TabVerzeichnis({
|
||||
activities, allActivities, activeCount, draftCount, thirdCountryCount, art9Count,
|
||||
filter, setFilter, searchQuery, setSearchQuery, sortBy, setSortBy,
|
||||
scopeAnswers, onEdit, onNew, onDelete, onAdoptGenerated,
|
||||
}: {
|
||||
activities: VVTActivity[]
|
||||
allActivities: VVTActivity[]
|
||||
activeCount: number
|
||||
draftCount: number
|
||||
thirdCountryCount: number
|
||||
art9Count: number
|
||||
filter: string
|
||||
setFilter: (f: string) => void
|
||||
searchQuery: string
|
||||
setSearchQuery: (q: string) => void
|
||||
sortBy: string
|
||||
setSortBy: (s: 'name' | 'date' | 'status') => void
|
||||
scopeAnswers?: import('@/lib/sdk/compliance-scope-types').ScopeProfilingAnswer[]
|
||||
onEdit: (id: string) => void
|
||||
onNew: () => void
|
||||
onDelete: (id: string) => void
|
||||
onAdoptGenerated: (activities: VVTActivity[]) => void
|
||||
}) {
|
||||
const [scopePreview, setScopePreview] = useState<VVTActivity[] | null>(null)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
const handleGenerateFromScope = useCallback(() => {
|
||||
if (!scopeAnswers) return
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
const profilingAnswers = prefillFromScopeAnswers(scopeAnswers)
|
||||
const result = generateActivities(profilingAnswers)
|
||||
setScopePreview(result.generatedActivities)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}, [scopeAnswers])
|
||||
|
||||
const handleAdoptPreview = useCallback(() => {
|
||||
if (!scopePreview) return
|
||||
onAdoptGenerated(scopePreview)
|
||||
setScopePreview(null)
|
||||
}, [scopePreview, onAdoptGenerated])
|
||||
|
||||
// Preview mode for generated activities
|
||||
if (scopePreview) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Generierte Verarbeitungen</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Basierend auf Ihrer Scope-Analyse wurden {scopePreview.length} Verarbeitungstaetigkeiten generiert.
|
||||
Sie koennen einzelne Eintraege abwaehlen, bevor Sie diese uebernehmen.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{scopePreview.map((a, i) => (
|
||||
<div key={a.id} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<input type="checkbox" defaultChecked className="w-4 h-4 text-purple-600 rounded"
|
||||
onChange={(e) => {
|
||||
if (!e.target.checked) {
|
||||
setScopePreview(scopePreview.filter((_, j) => j !== i))
|
||||
}
|
||||
}} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-gray-400">{a.vvtId}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{a.name}</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded-full">{BUSINESS_FUNCTION_LABELS[a.businessFunction]}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate">{a.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button onClick={() => setScopePreview(null)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={handleAdoptPreview} className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
Alle {scopePreview.length} uebernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Scope Generate Button */}
|
||||
{scopeAnswers && scopeAnswers.length > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-blue-900">Aus Scope-Analyse generieren</h4>
|
||||
<p className="text-xs text-blue-700 mt-0.5">
|
||||
Erstellen Sie automatisch Verarbeitungstaetigkeiten basierend auf Ihren Scope-Profiling-Antworten.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGenerateFromScope}
|
||||
disabled={isGenerating}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm whitespace-nowrap disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
{isGenerating ? 'Generiere...' : 'Generieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<StatCard label="Gesamt" value={allActivities.length} color="gray" />
|
||||
<StatCard label="Genehmigt" value={activeCount} color="green" />
|
||||
<StatCard label="Entwurf" value={draftCount} color="yellow" />
|
||||
<StatCard label="Drittland" value={thirdCountryCount} color="orange" />
|
||||
<StatCard label="Art. 9 Daten" value={art9Count} color="red" />
|
||||
</div>
|
||||
|
||||
{/* Search + Filter + New */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center gap-3">
|
||||
<div className="flex-1 relative w-full">
|
||||
<svg className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="VVT-ID, Name oder Beschreibung suchen..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{[
|
||||
{ key: 'all', label: 'Alle' },
|
||||
{ key: 'DRAFT', label: 'Entwurf' },
|
||||
{ key: 'REVIEW', label: 'Pruefung' },
|
||||
{ key: 'APPROVED', label: 'Genehmigt' },
|
||||
{ key: 'thirdcountry', label: 'Drittland' },
|
||||
{ key: 'art9', label: 'Art. 9' },
|
||||
].map(f => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f.key ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as 'name' | 'date' | 'status')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="date">Datum</option>
|
||||
<option value="status">Status</option>
|
||||
</select>
|
||||
<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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Neue Verarbeitung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Activity Cards */}
|
||||
<div className="space-y-3">
|
||||
{activities.map(activity => (
|
||||
<ActivityCard key={activity.id} activity={activity} onEdit={onEdit} onDelete={onDelete} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activities.length === 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 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>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Verarbeitungen gefunden</h3>
|
||||
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
||||
Erstellen Sie eine neue Verarbeitung manuell oder generieren Sie Eintraege automatisch aus Ihrer Scope-Analyse.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
const borderColors: Record<string, string> = {
|
||||
gray: 'border-gray-200', green: 'border-green-200', yellow: 'border-yellow-200', orange: 'border-orange-200', red: 'border-red-200',
|
||||
}
|
||||
const textColors: Record<string, string> = {
|
||||
gray: 'text-gray-600', green: 'text-green-600', yellow: 'text-yellow-600', orange: 'text-orange-600', red: 'text-red-600',
|
||||
}
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border ${borderColors[color]} p-4`}>
|
||||
<div className={`text-sm ${textColors[color]}`}>{label}</div>
|
||||
<div className={`text-2xl font-bold ${textColors[color]}`}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityCard({ activity, onEdit, onDelete }: { activity: VVTActivity; onEdit: (id: string) => void; onDelete: (id: string) => void }) {
|
||||
const hasArt9 = activity.personalDataCategories.some(c => ART9_CATEGORIES.includes(c))
|
||||
const hasThirdCountry = activity.thirdCountryTransfers.length > 0
|
||||
|
||||
return (
|
||||
<div 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">
|
||||
<span className="text-xs font-mono text-gray-400">{activity.vvtId}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[activity.status] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{STATUS_LABELS[activity.status] || activity.status}
|
||||
</span>
|
||||
{hasArt9 && (
|
||||
<span className="px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded-full">Art. 9</span>
|
||||
)}
|
||||
{hasThirdCountry && (
|
||||
<span className="px-2 py-0.5 text-xs bg-orange-100 text-orange-700 rounded-full">Drittland</span>
|
||||
)}
|
||||
{activity.dpiaRequired && (
|
||||
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">DSFA</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 truncate">{activity.name || '(Ohne Namen)'}</h3>
|
||||
{activity.description && (
|
||||
<p className="text-sm text-gray-500 mt-0.5 line-clamp-1">{activity.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
|
||||
<span>{BUSINESS_FUNCTION_LABELS[activity.businessFunction]}</span>
|
||||
<span>{activity.responsible || 'Kein Verantwortlicher'}</span>
|
||||
<span>Aktualisiert: {new Date(activity.updatedAt).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
<button
|
||||
onClick={() => onEdit(activity.id)}
|
||||
className="px-3 py-1.5 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('Verarbeitung loeschen?')) onDelete(activity.id) }}
|
||||
className="px-2 py-1.5 text-sm text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg 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="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>
|
||||
)
|
||||
}
|
||||
140
admin-compliance/app/sdk/vvt/_components/api.ts
Normal file
140
admin-compliance/app/sdk/vvt/_components/api.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { VVTActivity, VVTOrganizationHeader } from '@/lib/sdk/vvt-types'
|
||||
|
||||
export const VVT_API_BASE = '/api/sdk/v1/compliance/vvt'
|
||||
|
||||
export function activityFromApi(raw: any): VVTActivity {
|
||||
return {
|
||||
id: raw.id,
|
||||
vvtId: raw.vvt_id,
|
||||
name: raw.name || '',
|
||||
description: raw.description || '',
|
||||
purposes: raw.purposes || [],
|
||||
legalBases: raw.legal_bases || [],
|
||||
dataSubjectCategories: raw.data_subject_categories || [],
|
||||
personalDataCategories: raw.personal_data_categories || [],
|
||||
recipientCategories: raw.recipient_categories || [],
|
||||
thirdCountryTransfers: raw.third_country_transfers || [],
|
||||
retentionPeriod: raw.retention_period || { description: '' },
|
||||
tomDescription: raw.tom_description || '',
|
||||
businessFunction: raw.business_function || 'other',
|
||||
systems: raw.systems || [],
|
||||
deploymentModel: raw.deployment_model || 'cloud',
|
||||
dataSources: raw.data_sources || [],
|
||||
dataFlows: raw.data_flows || [],
|
||||
protectionLevel: raw.protection_level || 'MEDIUM',
|
||||
dpiaRequired: raw.dpia_required || false,
|
||||
structuredToms: raw.structured_toms || { accessControl: [], confidentiality: [], integrity: [], availability: [], separation: [] },
|
||||
status: raw.status || 'DRAFT',
|
||||
responsible: raw.responsible || '',
|
||||
owner: raw.owner || '',
|
||||
createdAt: raw.created_at || new Date().toISOString(),
|
||||
updatedAt: raw.updated_at || raw.created_at || new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export function activityToApi(act: VVTActivity): Record<string, unknown> {
|
||||
return {
|
||||
vvt_id: act.vvtId,
|
||||
name: act.name,
|
||||
description: act.description,
|
||||
purposes: act.purposes,
|
||||
legal_bases: act.legalBases,
|
||||
data_subject_categories: act.dataSubjectCategories,
|
||||
personal_data_categories: act.personalDataCategories,
|
||||
recipient_categories: act.recipientCategories,
|
||||
third_country_transfers: act.thirdCountryTransfers,
|
||||
retention_period: act.retentionPeriod,
|
||||
tom_description: act.tomDescription,
|
||||
business_function: act.businessFunction,
|
||||
systems: act.systems,
|
||||
deployment_model: act.deploymentModel,
|
||||
data_sources: act.dataSources,
|
||||
data_flows: act.dataFlows,
|
||||
protection_level: act.protectionLevel,
|
||||
dpia_required: act.dpiaRequired,
|
||||
structured_toms: act.structuredToms,
|
||||
status: act.status,
|
||||
responsible: act.responsible,
|
||||
owner: act.owner,
|
||||
}
|
||||
}
|
||||
|
||||
export function orgHeaderFromApi(raw: any): VVTOrganizationHeader {
|
||||
return {
|
||||
organizationName: raw.organization_name || '',
|
||||
industry: raw.industry || '',
|
||||
locations: raw.locations || [],
|
||||
employeeCount: raw.employee_count || 0,
|
||||
dpoName: raw.dpo_name || '',
|
||||
dpoContact: raw.dpo_contact || '',
|
||||
vvtVersion: raw.vvt_version || '1.0',
|
||||
lastReviewDate: raw.last_review_date || '',
|
||||
nextReviewDate: raw.next_review_date || '',
|
||||
reviewInterval: raw.review_interval || 'annual',
|
||||
}
|
||||
}
|
||||
|
||||
export function orgHeaderToApi(org: VVTOrganizationHeader): Record<string, unknown> {
|
||||
return {
|
||||
organization_name: org.organizationName,
|
||||
industry: org.industry,
|
||||
locations: org.locations,
|
||||
employee_count: org.employeeCount,
|
||||
dpo_name: org.dpoName,
|
||||
dpo_contact: org.dpoContact,
|
||||
vvt_version: org.vvtVersion,
|
||||
last_review_date: org.lastReviewDate || null,
|
||||
next_review_date: org.nextReviewDate || null,
|
||||
review_interval: org.reviewInterval,
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiListActivities(): Promise<VVTActivity[]> {
|
||||
const res = await fetch(`${VVT_API_BASE}/activities`)
|
||||
if (!res.ok) throw new Error(`GET activities failed: ${res.status}`)
|
||||
const data = await res.json()
|
||||
return data.map(activityFromApi)
|
||||
}
|
||||
|
||||
export async function apiGetOrganization(): Promise<VVTOrganizationHeader | null> {
|
||||
const res = await fetch(`${VVT_API_BASE}/organization`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
if (!data) return null
|
||||
return orgHeaderFromApi(data)
|
||||
}
|
||||
|
||||
export async function apiCreateActivity(act: VVTActivity): Promise<VVTActivity> {
|
||||
const res = await fetch(`${VVT_API_BASE}/activities`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(activityToApi(act)),
|
||||
})
|
||||
if (!res.ok) throw new Error(`POST activity failed: ${res.status}`)
|
||||
return activityFromApi(await res.json())
|
||||
}
|
||||
|
||||
export async function apiUpdateActivity(id: string, act: VVTActivity): Promise<VVTActivity> {
|
||||
const res = await fetch(`${VVT_API_BASE}/activities/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(activityToApi(act)),
|
||||
})
|
||||
if (!res.ok) throw new Error(`PUT activity failed: ${res.status}`)
|
||||
return activityFromApi(await res.json())
|
||||
}
|
||||
|
||||
export async function apiDeleteActivity(id: string): Promise<void> {
|
||||
const res = await fetch(`${VVT_API_BASE}/activities/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(`DELETE activity failed: ${res.status}`)
|
||||
}
|
||||
|
||||
export async function apiUpsertOrganization(org: VVTOrganizationHeader): Promise<VVTOrganizationHeader> {
|
||||
const res = await fetch(`${VVT_API_BASE}/organization`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(orgHeaderToApi(org)),
|
||||
})
|
||||
if (!res.ok) throw new Error(`PUT organization failed: ${res.status}`)
|
||||
return orgHeaderFromApi(await res.json())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user