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>
344 lines
18 KiB
TypeScript
344 lines
18 KiB
TypeScript
'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>
|
|
)
|
|
}
|