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>
237 lines
8.1 KiB
TypeScript
237 lines
8.1 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* VVT — Verarbeitungsverzeichnis (Art. 30 DSGVO)
|
|
*
|
|
* 3 Tabs:
|
|
* 1. Verzeichnis (Uebersicht + "Aus Scope generieren")
|
|
* 2. Verarbeitung bearbeiten (Detail-Editor)
|
|
* 3. Export & Compliance
|
|
*/
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { useSDK } from '@/lib/sdk'
|
|
import StepHeader, { STEP_EXPLANATIONS } from '@/components/sdk/StepHeader/StepHeader'
|
|
import {
|
|
ART9_CATEGORIES,
|
|
createEmptyActivity,
|
|
createDefaultOrgHeader,
|
|
generateVVTId,
|
|
} from '@/lib/sdk/vvt-types'
|
|
import type { VVTActivity, VVTOrganizationHeader } from '@/lib/sdk/vvt-types'
|
|
import {
|
|
apiListActivities,
|
|
apiGetOrganization,
|
|
apiCreateActivity,
|
|
apiUpdateActivity,
|
|
apiDeleteActivity,
|
|
apiUpsertOrganization,
|
|
} from './_components/api'
|
|
import { TabVerzeichnis } from './_components/TabVerzeichnis'
|
|
import { TabEditor } from './_components/TabEditor'
|
|
import { TabExport } from './_components/TabExport'
|
|
|
|
type Tab = 'verzeichnis' | 'editor' | 'export'
|
|
|
|
export default function VVTPage() {
|
|
const { state } = useSDK()
|
|
const [tab, setTab] = useState<Tab>('verzeichnis')
|
|
const [activities, setActivities] = useState<VVTActivity[]>([])
|
|
const [orgHeader, setOrgHeader] = useState<VVTOrganizationHeader>(createDefaultOrgHeader())
|
|
const [editingId, setEditingId] = useState<string | null>(null)
|
|
const [filter, setFilter] = useState('all')
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [sortBy, setSortBy] = useState<'name' | 'date' | 'status'>('name')
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [apiError, setApiError] = useState<string | null>(null)
|
|
|
|
// Load activities + org header from API
|
|
useEffect(() => {
|
|
async function loadFromApi() {
|
|
setIsLoading(true)
|
|
setApiError(null)
|
|
try {
|
|
const [acts, org] = await Promise.all([
|
|
apiListActivities(),
|
|
apiGetOrganization(),
|
|
])
|
|
setActivities(acts)
|
|
if (org) setOrgHeader(org)
|
|
} catch (err) {
|
|
setApiError('Fehler beim Laden der VVT-Daten. Bitte Verbindung prüfen.')
|
|
console.error('VVT API load error:', err)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
loadFromApi()
|
|
}, [])
|
|
|
|
// Computed stats
|
|
const activeCount = activities.filter(a => a.status === 'APPROVED').length
|
|
const draftCount = activities.filter(a => a.status === 'DRAFT').length
|
|
const thirdCountryCount = activities.filter(a => a.thirdCountryTransfers.length > 0).length
|
|
const art9Count = activities.filter(a => a.personalDataCategories.some(c => ART9_CATEGORIES.includes(c))).length
|
|
|
|
// Filtered & sorted activities
|
|
const filteredActivities = activities
|
|
.filter(a => {
|
|
const matchesFilter = filter === 'all' || a.status === filter || (filter === 'thirdcountry' && a.thirdCountryTransfers.length > 0) || (filter === 'art9' && a.personalDataCategories.some(c => ART9_CATEGORIES.includes(c)))
|
|
const matchesSearch = searchQuery === '' ||
|
|
a.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
a.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
a.vvtId.toLowerCase().includes(searchQuery.toLowerCase())
|
|
return matchesFilter && matchesSearch
|
|
})
|
|
.sort((a, b) => {
|
|
if (sortBy === 'name') return a.name.localeCompare(b.name)
|
|
if (sortBy === 'date') return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
return a.status.localeCompare(b.status)
|
|
})
|
|
|
|
const editingActivity = editingId ? activities.find(a => a.id === editingId) : null
|
|
|
|
const stepInfo = STEP_EXPLANATIONS['vvt']
|
|
|
|
// Tab buttons
|
|
const tabs: { id: Tab; label: string; count?: number }[] = [
|
|
{ id: 'verzeichnis', label: 'Verzeichnis', count: activities.length },
|
|
{ id: 'editor', label: 'Verarbeitung bearbeiten' },
|
|
{ id: 'export', label: 'Export & Compliance' },
|
|
]
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<StepHeader
|
|
stepId="vvt"
|
|
title={stepInfo.title}
|
|
description={stepInfo.description}
|
|
explanation={stepInfo.explanation}
|
|
tips={stepInfo.tips}
|
|
/>
|
|
|
|
{apiError && (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">
|
|
{apiError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tab Navigation */}
|
|
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg">
|
|
{tabs.map(t => (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => setTab(t.id)}
|
|
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
|
tab === t.id ? 'bg-white text-purple-700 shadow-sm' : 'text-gray-600 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
{t.label}
|
|
{t.count !== undefined && (
|
|
<span className={`px-1.5 py-0.5 text-xs rounded-full ${tab === t.id ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-gray-500'}`}>
|
|
{t.count}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
{tab === 'verzeichnis' && (
|
|
<TabVerzeichnis
|
|
activities={filteredActivities}
|
|
allActivities={activities}
|
|
activeCount={activeCount}
|
|
draftCount={draftCount}
|
|
thirdCountryCount={thirdCountryCount}
|
|
art9Count={art9Count}
|
|
filter={filter}
|
|
setFilter={setFilter}
|
|
searchQuery={searchQuery}
|
|
setSearchQuery={setSearchQuery}
|
|
sortBy={sortBy}
|
|
setSortBy={setSortBy}
|
|
scopeAnswers={state.complianceScope?.answers}
|
|
onEdit={(id) => { setEditingId(id); setTab('editor') }}
|
|
onNew={async () => {
|
|
const vvtId = generateVVTId(activities.map(a => a.vvtId))
|
|
const newAct = createEmptyActivity(vvtId)
|
|
try {
|
|
const created = await apiCreateActivity(newAct)
|
|
setActivities(prev => [...prev, created])
|
|
setEditingId(created.id)
|
|
setTab('editor')
|
|
} catch (err) {
|
|
setApiError('Fehler beim Anlegen der Verarbeitung.')
|
|
console.error(err)
|
|
}
|
|
}}
|
|
onDelete={async (id) => {
|
|
try {
|
|
await apiDeleteActivity(id)
|
|
setActivities(prev => prev.filter(a => a.id !== id))
|
|
} catch (err) {
|
|
setApiError('Fehler beim Löschen der Verarbeitung.')
|
|
console.error(err)
|
|
}
|
|
}}
|
|
onAdoptGenerated={async (newActivities) => {
|
|
const created: VVTActivity[] = []
|
|
for (const act of newActivities) {
|
|
try {
|
|
const saved = await apiCreateActivity(act)
|
|
created.push(saved)
|
|
} catch (err) {
|
|
console.error('Failed to create activity from scope:', err)
|
|
}
|
|
}
|
|
if (created.length > 0) setActivities(prev => [...prev, ...created])
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{tab === 'editor' && (
|
|
<TabEditor
|
|
activity={editingActivity}
|
|
activities={activities}
|
|
onSave={async (updated) => {
|
|
try {
|
|
const saved = await apiUpdateActivity(updated.id, updated)
|
|
setActivities(prev => prev.map(a => a.id === saved.id ? saved : a))
|
|
} catch (err) {
|
|
setApiError('Fehler beim Speichern der Verarbeitung.')
|
|
console.error(err)
|
|
}
|
|
}}
|
|
onBack={() => setTab('verzeichnis')}
|
|
onSelectActivity={(id) => setEditingId(id)}
|
|
/>
|
|
)}
|
|
|
|
{tab === 'export' && (
|
|
<TabExport
|
|
activities={activities}
|
|
orgHeader={orgHeader}
|
|
onUpdateOrgHeader={async (org) => {
|
|
try {
|
|
const saved = await apiUpsertOrganization(org)
|
|
setOrgHeader(saved)
|
|
} catch (err) {
|
|
setApiError('Fehler beim Speichern der Organisationsdaten.')
|
|
console.error(err)
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|