Files
breakpilot-compliance/admin-compliance/app/sdk/founding-wizard/_components/StepGesellschafter.tsx
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

180 lines
7.4 KiB
TypeScript

'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>
)
}