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,125 @@
|
||||
'use client'
|
||||
|
||||
import type { FoundingWizardState } from '@/lib/sdk/founding/types'
|
||||
|
||||
interface Props {
|
||||
state: FoundingWizardState
|
||||
update: <K extends keyof FoundingWizardState>(k: K, v: FoundingWizardState[K]) => void
|
||||
}
|
||||
|
||||
export function StepBasics({ state, update }: Props) {
|
||||
const b = state.basics
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname</label>
|
||||
<input
|
||||
data-testid="company-name"
|
||||
type="text"
|
||||
value={b.company_name}
|
||||
onChange={e => update('basics', { ...b, company_name: e.target.value })}
|
||||
placeholder="Breakpilot GmbH"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsform</label>
|
||||
<select
|
||||
data-testid="legal-form"
|
||||
value={b.legal_form}
|
||||
onChange={e => update('basics', { ...b, legal_form: e.target.value as 'GmbH' | 'UG' })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
>
|
||||
<option value="GmbH">GmbH</option>
|
||||
<option value="UG">UG (haftungsbeschränkt)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sitz (Stadt)</label>
|
||||
<input
|
||||
data-testid="company-seat"
|
||||
type="text"
|
||||
value={b.company_seat}
|
||||
onChange={e => update('basics', { ...b, company_seat: e.target.value })}
|
||||
placeholder="z.B. Stuttgart"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||
<input
|
||||
data-testid="company-address"
|
||||
type="text"
|
||||
value={b.company_address}
|
||||
onChange={e => update('basics', { ...b, company_address: e.target.value })}
|
||||
placeholder="Straße, PLZ Ort"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Branche</label>
|
||||
<input
|
||||
data-testid="industry"
|
||||
type="text"
|
||||
value={b.industry}
|
||||
onChange={e => update('basics', { ...b, industry: e.target.value })}
|
||||
placeholder="z.B. SaaS, Beratung, Handwerk"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Geschäftsjahr</label>
|
||||
<input
|
||||
data-testid="business-year"
|
||||
type="text"
|
||||
value={b.business_year}
|
||||
onChange={e => update('basics', { ...b, business_year: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Unternehmensgegenstand (Volltext für § 2 Satzung)
|
||||
</label>
|
||||
<textarea
|
||||
data-testid="company-purpose"
|
||||
value={b.company_purpose_description}
|
||||
onChange={e => update('basics', { ...b, company_purpose_description: e.target.value })}
|
||||
rows={4}
|
||||
placeholder="z.B. die Entwicklung, Bereitstellung, der Betrieb und der Vertrieb von Softwarelösungen, Plattformen und IT-Dienstleistungen im Bereich der Künstlichen Intelligenz"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Detaillierte Tätigkeitsbereiche (eine Zeile pro Bullet)
|
||||
</label>
|
||||
<textarea
|
||||
data-testid="company-purpose-bullets"
|
||||
value={b.company_purpose_bullets.join('\n')}
|
||||
onChange={e => update('basics', { ...b, company_purpose_bullets: e.target.value.split('\n').filter(Boolean) })}
|
||||
rows={5}
|
||||
placeholder={'a) Entwicklung von Software\nb) Beratung im Bereich...\nc) ...'}
|
||||
className="w-full px-3 py-2 border rounded-lg font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="research_focus"
|
||||
data-testid="research-focus"
|
||||
checked={b.has_research_focus}
|
||||
onChange={e => update('basics', { ...b, has_research_focus: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="research_focus" className="text-sm text-gray-700">
|
||||
Forschungsfokus (aktiviert F&E-Klauseln in SHA und GO-GF)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { FoundingWizardState, GeneratedDocument } from '@/lib/sdk/founding/types'
|
||||
import { NOTARY_BUNDLE_DOCUMENTS } from '@/lib/sdk/founding/template-categories'
|
||||
|
||||
interface Props {
|
||||
state: FoundingWizardState
|
||||
update: <K extends keyof FoundingWizardState>(k: K, v: FoundingWizardState[K]) => void
|
||||
generating: boolean
|
||||
error: string | null
|
||||
onGenerate: () => Promise<GeneratedDocument[]>
|
||||
}
|
||||
|
||||
const DOC_LABELS: Record<string, string> = {
|
||||
articles_of_association: 'Satzung',
|
||||
gesellschafterliste: 'Gesellschafterliste (§ 40 GmbHG)',
|
||||
gf_bestellungsbeschluss: 'Gesellschafterbeschluss zur GF-Bestellung',
|
||||
hrb_anmeldung: 'Handelsregister-Anmeldung',
|
||||
sha: 'Shareholders\' Agreement (SHA)',
|
||||
geschaeftsordnung_gf: 'Geschäftsordnung Geschäftsführung (GO-GF)',
|
||||
managing_director_employment_contract: 'GF-Dienstvertrag (pro GF)',
|
||||
ip_assignment_agreement: 'IP-Assignment (pro Gründer)',
|
||||
term_sheet: 'Term Sheet',
|
||||
convertible_loan_agreement: 'Wandeldarlehensvertrag',
|
||||
subscription_agreement: 'Beteiligungsvertrag',
|
||||
esop_plan: 'ESOP/VSOP-Plan',
|
||||
cap_table: 'Cap Table',
|
||||
}
|
||||
|
||||
export function StepGenerate({ state, update, generating, error, onGenerate }: Props) {
|
||||
const toggleDoc = (docType: string) => {
|
||||
const next = state.selected_documents.includes(docType)
|
||||
? state.selected_documents.filter(d => d !== docType)
|
||||
: [...state.selected_documents, docType]
|
||||
update('selected_documents', next)
|
||||
}
|
||||
|
||||
const selectNotaryBundle = () => {
|
||||
update('selected_documents', [...NOTARY_BUNDLE_DOCUMENTS])
|
||||
}
|
||||
|
||||
const summary = useMemo(() => ({
|
||||
name: state.basics.company_name,
|
||||
seat: state.basics.company_seat,
|
||||
stammkapital: state.capital.stammkapital_eur,
|
||||
num_gesellschafter: state.gesellschafter.length,
|
||||
num_gf: state.gesellschafter.filter(g => g.is_geschaeftsfuehrer).length,
|
||||
}), [state])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-purple-900 mb-2">Zusammenfassung</h3>
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm" data-testid="generate-summary">
|
||||
<dt className="text-gray-600">Firma:</dt><dd>{summary.name} ({state.basics.legal_form})</dd>
|
||||
<dt className="text-gray-600">Sitz:</dt><dd>{summary.seat}</dd>
|
||||
<dt className="text-gray-600">Stammkapital:</dt><dd>{summary.stammkapital.toLocaleString('de-DE')} €</dd>
|
||||
<dt className="text-gray-600">Gesellschafter:</dt><dd>{summary.num_gesellschafter}</dd>
|
||||
<dt className="text-gray-600">Geschäftsführer:</dt><dd>{summary.num_gf}</dd>
|
||||
<dt className="text-gray-600">Notar:</dt><dd>{state.notar.notary_name} ({state.notar.notary_place})</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-semibold">Zu generierende Dokumente</h3>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="select-notary-bundle"
|
||||
onClick={selectNotaryBundle}
|
||||
className="text-sm text-purple-600 hover:underline"
|
||||
>
|
||||
➜ Notartermin-Bundle auswählen
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{Object.entries(DOC_LABELS).map(([docType, label]) => (
|
||||
<label key={docType} className="flex items-start gap-3 p-2 hover:bg-gray-50 rounded">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid={`doc-${docType}`}
|
||||
checked={state.selected_documents.includes(docType)}
|
||||
onChange={() => toggleDoc(docType)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<div className="text-xs text-gray-500">{docType}</div>
|
||||
</div>
|
||||
{NOTARY_BUNDLE_DOCUMENTS.includes(docType) && (
|
||||
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">Notartermin</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t">
|
||||
<p className="text-sm text-gray-500">
|
||||
{state.selected_documents.length} Dokument(e) ausgewählt
|
||||
</p>
|
||||
<button
|
||||
data-testid="generate-docs"
|
||||
onClick={onGenerate}
|
||||
disabled={generating || state.selected_documents.length === 0}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 font-medium"
|
||||
>
|
||||
{generating ? 'Generiere...' : 'Dokumente als Word generieren'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-900" data-testid="generate-error">
|
||||
Fehler: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.generated_documents && state.generated_documents.length > 0 && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4" data-testid="generated-docs">
|
||||
<h3 className="font-semibold text-green-900 mb-3">
|
||||
✓ {state.generated_documents.length} Dokument(e) generiert
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{state.generated_documents.map((doc, idx) => (
|
||||
<li key={idx} className="flex justify-between items-center bg-white rounded px-3 py-2 border border-green-200">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{doc.title}</div>
|
||||
<div className="text-xs text-gray-500">{(doc.size_bytes / 1024).toFixed(1)} KB</div>
|
||||
</div>
|
||||
<a
|
||||
href={doc.download_url}
|
||||
download
|
||||
data-testid={`download-${doc.document_type}`}
|
||||
className="px-3 py-1.5 bg-green-600 text-white rounded text-sm hover:bg-green-700"
|
||||
>
|
||||
Word herunterladen
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { FoundingWizardState, Gesellschafter } from '@/lib/sdk/founding/types'
|
||||
|
||||
interface Props {
|
||||
state: FoundingWizardState
|
||||
addGesellschafter: (g: Omit<Gesellschafter, 'id' | 'anteil_nr'>) => void
|
||||
updateGesellschafter: (id: string, p: Partial<Gesellschafter>) => void
|
||||
removeGesellschafter: (id: string) => void
|
||||
}
|
||||
|
||||
export function StepGesellschafter({ state, addGesellschafter, updateGesellschafter, removeGesellschafter }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
name: '', geburtsdatum: '', adresse: '', email: '',
|
||||
nennbetrag_eur: 12500, is_geschaeftsfuehrer: true, internal_role: '',
|
||||
has_academic_background: false,
|
||||
})
|
||||
|
||||
const totalNennbetrag = state.gesellschafter.reduce((s, g) => s + g.nennbetrag_eur, 0)
|
||||
const target = state.capital.stammkapital_eur
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!form.name.trim()) return
|
||||
addGesellschafter({
|
||||
rolle: 'founder',
|
||||
name: form.name,
|
||||
geburtsdatum: form.geburtsdatum || undefined,
|
||||
adresse: form.adresse,
|
||||
email: form.email || undefined,
|
||||
nennbetrag_eur: form.nennbetrag_eur,
|
||||
is_geschaeftsfuehrer: form.is_geschaeftsfuehrer,
|
||||
internal_role: form.internal_role || undefined,
|
||||
has_academic_background: form.has_academic_background,
|
||||
})
|
||||
setForm({ name: '', geburtsdatum: '', adresse: '', email: '', nennbetrag_eur: 12500,
|
||||
is_geschaeftsfuehrer: true, internal_role: '', has_academic_background: false })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-3">Neuen Gesellschafter hinzufügen</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input
|
||||
data-testid="gs-name"
|
||||
placeholder="Name"
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
className="px-3 py-2 border rounded"
|
||||
/>
|
||||
<input
|
||||
data-testid="gs-birthdate"
|
||||
type="date"
|
||||
placeholder="Geburtsdatum"
|
||||
value={form.geburtsdatum}
|
||||
onChange={e => setForm({ ...form, geburtsdatum: e.target.value })}
|
||||
className="px-3 py-2 border rounded"
|
||||
/>
|
||||
<input
|
||||
data-testid="gs-address"
|
||||
placeholder="Adresse (Straße, PLZ Ort)"
|
||||
value={form.adresse}
|
||||
onChange={e => setForm({ ...form, adresse: e.target.value })}
|
||||
className="px-3 py-2 border rounded col-span-2"
|
||||
/>
|
||||
<input
|
||||
data-testid="gs-email"
|
||||
type="email"
|
||||
placeholder="E-Mail (optional)"
|
||||
value={form.email}
|
||||
onChange={e => setForm({ ...form, email: e.target.value })}
|
||||
className="px-3 py-2 border rounded"
|
||||
/>
|
||||
<input
|
||||
data-testid="gs-nennbetrag"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
placeholder="Nennbetrag in EUR"
|
||||
value={form.nennbetrag_eur}
|
||||
onChange={e => setForm({ ...form, nennbetrag_eur: parseInt(e.target.value) || 0 })}
|
||||
className="px-3 py-2 border rounded"
|
||||
/>
|
||||
<input
|
||||
data-testid="gs-role"
|
||||
placeholder="Interne Rolle (z.B. CEO, CTO)"
|
||||
value={form.internal_role}
|
||||
onChange={e => setForm({ ...form, internal_role: e.target.value })}
|
||||
className="px-3 py-2 border rounded"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="gs-is-gf"
|
||||
checked={form.is_geschaeftsfuehrer}
|
||||
onChange={e => setForm({ ...form, is_geschaeftsfuehrer: e.target.checked })}
|
||||
/>
|
||||
<label className="text-sm">Geschäftsführer/in</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="gs-academic"
|
||||
checked={form.has_academic_background}
|
||||
onChange={e => setForm({ ...form, has_academic_background: e.target.checked })}
|
||||
/>
|
||||
<label className="text-sm">Akademischer Hintergrund</label>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
data-testid="add-gesellschafter"
|
||||
onClick={handleAdd}
|
||||
disabled={!form.name.trim() || form.nennbetrag_eur < 1}
|
||||
className="mt-3 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
Gesellschafter hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-3">Gesellschafter ({state.gesellschafter.length})</h3>
|
||||
{state.gesellschafter.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">Noch keine Gesellschafter angelegt.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm" data-testid="gs-table">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Nr.</th>
|
||||
<th className="px-3 py-2 text-left">Name</th>
|
||||
<th className="px-3 py-2 text-left">Geburtsdatum</th>
|
||||
<th className="px-3 py-2 text-right">Nennbetrag</th>
|
||||
<th className="px-3 py-2 text-right">Anteil %</th>
|
||||
<th className="px-3 py-2">GF?</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.gesellschafter.map(g => (
|
||||
<tr key={g.id} className="border-t" data-testid={`gs-row-${g.anteil_nr}`}>
|
||||
<td className="px-3 py-2">{g.anteil_nr}</td>
|
||||
<td className="px-3 py-2 font-medium">{g.name}{g.internal_role ? ` (${g.internal_role})` : ''}</td>
|
||||
<td className="px-3 py-2">{g.geburtsdatum || '—'}</td>
|
||||
<td className="px-3 py-2 text-right">{g.nennbetrag_eur.toLocaleString('de-DE')} €</td>
|
||||
<td className="px-3 py-2 text-right">{((g.nennbetrag_eur / Math.max(target, 1)) * 100).toFixed(2)}%</td>
|
||||
<td className="px-3 py-2 text-center">{g.is_geschaeftsfuehrer ? '✓' : '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
onClick={() => removeGesellschafter(g.id)}
|
||||
className="text-red-600 hover:underline text-xs"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-t-2 font-semibold bg-gray-50">
|
||||
<td colSpan={3} className="px-3 py-2">Summe</td>
|
||||
<td className="px-3 py-2 text-right" data-testid="gs-total">
|
||||
{totalNennbetrag.toLocaleString('de-DE')} €
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{totalNennbetrag === target ? '100%' : `≠ ${target.toLocaleString('de-DE')} €`}
|
||||
</td>
|
||||
<td colSpan={2}></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{totalNennbetrag !== target && state.gesellschafter.length > 0 && (
|
||||
<p className="mt-2 text-sm text-orange-600">
|
||||
⚠ Die Summe der Nennbeträge ({totalNennbetrag.toLocaleString('de-DE')} €)
|
||||
entspricht nicht dem Stammkapital ({target.toLocaleString('de-DE')} €).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Kombinierte einfache Steps: Geschäftsführer (3), Kapital (4), Notar (5), SHA (6).
|
||||
* Jeder Sub-Step ist eine simple Form.
|
||||
*/
|
||||
|
||||
import type { FoundingWizardState, GFContract } from '@/lib/sdk/founding/types'
|
||||
|
||||
interface PropsBase {
|
||||
state: FoundingWizardState
|
||||
update: <K extends keyof FoundingWizardState>(k: K, v: FoundingWizardState[K]) => void
|
||||
}
|
||||
|
||||
export function StepGFAssignment({ state, update }: PropsBase) {
|
||||
const founders = state.gesellschafter
|
||||
const toggleGF = (id: string, val: boolean) => {
|
||||
update('gesellschafter', state.gesellschafter.map(g => g.id === id ? { ...g, is_geschaeftsfuehrer: val } : g))
|
||||
}
|
||||
const setRole = (id: string, role: string) => {
|
||||
update('gesellschafter', state.gesellschafter.map(g => g.id === id ? { ...g, internal_role: role } : g))
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Wähle, welche Gesellschafter zu Geschäftsführern bestellt werden sollen. Standardmäßig sind alle Gründer auch GF.
|
||||
</p>
|
||||
{founders.length === 0 ? (
|
||||
<p className="text-orange-600">Bitte zuerst Gesellschafter in Step 2 anlegen.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm" data-testid="gf-assignment-table">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Gesellschafter</th>
|
||||
<th className="px-3 py-2 text-left">Interne Rolle (CEO, CTO, ...)</th>
|
||||
<th className="px-3 py-2">GF?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{founders.map(g => (
|
||||
<tr key={g.id} className="border-t">
|
||||
<td className="px-3 py-2 font-medium">{g.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
value={g.internal_role || ''}
|
||||
onChange={e => setRole(g.id, e.target.value)}
|
||||
className="px-2 py-1 border rounded w-48"
|
||||
placeholder="CEO, CTO, COO..."
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid={`gf-toggle-${g.anteil_nr}`}
|
||||
checked={g.is_geschaeftsfuehrer}
|
||||
onChange={e => toggleGF(g.id, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepCapital({ state, update }: PropsBase) {
|
||||
const c = state.capital
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Stammkapital (EUR)</label>
|
||||
<input
|
||||
data-testid="stammkapital"
|
||||
type="number" min={1} step={1}
|
||||
value={c.stammkapital_eur}
|
||||
onChange={e => update('capital', { ...c, stammkapital_eur: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">GmbH: mind. 25.000 €, UG: ab 1 €</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Einlage-Art</label>
|
||||
<select
|
||||
data-testid="einlage-method"
|
||||
value={c.einlage_method}
|
||||
onChange={e => update('capital', { ...c, einlage_method: e.target.value as typeof c.einlage_method })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
>
|
||||
<option value="Geld">Bargründung</option>
|
||||
<option value="Sacheinlage">Sachgründung</option>
|
||||
<option value="Geld und Sacheinlage">Misch-Gründung</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Sofortige Einzahlung (%)
|
||||
</label>
|
||||
<input
|
||||
data-testid="einlage-quote"
|
||||
type="number" min={25} max={100}
|
||||
value={c.einlage_quote_initial_pct}
|
||||
onChange={e => update('capital', { ...c, einlage_quote_initial_pct: parseInt(e.target.value) || 50 })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Mind. 25% gem. § 7 Abs. 2 GmbHG, Standard 50%</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-7">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="has_sach"
|
||||
data-testid="has-sacheinlage"
|
||||
checked={c.has_sacheinlage}
|
||||
onChange={e => update('capital', { ...c, has_sacheinlage: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="has_sach" className="text-sm">Sacheinlage-Klausel aktivieren</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepNotar({ state, update }: PropsBase) {
|
||||
const n = state.notar
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name des Notars</label>
|
||||
<input
|
||||
data-testid="notary-name"
|
||||
value={n.notary_name}
|
||||
onChange={e => update('notar', { ...n, notary_name: e.target.value })}
|
||||
placeholder="z.B. Dr. Müller"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notarsitz</label>
|
||||
<input
|
||||
data-testid="notary-place"
|
||||
value={n.notary_place}
|
||||
onChange={e => update('notar', { ...n, notary_place: e.target.value })}
|
||||
placeholder="z.B. Stuttgart"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||
<input
|
||||
data-testid="notary-address"
|
||||
value={n.notary_address || ''}
|
||||
onChange={e => update('notar', { ...n, notary_address: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Geplanter Notartermin</label>
|
||||
<input
|
||||
data-testid="notarial-date"
|
||||
type="date"
|
||||
value={n.notarial_date || ''}
|
||||
onChange={e => update('notar', { ...n, notarial_date: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm text-blue-900">
|
||||
<strong>Hinweis:</strong> Die URNr. wird vom Notar beim Beurkundungstermin vergeben. Du kannst die generierte
|
||||
HRB-Anmeldung als Vorbereitungsdokument zum Termin mitnehmen.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepSHAConfig({ state, update }: PropsBase) {
|
||||
const s = state.sha
|
||||
const updateField = <K extends keyof typeof s>(k: K, v: typeof s[K]) => update('sha', { ...s, [k]: v })
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="has-sha"
|
||||
checked={s.has_sha}
|
||||
onChange={e => updateField('has_sha', e.target.checked)}
|
||||
/>
|
||||
<label className="text-sm font-medium">SHA (Shareholders' Agreement) ist Teil des Notartermin-Pakets</label>
|
||||
</div>
|
||||
|
||||
{s.has_sha && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Vesting-Dauer (Monate)</label>
|
||||
<input data-testid="vesting-months" type="number" value={s.vesting_months}
|
||||
onChange={e => updateField('vesting_months', parseInt(e.target.value) || 48)}
|
||||
className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Cliff (Monate)</label>
|
||||
<input data-testid="cliff-months" type="number" value={s.cliff_months}
|
||||
onChange={e => updateField('cliff_months', parseInt(e.target.value) || 12)}
|
||||
className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Drag-Along Schwelle (%)</label>
|
||||
<input data-testid="drag-along-pct" type="number" value={s.drag_along_threshold_pct}
|
||||
onChange={e => updateField('drag_along_threshold_pct', parseInt(e.target.value) || 75)}
|
||||
className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-700 mb-1">Reserved-Matters Mehrheit (%)</label>
|
||||
<input data-testid="reserved-matters-pct" type="number" value={s.reserved_matters_majority_pct}
|
||||
onChange={e => updateField('reserved_matters_majority_pct', parseInt(e.target.value) || 75)}
|
||||
className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div className="col-span-2 grid grid-cols-3 gap-3 mt-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" data-testid="has-beirat" checked={s.has_beirat}
|
||||
onChange={e => updateField('has_beirat', e.target.checked)} />
|
||||
Beirat einrichten
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" data-testid="has-texas" checked={s.has_texas_shootout}
|
||||
onChange={e => updateField('has_texas_shootout', e.target.checked)} />
|
||||
Texas Shoot-Out (Deadlock)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" data-testid="has-ceo" checked={s.has_ceo_designation}
|
||||
onChange={e => updateField('has_ceo_designation', e.target.checked)} />
|
||||
CEO mit Stichentscheid
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface GFContractStepProps extends PropsBase {
|
||||
gf_list: Array<{ id: string; name: string; internal_role?: string }>
|
||||
upsertGFContract: (c: GFContract) => void
|
||||
}
|
||||
|
||||
export function StepGFContracts({ state, gf_list, upsertGFContract }: GFContractStepProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Für jeden Geschäftsführer wird ein Dienstvertrag generiert. Bitte Eckdaten ausfüllen.
|
||||
</p>
|
||||
{gf_list.length === 0 ? (
|
||||
<p className="text-orange-600">Bitte zuerst in Step 2 mindestens einen GF anlegen.</p>
|
||||
) : (
|
||||
gf_list.map(gf => {
|
||||
const c = state.gf_contracts.find(x => x.gesellschafter_id === gf.id) || {
|
||||
gesellschafter_id: gf.id,
|
||||
gross_annual_salary_eur: 84000,
|
||||
has_bonus: false,
|
||||
has_company_car: false,
|
||||
has_bav: false,
|
||||
vacation_days: 30,
|
||||
kuendigungsfrist_gesellschaft_monate: 6,
|
||||
kuendigungsfrist_gf_monate: 3,
|
||||
para_181_release: true,
|
||||
sv_status: 'sozialversicherungsfrei' as const,
|
||||
}
|
||||
const u = (patch: Partial<GFContract>) => upsertGFContract({ ...c, ...patch })
|
||||
return (
|
||||
<div key={gf.id} className="border rounded-lg p-4" data-testid={`contract-${gf.id}`}>
|
||||
<h4 className="font-semibold mb-3">{gf.name} {gf.internal_role && `(${gf.internal_role})`}</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-700 mb-1">Jahresgehalt (EUR brutto)</label>
|
||||
<input
|
||||
data-testid={`salary-${gf.id}`}
|
||||
type="number"
|
||||
value={c.gross_annual_salary_eur}
|
||||
onChange={e => u({ gross_annual_salary_eur: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-2 py-1 border rounded"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-700 mb-1">Urlaubstage</label>
|
||||
<input type="number" value={c.vacation_days}
|
||||
onChange={e => u({ vacation_days: parseInt(e.target.value) || 30 })}
|
||||
className="w-full px-2 py-1 border rounded" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-700 mb-1">SV-Status</label>
|
||||
<select value={c.sv_status} onChange={e => u({ sv_status: e.target.value as GFContract['sv_status'] })}
|
||||
className="w-full px-2 py-1 border rounded">
|
||||
<option value="sozialversicherungsfrei">sv-frei (Standard für GF/Gesellschafter)</option>
|
||||
<option value="sozialversicherungspflichtig">sv-pflichtig</option>
|
||||
<option value="noch zu klären">noch zu klären</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={c.para_181_release}
|
||||
onChange={e => u({ para_181_release: e.target.checked })} />
|
||||
§ 181 BGB-Befreiung
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={c.has_bonus}
|
||||
onChange={e => u({ has_bonus: e.target.checked })} />
|
||||
Bonus-Vereinbarung
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={c.has_company_car}
|
||||
onChange={e => u({ has_company_car: e.target.checked })} />
|
||||
Firmenfahrzeug
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useFoundingWizardForm } from './_hooks/useFoundingWizardForm'
|
||||
import { StepBasics } from './_components/StepBasics'
|
||||
import { StepGesellschafter } from './_components/StepGesellschafter'
|
||||
import { StepCapital, StepGFAssignment, StepGFContracts, StepNotar, StepSHAConfig } from './_components/StepsSimpleConfig'
|
||||
import { StepGenerate } from './_components/StepGenerate'
|
||||
|
||||
export default function FoundingWizardPage() {
|
||||
const {
|
||||
state, hydrated, generating, error,
|
||||
update, nextStep, prevStep, reset,
|
||||
addGesellschafter, updateGesellschafter, removeGesellschafter,
|
||||
upsertGFContract,
|
||||
canProceed, generateDocuments,
|
||||
gf_list, steps,
|
||||
} = useFoundingWizardForm()
|
||||
|
||||
if (!hydrated) return null
|
||||
|
||||
const isLastStep = state.current_step === steps.length
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8" data-testid="founding-wizard">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Gründungs-Wizard</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Erstellt alle Notartermin-Dokumente für Deine GmbH/UG-Gründung in 8 Schritten.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
data-testid="reset-wizard"
|
||||
onClick={() => { if (confirm('Wizard-Daten zurücksetzen?')) reset() }}
|
||||
className="text-sm text-gray-500 hover:text-red-600"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="mb-8" data-testid="wizard-progress">
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, idx) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.current_step > step.id && update('current_step', step.id)}
|
||||
className="flex items-center"
|
||||
data-testid={`step-indicator-${step.id}`}
|
||||
>
|
||||
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
step.id < state.current_step ? 'bg-purple-600 text-white' :
|
||||
step.id === state.current_step ? 'bg-purple-100 text-purple-600 border-2 border-purple-600' :
|
||||
'bg-gray-100 text-gray-400'
|
||||
}`}>
|
||||
{step.id < state.current_step ? '✓' : step.id}
|
||||
</div>
|
||||
<div className="ml-2 hidden md:block text-left">
|
||||
<div className={`text-xs font-medium ${step.id <= state.current_step ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||
{step.name}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{idx < steps.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mx-2 ${step.id < state.current_step ? 'bg-purple-600' : 'bg-gray-200'}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{steps[state.current_step - 1]?.name}
|
||||
</h2>
|
||||
<p className="text-gray-500 text-sm">{steps[state.current_step - 1]?.description}</p>
|
||||
</div>
|
||||
|
||||
<div data-testid={`step-content-${state.current_step}`}>
|
||||
{state.current_step === 1 && <StepBasics state={state} update={update} />}
|
||||
{state.current_step === 2 && (
|
||||
<StepGesellschafter
|
||||
state={state}
|
||||
addGesellschafter={addGesellschafter}
|
||||
updateGesellschafter={updateGesellschafter}
|
||||
removeGesellschafter={removeGesellschafter}
|
||||
/>
|
||||
)}
|
||||
{state.current_step === 3 && <StepGFAssignment state={state} update={update} />}
|
||||
{state.current_step === 4 && <StepCapital state={state} update={update} />}
|
||||
{state.current_step === 5 && <StepNotar state={state} update={update} />}
|
||||
{state.current_step === 6 && <StepSHAConfig state={state} update={update} />}
|
||||
{state.current_step === 7 && (
|
||||
<StepGFContracts state={state} update={update} gf_list={gf_list} upsertGFContract={upsertGFContract} />
|
||||
)}
|
||||
{state.current_step === 8 && (
|
||||
<StepGenerate
|
||||
state={state}
|
||||
update={update}
|
||||
generating={generating}
|
||||
error={error}
|
||||
onGenerate={generateDocuments}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
{!isLastStep && (
|
||||
<div className="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
data-testid="prev-step"
|
||||
onClick={prevStep}
|
||||
disabled={state.current_step === 1}
|
||||
className="px-6 py-3 text-gray-600 hover:text-gray-900 disabled:opacity-50"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<span className="text-xs text-gray-400">
|
||||
Schritt {state.current_step} von {steps.length}
|
||||
</span>
|
||||
<button
|
||||
data-testid="next-step"
|
||||
onClick={nextStep}
|
||||
disabled={!canProceed}
|
||||
className="px-8 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user