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
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
'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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user