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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user