refactor(admin): split company-profile page.tsx (3017 LOC) into colocated components
Extract the monolithic company-profile wizard into _components/ and _hooks/ following Next.js 15 conventions from AGENTS.typescript.md: - _components/constants.ts: wizard steps, legal forms, industries, certifications - _components/types.ts: local interfaces (ProcessingActivity, AISystem, etc.) - _components/activity-data.ts: DSGVO data categories, department/activity templates - _components/ai-system-data.ts: AI system template catalog - _components/StepBasicInfo.tsx: step 1 (company name, legal form, industry) - _components/StepBusinessModel.tsx: step 2 (B2B/B2C, offerings) - _components/StepCompanySize.tsx: step 3 (size, revenue) - _components/StepLocations.tsx: step 4 (headquarters, target markets) - _components/StepDataProtection.tsx: step 5 (DSGVO roles, DPO) - _components/StepProcessing.tsx: processing activities with category checkboxes - _components/StepAISystems.tsx: AI system inventory - _components/StepLegalFramework.tsx: certifications and contacts - _components/StepMachineBuilder.tsx: machine builder profile (step 7) - _components/ProfileSummary.tsx: completion summary view - _hooks/useCompanyProfileForm.ts: form state, auto-save, navigation logic - page.tsx: thin orchestrator (160 LOC), imports and composes sections All 16 files are under 500 LOC (largest: StepProcessing at 343). Build verified: npx next build passes cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CompanyProfile } from '@/lib/sdk/types'
|
||||
import { ProcessingActivity, ActivityTemplate, ActivityDepartment } from './types'
|
||||
import { ALL_DATA_CATEGORIES, ALL_SPECIAL_CATEGORIES, getRelevantDepartments } from './activity-data'
|
||||
|
||||
function CategoryCheckbox({
|
||||
cat,
|
||||
activity,
|
||||
variant,
|
||||
template,
|
||||
expandedInfoCat,
|
||||
onToggleCategory,
|
||||
onToggleInfo,
|
||||
}: {
|
||||
cat: { id: string; label: string; desc: string; info: string }
|
||||
activity: ProcessingActivity
|
||||
variant: 'normal' | 'extra' | 'art9' | 'art9-extra'
|
||||
template?: ActivityTemplate | null
|
||||
expandedInfoCat: string | null
|
||||
onToggleCategory: (activityId: string, categoryId: string) => void
|
||||
onToggleInfo: (key: string | null) => void
|
||||
}) {
|
||||
const infoText = template?.categoryInfo?.[cat.id] || cat.info
|
||||
const isInfoExpanded = expandedInfoCat === `${activity.id}-${cat.id}`
|
||||
const colorClasses = variant.startsWith('art9')
|
||||
? { check: 'text-red-600 focus:ring-red-500', hover: 'hover:bg-red-100', text: variant === 'art9-extra' ? 'text-gray-500' : 'text-gray-700' }
|
||||
: { check: 'text-purple-600 focus:ring-purple-500', hover: 'hover:bg-gray-100', text: variant === 'extra' ? 'text-gray-500' : 'text-gray-700' }
|
||||
|
||||
const aufbewahrungIdx = infoText.indexOf('Aufbewahrung:')
|
||||
const loeschfristIdx = infoText.indexOf('L\u00F6schfrist')
|
||||
const speicherdauerIdx = infoText.indexOf('Speicherdauer:')
|
||||
const retentionIdx = [aufbewahrungIdx, loeschfristIdx, speicherdauerIdx].filter(i => i >= 0).sort((a, b) => a - b)[0] ?? -1
|
||||
const hasRetention = retentionIdx >= 0
|
||||
const mainText = hasRetention ? infoText.slice(0, retentionIdx).replace(/\.\s*$/, '') : infoText
|
||||
const retentionText = hasRetention ? infoText.slice(retentionIdx) : ''
|
||||
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
<label className={`flex items-center gap-2 text-xs p-1.5 rounded ${colorClasses.hover} cursor-pointer`}>
|
||||
<input type="checkbox" checked={activity.data_categories.includes(cat.id)} onChange={() => onToggleCategory(activity.id, cat.id)} className={`w-3.5 h-3.5 ${colorClasses.check} rounded`} />
|
||||
<span className={colorClasses.text}>{cat.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.preventDefault(); e.stopPropagation(); onToggleInfo(isInfoExpanded ? null : `${activity.id}-${cat.id}`) }}
|
||||
className="ml-auto w-4 h-4 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 text-gray-500 text-[10px] font-bold flex-shrink-0"
|
||||
title={infoText}
|
||||
>
|
||||
i
|
||||
</button>
|
||||
</label>
|
||||
{isInfoExpanded && (
|
||||
<div className="ml-7 mt-1 mb-1 px-2 py-1.5 bg-blue-50 border border-blue-100 rounded text-[11px] text-blue-800">
|
||||
{hasRetention ? (
|
||||
<>
|
||||
<span>{mainText}</span>
|
||||
<span className="block mt-1 px-1.5 py-0.5 bg-amber-50 border border-amber-200 rounded text-amber-800">
|
||||
<span className="mr-1">🕓</span>{retentionText}
|
||||
</span>
|
||||
</>
|
||||
) : infoText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityDetail({
|
||||
activity,
|
||||
template,
|
||||
showExtraCategories,
|
||||
expandedInfoCat,
|
||||
onToggleCategory,
|
||||
onToggleInfo,
|
||||
onToggleExtraCategories,
|
||||
onUpdateActivity,
|
||||
onRemoveActivity,
|
||||
}: {
|
||||
activity: ProcessingActivity
|
||||
template: ActivityTemplate | null
|
||||
showExtraCategories: Set<string>
|
||||
expandedInfoCat: string | null
|
||||
onToggleCategory: (activityId: string, categoryId: string) => void
|
||||
onToggleInfo: (key: string | null) => void
|
||||
onToggleExtraCategories: (activityId: string) => void
|
||||
onUpdateActivity: (id: string, updates: Partial<ProcessingActivity>) => void
|
||||
onRemoveActivity: (id: string) => void
|
||||
}) {
|
||||
const primaryIds = new Set(template?.primary_categories || [])
|
||||
const art9Ids = new Set(template?.art9_relevant || [])
|
||||
const primaryCats = ALL_DATA_CATEGORIES.filter(c => primaryIds.has(c.id))
|
||||
const extraCats = ALL_DATA_CATEGORIES.filter(c => !primaryIds.has(c.id))
|
||||
const relevantArt9 = ALL_SPECIAL_CATEGORIES.filter(c => art9Ids.has(c.id))
|
||||
const otherArt9 = ALL_SPECIAL_CATEGORIES.filter(c => !art9Ids.has(c.id))
|
||||
const showingExtra = showExtraCategories.has(activity.id)
|
||||
const isCustom = !template || activity.custom
|
||||
|
||||
return (
|
||||
<div className="ml-4 mt-2 p-4 bg-gray-50 rounded-lg border border-gray-200 space-y-4">
|
||||
{template?.legalHint && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<span className="text-amber-600 text-sm mt-0.5">⚠</span>
|
||||
<span className="text-xs text-amber-800">{template.legalHint}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCustom && (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<input type="text" value={activity.name} onChange={e => onUpdateActivity(activity.id, { name: e.target.value })} placeholder="Name der Verarbeitungst\u00E4tigkeit" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<input type="text" value={activity.purpose} onChange={e => onUpdateActivity(activity.id, { purpose: e.target.value })} placeholder="Zweck der Verarbeitung" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{template?.hasServiceProvider && (
|
||||
<div className="bg-blue-50 rounded-lg p-3 border border-blue-100 space-y-2">
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activity.usesServiceProvider || false}
|
||||
onChange={e => onUpdateActivity(activity.id, {
|
||||
usesServiceProvider: e.target.checked,
|
||||
...(!e.target.checked ? { serviceProviderName: '' } : {})
|
||||
})}
|
||||
className="w-3.5 h-3.5 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-blue-800 font-medium">Externer Dienstleister wird eingesetzt</span>
|
||||
</label>
|
||||
{activity.usesServiceProvider && (
|
||||
<div className="ml-6">
|
||||
<input
|
||||
type="text"
|
||||
value={activity.serviceProviderName || ''}
|
||||
onChange={e => onUpdateActivity(activity.id, { serviceProviderName: e.target.value })}
|
||||
placeholder="Name des Dienstleisters (optional)"
|
||||
className="w-full px-3 py-1.5 border border-blue-200 rounded text-xs focus:ring-2 focus:ring-blue-400 focus:border-transparent bg-white"
|
||||
/>
|
||||
<p className="text-[10px] text-blue-600 mt-1">Wird als Auftragsverarbeiter (AVV) im VVT erfasst.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">Betroffene Datenkategorien</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{(isCustom ? ALL_DATA_CATEGORIES : primaryCats).map(cat =>
|
||||
<CategoryCheckbox key={cat.id} cat={cat} activity={activity} variant="normal" template={template} expandedInfoCat={expandedInfoCat} onToggleCategory={onToggleCategory} onToggleInfo={onToggleInfo} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCustom && extraCats.length > 0 && (
|
||||
<div>
|
||||
<button type="button" onClick={() => onToggleExtraCategories(activity.id)} className="text-xs text-purple-600 hover:text-purple-800">
|
||||
{showingExtra ? '\u25BE Weitere Kategorien ausblenden' : `\u25B8 Weitere ${extraCats.length} Kategorien anzeigen`}
|
||||
</button>
|
||||
{showingExtra && (
|
||||
<div className="grid grid-cols-2 gap-1.5 mt-2">
|
||||
{extraCats.map(cat => <CategoryCheckbox key={cat.id} cat={cat} activity={activity} variant="extra" template={template} expandedInfoCat={expandedInfoCat} onToggleCategory={onToggleCategory} onToggleInfo={onToggleInfo} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isCustom ? ALL_SPECIAL_CATEGORIES.length > 0 : relevantArt9.length > 0) && (
|
||||
<div className="bg-red-50 rounded-lg p-3 border border-red-100">
|
||||
<label className="block text-xs font-medium text-red-700 mb-2">
|
||||
Besondere Kategorien (Art. 9 DSGVO)
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{(isCustom ? ALL_SPECIAL_CATEGORIES : relevantArt9).map(cat =>
|
||||
<CategoryCheckbox key={cat.id} cat={cat} activity={activity} variant="art9" template={template} expandedInfoCat={expandedInfoCat} onToggleCategory={onToggleCategory} onToggleInfo={onToggleInfo} />
|
||||
)}
|
||||
</div>
|
||||
{!isCustom && otherArt9.length > 0 && showingExtra && (
|
||||
<div className="grid grid-cols-2 gap-1.5 mt-2 pt-2 border-t border-red-100">
|
||||
{otherArt9.map(cat => <CategoryCheckbox key={cat.id} cat={cat} activity={activity} variant="art9-extra" template={template} expandedInfoCat={expandedInfoCat} onToggleCategory={onToggleCategory} onToggleInfo={onToggleInfo} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="button" onClick={() => onRemoveActivity(activity.id)} className="text-xs text-red-500 hover:text-red-700">
|
||||
Verarbeitungstätigkeit entfernen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepProcessing({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile> & { processingSystems?: ProcessingActivity[] }
|
||||
onChange: (updates: Record<string, unknown>) => void
|
||||
}) {
|
||||
const activities: ProcessingActivity[] = (data as any).processingSystems || []
|
||||
const industry = data.industry || []
|
||||
const [expandedActivity, setExpandedActivity] = useState<string | null>(null)
|
||||
const [collapsedDepts, setCollapsedDepts] = useState<Set<string>>(new Set())
|
||||
const [showExtraCats, setShowExtraCats] = useState<Set<string>>(new Set())
|
||||
const [expandedInfoCat, setExpandedInfoCat] = useState<string | null>(null)
|
||||
|
||||
const departments = getRelevantDepartments(industry, data.businessModel, data.companySize)
|
||||
const activeIds = new Set(activities.map(a => a.id))
|
||||
|
||||
const toggleActivity = (template: ActivityTemplate, deptId: string) => {
|
||||
if (activeIds.has(template.id)) {
|
||||
onChange({ processingSystems: activities.filter(a => a.id !== template.id) })
|
||||
} else {
|
||||
onChange({
|
||||
processingSystems: [...activities, {
|
||||
id: template.id, name: template.name, purpose: template.purpose,
|
||||
data_categories: [...template.primary_categories],
|
||||
legal_basis: template.default_legal_basis, department: deptId,
|
||||
}],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updateActivity = (id: string, updates: Partial<ProcessingActivity>) => {
|
||||
onChange({ processingSystems: activities.map(a => a.id === id ? { ...a, ...updates } : a) })
|
||||
}
|
||||
|
||||
const toggleDataCategory = (activityId: string, categoryId: string) => {
|
||||
const activity = activities.find(a => a.id === activityId)
|
||||
if (!activity) return
|
||||
const cats = activity.data_categories.includes(categoryId)
|
||||
? activity.data_categories.filter(c => c !== categoryId)
|
||||
: [...activity.data_categories, categoryId]
|
||||
updateActivity(activityId, { data_categories: cats })
|
||||
}
|
||||
|
||||
const toggleDeptCollapse = (deptId: string) => {
|
||||
setCollapsedDepts(prev => { const next = new Set(prev); if (next.has(deptId)) next.delete(deptId); else next.add(deptId); return next })
|
||||
}
|
||||
|
||||
const toggleExtraCategories = (activityId: string) => {
|
||||
setShowExtraCats(prev => { const next = new Set(prev); if (next.has(activityId)) next.delete(activityId); else next.add(activityId); return next })
|
||||
}
|
||||
|
||||
const addCustomActivity = () => {
|
||||
const id = `custom_${Date.now()}`
|
||||
onChange({ processingSystems: [...activities, { id, name: '', purpose: '', data_categories: [], legal_basis: 'contract', custom: true }] })
|
||||
setExpandedActivity(id)
|
||||
}
|
||||
|
||||
const removeActivity = (id: string) => {
|
||||
onChange({ processingSystems: activities.filter(a => a.id !== id) })
|
||||
if (expandedActivity === id) setExpandedActivity(null)
|
||||
}
|
||||
|
||||
const deptActivityCount = (dept: ActivityDepartment) => dept.activities.filter(a => activeIds.has(a.id)).length
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-1">Verarbeitungstätigkeiten</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Wählen Sie pro Abteilung aus, welche Verarbeitungen stattfinden. Diese bilden die Grundlage für Ihr Verarbeitungsverzeichnis (VVT) nach Art. 30 DSGVO.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{departments.map(dept => {
|
||||
const isCollapsed = collapsedDepts.has(dept.id)
|
||||
const activeCount = deptActivityCount(dept)
|
||||
|
||||
return (
|
||||
<div key={dept.id} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button type="button" onClick={() => toggleDeptCollapse(dept.id)} className="w-full flex items-center gap-3 px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-left">
|
||||
<span className="text-base">{dept.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900 flex-1">{dept.name}</span>
|
||||
{activeCount > 0 && <span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">{activeCount} aktiv</span>}
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isCollapsed ? '' : 'rotate-180'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="p-3 space-y-2">
|
||||
{dept.activities.map(template => {
|
||||
const isActive = activeIds.has(template.id)
|
||||
const activity = activities.find(a => a.id === template.id)
|
||||
const isExpanded = expandedActivity === template.id
|
||||
|
||||
return (
|
||||
<div key={template.id}>
|
||||
<div
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${isActive ? 'border-purple-500 bg-purple-50' : 'border-gray-100 hover:border-purple-300'}`}
|
||||
onClick={() => { if (!isActive) { toggleActivity(template, dept.id); setExpandedActivity(template.id) } else { setExpandedActivity(isExpanded ? null : template.id) } }}
|
||||
>
|
||||
<input type="checkbox" checked={isActive} onChange={e => { e.stopPropagation(); toggleActivity(template, dept.id) }} className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">{template.name}</span>
|
||||
{template.legalHint && <span className="text-[10px] bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-medium whitespace-nowrap">Pflicht</span>}
|
||||
{template.hasServiceProvider && <span className="text-[10px] bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded font-medium whitespace-nowrap">AVV-relevant</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate">{template.purpose}</p>
|
||||
</div>
|
||||
{isActive && <span className="text-xs text-purple-600 flex-shrink-0">{activity?.data_categories.length || 0} Kat.</span>}
|
||||
{isActive && (
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{isActive && isExpanded && activity && (
|
||||
<ActivityDetail activity={activity} template={template} showExtraCategories={showExtraCats} expandedInfoCat={expandedInfoCat} onToggleCategory={toggleDataCategory} onToggleInfo={setExpandedInfoCat} onToggleExtraCategories={toggleExtraCategories} onUpdateActivity={updateActivity} onRemoveActivity={removeActivity} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activities.filter(a => a.custom).map(activity => (
|
||||
<div key={activity.id} className="mt-2">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border-2 border-purple-500 bg-purple-50 cursor-pointer" onClick={() => setExpandedActivity(expandedActivity === activity.id ? null : activity.id)}>
|
||||
<span className="w-4 h-4 flex items-center justify-center text-purple-600 flex-shrink-0 text-sm">+</span>
|
||||
<div className="flex-1 min-w-0"><span className="text-sm font-medium text-gray-900">{activity.name || 'Neue Verarbeitungst\u00E4tigkeit'}</span></div>
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${expandedActivity === activity.id ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{expandedActivity === activity.id && (
|
||||
<ActivityDetail activity={activity} template={null} showExtraCategories={showExtraCats} expandedInfoCat={expandedInfoCat} onToggleCategory={toggleDataCategory} onToggleInfo={setExpandedInfoCat} onToggleExtraCategories={toggleExtraCategories} onUpdateActivity={updateActivity} onRemoveActivity={removeActivity} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="button" onClick={addCustomActivity} className="w-full mt-3 px-3 py-2 text-sm text-purple-700 bg-purple-50 border-2 border-dashed border-purple-200 rounded-lg hover:bg-purple-100 hover:border-purple-300 transition-colors">
|
||||
+ Eigene Verarbeitungstätigkeit hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user