Files
breakpilot-compliance/admin-compliance/app/sdk/founding-wizard/_hooks/useFoundingWizardForm.ts
T
Benjamin Admin 7a5f1e48dd feat(founding-wizard): Gründungs-Wizard für 2-Mann GmbH + 14 Notar-Templates
[migration-approved]

Templates (Migrations 123-136):
- 123 GO-GF (Geschäftsordnung Geschäftsführung)
- 124 SHA (Shareholders' Agreement, 56 Platzhalter)
- 125 Satzung (Articles of Association mit UG-Variante)
- 126 GF-Dienstvertrag (Trennungsprinzip Organ/Anstellung)
- 127 Arbeitsvertrag (AGG-neutral, NachwG, eAU)
- 128 Gesellschafterliste (§ 40 GmbHG)
- 129 GF-Bestellungsbeschluss (mit § 6 Abs. 2 Versicherung)
- 130 HRB-Anmeldung (§§ 7, 8, 39 GmbHG, § 12 HGB)
- 131 IP-Assignment Agreement (Gründer→GmbH)
- 132 Term Sheet (Pre-Seed/Seed VC-Standard)
- 133 Wandeldarlehensvertrag (Convertible Loan)
- 134 Beteiligungsvertrag (Subscription Agreement)
- 135 ESOP/VSOP-Plan (3 Varianten)
- 136 Cap Table

Kategorisierung (Migrations 137-138):
- ALTER TABLE compliance_legal_templates ADD lifecycle_stage TEXT[],
  functional_category TEXT (mit CHECK Constraints + GIN-Index)
- Backfill aller 105 Templates: lifecycle_stage (pre_founding|founding|
  startup|kmu|konzern) + functional_category (founding_legal|employment|
  investor_funding|...)

Backend Founding-Wizard Service:
- template_renderer.py: Handlebars-light ({{VAR}}, {{#IF FLAG}}...{{/IF}})
- wizard_to_context.py: Mapping Wizard-State → SCREAMING_SNAKE_CASE Vars
- markdown_to_docx.py: Markdown → DOCX via python-docx
- founding_wizard_routes.py: POST /v1/founding-wizard/generate
  → liefert base64-DOCX-Files für ausgewählte Templates

Frontend Founding-Wizard (/sdk/founding-wizard):
- 8-Step Wizard (Basics, Gesellschafter, GF, Kapital, Notar, SHA, GF-Verträge, Generate)
- useFoundingWizardForm Hook mit localStorage-Persistenz
- TypeScript Code-Registry (template-categories.ts) als Backup zur DB
- Word-Download via data:URLs (base64)

Tests:
- 20 Unit-Tests grün (Renderer, Context-Mapping, DOCX-Conversion)
- Playwright E2E-Test mit 2-Mann GmbH (Benjamin + Sharang) Test-Daten
2026-05-20 09:30:51 +02:00

188 lines
6.3 KiB
TypeScript

'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
defaultFoundingWizardState,
type FoundingWizardState,
type Gesellschafter,
type GFContract,
type GeneratedDocument,
} from '@/lib/sdk/founding/types'
const STORAGE_KEY = 'breakpilot:founding-wizard:state:v1'
export const FOUNDING_WIZARD_STEPS = [
{ id: 1, name: 'Stage & Basics', description: 'Unternehmensname, Sitz, Gegenstand' },
{ id: 2, name: 'Gesellschafter', description: 'Gründer und ihre Anteile' },
{ id: 3, name: 'Geschäftsführer', description: 'GF-Bestellung und Rollen' },
{ id: 4, name: 'Kapital', description: 'Stammkapital und Einzahlung' },
{ id: 5, name: 'Notar', description: 'Notartermin und Beurkundung' },
{ id: 6, name: 'SHA-Optionen', description: 'Vesting, Drag-Along, Reserved Matters' },
{ id: 7, name: 'GF-Verträge', description: 'Vergütung, D&O, Kündigungsfristen' },
{ id: 8, name: 'Dokumente generieren', description: 'Auswahl und Word-Export' },
]
export function useFoundingWizardForm() {
const [state, setState] = useState<FoundingWizardState>(defaultFoundingWizardState())
const [hydrated, setHydrated] = useState(false)
const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
// Hydrate from localStorage
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
setState({ ...defaultFoundingWizardState(), ...parsed })
}
} catch {
// ignore corrupted storage
}
setHydrated(true)
}, [])
// Persist on every change after hydration
useEffect(() => {
if (!hydrated) return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
} catch {
// quota exceeded - ignore
}
}, [state, hydrated])
const update = useCallback(<K extends keyof FoundingWizardState>(
key: K,
value: FoundingWizardState[K] | ((prev: FoundingWizardState[K]) => FoundingWizardState[K])
) => {
setState(prev => ({
...prev,
[key]: typeof value === 'function' ? (value as Function)(prev[key]) : value,
}))
}, [])
const setStep = useCallback((step: number) => {
setState(prev => ({ ...prev, current_step: step }))
}, [])
const nextStep = useCallback(() => {
setState(prev => ({ ...prev, current_step: Math.min(prev.current_step + 1, FOUNDING_WIZARD_STEPS.length) }))
}, [])
const prevStep = useCallback(() => {
setState(prev => ({ ...prev, current_step: Math.max(prev.current_step - 1, 1) }))
}, [])
const reset = useCallback(() => {
setState(defaultFoundingWizardState())
try { localStorage.removeItem(STORAGE_KEY) } catch {}
}, [])
// Gesellschafter helpers
const addGesellschafter = useCallback((gs: Omit<Gesellschafter, 'id' | 'anteil_nr'>) => {
setState(prev => {
const nextNr = (prev.gesellschafter.reduce((m, g) => Math.max(m, g.anteil_nr), 0)) + 1
const id = `gs_${Date.now()}_${nextNr}`
return { ...prev, gesellschafter: [...prev.gesellschafter, { ...gs, id, anteil_nr: nextNr }] }
})
}, [])
const updateGesellschafter = useCallback((id: string, patch: Partial<Gesellschafter>) => {
setState(prev => ({
...prev,
gesellschafter: prev.gesellschafter.map(g => g.id === id ? { ...g, ...patch } : g),
}))
}, [])
const removeGesellschafter = useCallback((id: string) => {
setState(prev => ({
...prev,
gesellschafter: prev.gesellschafter.filter(g => g.id !== id),
gf_contracts: prev.gf_contracts.filter(c => c.gesellschafter_id !== id),
}))
}, [])
// GF Contract helpers
const upsertGFContract = useCallback((contract: GFContract) => {
setState(prev => {
const idx = prev.gf_contracts.findIndex(c => c.gesellschafter_id === contract.gesellschafter_id)
const next = [...prev.gf_contracts]
if (idx >= 0) next[idx] = contract
else next.push(contract)
return { ...prev, gf_contracts: next }
})
}, [])
// Validation (canProceed for current step)
const canProceed = useMemo(() => {
switch (state.current_step) {
case 1:
return state.basics.company_name.trim().length > 1 &&
state.basics.company_seat.trim().length > 1 &&
state.basics.company_purpose_description.trim().length > 10
case 2: {
if (state.gesellschafter.length < 1) return false
const sum = state.gesellschafter.reduce((s, g) => s + (g.nennbetrag_eur || 0), 0)
return sum === state.capital.stammkapital_eur
}
case 3:
return state.gesellschafter.some(g => g.is_geschaeftsfuehrer)
case 4:
return state.capital.stammkapital_eur >= 25000
case 5:
return state.notar.notary_name.trim().length > 1 && state.notar.notary_place.trim().length > 1
case 6:
return true
case 7:
return state.gesellschafter.filter(g => g.is_geschaeftsfuehrer)
.every(g => state.gf_contracts.some(c => c.gesellschafter_id === g.id))
case 8:
return state.selected_documents.length > 0
default:
return false
}
}, [state])
const generateDocuments = useCallback(async (): Promise<GeneratedDocument[]> => {
setGenerating(true)
setError(null)
try {
const response = await fetch('/api/v1/founding-wizard/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state),
})
if (!response.ok) {
throw new Error(`Generierung fehlgeschlagen: ${response.status}`)
}
const data = await response.json()
const docs: GeneratedDocument[] = data.documents || []
setState(prev => ({ ...prev, generated_documents: docs }))
return docs
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Unbekannter Fehler'
setError(msg)
throw e
} finally {
setGenerating(false)
}
}, [state])
// Derived: hat zugehöriger GF einen Vertrag?
const gf_list = useMemo(
() => state.gesellschafter.filter(g => g.is_geschaeftsfuehrer),
[state.gesellschafter]
)
return {
state, hydrated, generating, error,
update, setStep, nextStep, prevStep, reset,
addGesellschafter, updateGesellschafter, removeGesellschafter,
upsertGFContract,
canProceed, generateDocuments,
gf_list,
steps: FOUNDING_WIZARD_STEPS,
}
}