Files
breakpilot-compliance/admin-compliance/app/sdk/company-profile/_hooks/useCompanyProfileForm.ts
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

306 lines
14 KiB
TypeScript

'use client'
import { useState, useEffect, useRef } from 'react'
import React from 'react'
import { CompanyProfile } from '@/lib/sdk/types'
import { useSDK } from '@/lib/sdk'
import { isMachineBuilderIndustry, getWizardSteps } from '../_components/constants'
const INITIAL_FORM_DATA: Partial<CompanyProfile> = {
companyName: '', legalForm: undefined, industry: [], industryOther: '', foundedYear: null,
businessModel: undefined, offerings: [], offeringUrls: {},
companySize: undefined, employeeCount: '', annualRevenue: '',
headquartersCountry: 'DE', headquartersCountryOther: '', headquartersStreet: '',
headquartersZip: '', headquartersCity: '', headquartersState: '',
hasInternationalLocations: false, internationalCountries: [], targetMarkets: [],
primaryJurisdiction: 'DE', isDataController: true, isDataProcessor: false,
dpoName: null, dpoEmail: null, legalContactName: null, legalContactEmail: null,
isComplete: false, completedAt: null,
}
function buildProfilePayload(formData: Partial<CompanyProfile>, projectId: string | null, isComplete: boolean) {
return {
project_id: projectId || null,
company_name: formData.companyName || '',
legal_form: formData.legalForm || 'GmbH',
industry: formData.industry || [],
industry_other: formData.industryOther || '',
founded_year: formData.foundedYear || null,
business_model: formData.businessModel || 'B2B',
offerings: formData.offerings || [],
offering_urls: formData.offeringUrls || {},
company_size: formData.companySize || 'small',
employee_count: formData.employeeCount || '',
annual_revenue: formData.annualRevenue || '',
headquarters_country: formData.headquartersCountry || 'DE',
headquarters_country_other: formData.headquartersCountryOther || '',
headquarters_street: formData.headquartersStreet || '',
headquarters_zip: formData.headquartersZip || '',
headquarters_city: formData.headquartersCity || '',
headquarters_state: formData.headquartersState || '',
has_international_locations: formData.hasInternationalLocations || false,
international_countries: formData.internationalCountries || [],
target_markets: formData.targetMarkets || [],
primary_jurisdiction: formData.primaryJurisdiction || 'DE',
is_data_controller: formData.isDataController ?? true,
is_data_processor: formData.isDataProcessor ?? false,
dpo_name: formData.dpoName || '',
dpo_email: formData.dpoEmail || '',
is_complete: isComplete,
processing_systems: (formData as any).processingSystems || [],
ai_systems: (formData as any).aiSystems || [],
technical_contacts: (formData as any).technicalContacts || [],
existing_certifications: (formData as any).existingCertifications || [],
target_certifications: (formData as any).targetCertifications || [],
target_certification_other: (formData as any).targetCertificationOther || '',
review_cycle_months: (formData as any).reviewCycleMonths || 12,
repos: (formData as any).repos || [],
document_sources: (formData as any).documentSources || [],
...(formData.machineBuilder ? {
machine_builder: {
product_types: formData.machineBuilder.productTypes || [],
product_description: formData.machineBuilder.productDescription || '',
product_pride: formData.machineBuilder.productPride || '',
contains_software: formData.machineBuilder.containsSoftware || false,
contains_firmware: formData.machineBuilder.containsFirmware || false,
contains_ai: formData.machineBuilder.containsAI || false,
ai_integration_type: formData.machineBuilder.aiIntegrationType || [],
has_safety_function: formData.machineBuilder.hasSafetyFunction || false,
safety_function_description: formData.machineBuilder.safetyFunctionDescription || '',
autonomous_behavior: formData.machineBuilder.autonomousBehavior || false,
human_oversight_level: formData.machineBuilder.humanOversightLevel || 'full',
is_networked: formData.machineBuilder.isNetworked || false,
has_remote_access: formData.machineBuilder.hasRemoteAccess || false,
has_ota_updates: formData.machineBuilder.hasOTAUpdates || false,
update_mechanism: formData.machineBuilder.updateMechanism || '',
export_markets: formData.machineBuilder.exportMarkets || [],
critical_sector_clients: formData.machineBuilder.criticalSectorClients || false,
critical_sectors: formData.machineBuilder.criticalSectors || [],
oem_clients: formData.machineBuilder.oemClients || false,
ce_marking_required: formData.machineBuilder.ceMarkingRequired || false,
existing_ce_process: formData.machineBuilder.existingCEProcess || false,
has_risk_assessment: formData.machineBuilder.hasRiskAssessment || false,
},
} : {}),
}
}
export function useCompanyProfileForm() {
const { state, dispatch, setCompanyProfile, goToNextStep, projectId } = useSDK()
const [currentStep, setCurrentStep] = useState(1)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [formData, setFormData] = useState<Partial<CompanyProfile>>(INITIAL_FORM_DATA)
const [draftSaveStatus, setDraftSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const showMachineBuilderStep = isMachineBuilderIndustry(formData.industry || [])
const wizardSteps = getWizardSteps(formData.industry || [])
const lastStep = wizardSteps[wizardSteps.length - 1].id
const profileApiUrl = (extra?: string) => {
const params = new URLSearchParams()
if (projectId) params.set('project_id', projectId)
const qs = params.toString()
const base = '/api/sdk/v1/company-profile' + (extra || '')
return qs ? `${base}?${qs}` : base
}
// Load existing profile
useEffect(() => {
let cancelled = false
async function loadFromBackend() {
try {
const apiUrl = '/api/sdk/v1/company-profile' + (projectId ? `?project_id=${encodeURIComponent(projectId)}` : '')
const response = await fetch(apiUrl)
if (response.ok) {
const data = await response.json()
if (data && !cancelled) {
const backendProfile: Partial<CompanyProfile> = {
companyName: data.company_name || '', legalForm: data.legal_form || undefined,
industry: Array.isArray(data.industry) ? data.industry : (data.industry ? [data.industry] : []),
industryOther: data.industry_other || '', foundedYear: data.founded_year || undefined,
businessModel: data.business_model || undefined, offerings: data.offerings || [],
offeringUrls: data.offering_urls || {}, companySize: data.company_size || undefined,
employeeCount: data.employee_count || '', annualRevenue: data.annual_revenue || '',
headquartersCountry: data.headquarters_country || 'DE',
headquartersCountryOther: data.headquarters_country_other || '',
headquartersStreet: data.headquarters_street || '',
headquartersZip: data.headquarters_zip || '', headquartersCity: data.headquarters_city || '',
headquartersState: data.headquarters_state || '',
hasInternationalLocations: data.has_international_locations || false,
internationalCountries: data.international_countries || [],
targetMarkets: data.target_markets || [], primaryJurisdiction: data.primary_jurisdiction || 'DE',
isDataController: data.is_data_controller ?? true, isDataProcessor: data.is_data_processor ?? false,
dpoName: data.dpo_name || '', dpoEmail: data.dpo_email || '',
isComplete: data.is_complete || false,
processingSystems: data.processing_systems || [], aiSystems: data.ai_systems || [],
technicalContacts: data.technical_contacts || [],
existingCertifications: data.existing_certifications || [],
targetCertifications: data.target_certifications || [],
targetCertificationOther: data.target_certification_other || '',
reviewCycleMonths: data.review_cycle_months || 12,
repos: data.repos || [], documentSources: data.document_sources || [],
} as any
setFormData(backendProfile)
setCompanyProfile(backendProfile as CompanyProfile)
if (backendProfile.isComplete) {
setCurrentStep(99)
dispatch({ type: 'COMPLETE_STEP', payload: 'company-profile' })
}
return
}
}
} catch { /* Backend not available, fall through to SDK state */ }
if (!cancelled && state.companyProfile) {
setFormData(state.companyProfile)
if (state.companyProfile.isComplete) setCurrentStep(99)
}
}
loadFromBackend()
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId])
const updateFormData = (updates: Partial<CompanyProfile>) => {
setFormData(prev => ({ ...prev, ...updates }))
}
// Auto-save to SDK context (debounced)
const autoSaveRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const initialLoadDone = useRef(false)
useEffect(() => {
if (!initialLoadDone.current) {
if (formData.companyName !== undefined) initialLoadDone.current = true
return
}
if (currentStep === 99) return
if (autoSaveRef.current) clearTimeout(autoSaveRef.current)
autoSaveRef.current = setTimeout(() => {
const hasData = formData.companyName || (formData.industry && formData.industry.length > 0)
if (hasData) setCompanyProfile({ ...formData, isComplete: false, completedAt: null } as CompanyProfile)
}, 500)
return () => { if (autoSaveRef.current) clearTimeout(autoSaveRef.current) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formData, currentStep])
// Auto-save draft to backend (debounced, 2s)
const backendAutoSaveRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const draftSaveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (!initialLoadDone.current) return
if (currentStep === 99) return
const hasData = formData.companyName || (formData.industry && formData.industry.length > 0)
if (!hasData) return
if (backendAutoSaveRef.current) clearTimeout(backendAutoSaveRef.current)
backendAutoSaveRef.current = setTimeout(async () => {
try {
await fetch(profileApiUrl(), {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildProfilePayload(formData, projectId, false)),
})
setDraftSaveStatus('saved')
if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current)
draftSaveTimerRef.current = setTimeout(() => setDraftSaveStatus('idle'), 3000)
} catch { /* Silent fail for auto-save */ }
}, 2000)
return () => { if (backendAutoSaveRef.current) clearTimeout(backendAutoSaveRef.current) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formData, currentStep])
const saveProfileDraft = async () => {
setDraftSaveStatus('saving')
try {
await fetch(profileApiUrl(), {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildProfilePayload(formData, projectId, false)),
})
setCompanyProfile({ ...formData, isComplete: false, completedAt: null } as CompanyProfile)
setDraftSaveStatus('saved')
if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current)
draftSaveTimerRef.current = setTimeout(() => setDraftSaveStatus('idle'), 3000)
} catch (err) {
console.error('Draft save failed:', err)
setDraftSaveStatus('error')
if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current)
draftSaveTimerRef.current = setTimeout(() => setDraftSaveStatus('idle'), 5000)
}
}
const completeAndSaveProfile = async () => {
if (autoSaveRef.current) clearTimeout(autoSaveRef.current)
if (backendAutoSaveRef.current) clearTimeout(backendAutoSaveRef.current)
const completeProfile: CompanyProfile = { ...formData, isComplete: true, completedAt: new Date() } as CompanyProfile
try {
await fetch(profileApiUrl(), {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildProfilePayload(formData, projectId, true)),
})
} catch (err) { console.error('Failed to save company profile to backend:', err) }
setCompanyProfile(completeProfile)
dispatch({ type: 'COMPLETE_STEP', payload: 'company-profile' })
dispatch({ type: 'SET_STATE', payload: { projectVersion: (state.projectVersion || 0) + 1 } })
setCurrentStep(99)
}
const handleNext = () => {
if (currentStep < lastStep) {
const nextStep = currentStep + 1
if (nextStep === 7 && !showMachineBuilderStep) {
completeAndSaveProfile()
return
}
saveProfileDraft()
setCurrentStep(nextStep)
} else {
completeAndSaveProfile()
}
}
const handleBack = () => {
if (currentStep > 1) { saveProfileDraft(); setCurrentStep(prev => prev - 1) }
}
const handleDeleteProfile = async () => {
setIsDeleting(true)
try {
const response = await fetch(profileApiUrl(), { method: 'DELETE' })
if (response.ok) {
setFormData(INITIAL_FORM_DATA)
setCurrentStep(1)
dispatch({ type: 'SET_STATE', payload: { companyProfile: undefined } })
}
} catch (err) { console.error('Failed to delete company profile:', err) }
finally { setIsDeleting(false); setShowDeleteConfirm(false) }
}
const canProceed = () => {
switch (currentStep) {
case 1: return formData.companyName && formData.legalForm
case 2: return formData.businessModel && (formData.offerings?.length || 0) > 0
case 3: return formData.companySize
case 4: return formData.headquartersCountry && (formData.targetMarkets?.length || 0) > 0
case 5: return true
case 6: return true
case 7: return (formData.machineBuilder?.productDescription?.length || 0) > 0
default: return false
}
}
const isLastStep = currentStep === lastStep || (currentStep === 6 && !showMachineBuilderStep)
return {
formData, updateFormData, currentStep, setCurrentStep,
wizardSteps, showMachineBuilderStep, isLastStep,
draftSaveStatus, canProceed, handleNext, handleBack,
handleDeleteProfile, showDeleteConfirm, setShowDeleteConfirm,
isDeleting, goToNextStep,
}
}