Files
breakpilot-compliance/admin-compliance/app/sdk/company-profile/_components/StepProcessing.tsx
Sharang Parnerkar f7b77fd504 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>
2026-04-11 18:50:30 +02:00

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">&#128339;</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">&#9888;</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&auml;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&auml;tigkeiten</h3>
<p className="text-xs text-gray-500 mb-4">
W&auml;hlen Sie pro Abteilung aus, welche Verarbeitungen stattfinden. Diese bilden die Grundlage f&uuml;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&auml;tigkeit hinzuf&uuml;gen
</button>
</div>
</div>
)
}