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>
306 lines
14 KiB
TypeScript
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,
|
|
}
|
|
}
|