From 82c9b5cf53f23b1fdf5c51d5b907177bb8709711 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Tue, 21 Apr 2026 23:55:48 +0200 Subject: [PATCH] feat(pitch-deck): interactive charts with Y-axes, click-to-detail, explanations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - All charts now have Y-axis labels with scale - X-axis with year labels on border lines - Click any chart → modal with KPI explanation + yearly breakdown - 8 detail explanations: MRR, EBIT, Headcount, Cash, Rev vs Costs, ACV, Gross Margin, NRR, EBIT Margin - Unit Economics cards clickable with hover effect - Compact 2x2 grid for EBIT/Headcount and Cash/RevCost - ISO 27001 cert moved to Jan 2027 on production Co-Authored-By: Claude Opus 4.6 (1M context) --- pitch-deck/app/api/admin/fp-patch/route.ts | 33 +- .../components/slides/FinanzplanSlide.tsx | 337 +++++++++++------- pitch-deck/middleware.ts | 1 - 3 files changed, 209 insertions(+), 162 deletions(-) diff --git a/pitch-deck/app/api/admin/fp-patch/route.ts b/pitch-deck/app/api/admin/fp-patch/route.ts index 47ff5f8..ff87109 100644 --- a/pitch-deck/app/api/admin/fp-patch/route.ts +++ b/pitch-deck/app/api/admin/fp-patch/route.ts @@ -1,32 +1,17 @@ import { NextRequest, NextResponse } from 'next/server' +import { requireAdmin } from '@/lib/admin-auth' import pool from '@/lib/db' import { computeFinanzplan } from '@/lib/finanzplan/engine' +/** Admin-only: recompute a Finanzplan scenario. */ export async function POST(request: NextRequest) { - const WD = 'c0000000-0000-0000-0000-000000000200' + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + const body = await request.json().catch(() => ({})) - const results: string[] = [] + const scenarioId = body.scenarioId || (await pool.query("SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1")).rows[0]?.id + if (!scenarioId) return NextResponse.json({ error: 'No scenario found' }, { status: 404 }) - try { - // ISO Cert: add months m13-m24 (Jan-Dec 2027) at 1500 - const { rows } = await pool.query(`SELECT id, values FROM fp_betriebliche_aufwendungen WHERE scenario_id=$1 AND row_label ILIKE '%Zertifizierung%'`, [WD]) - if (rows.length > 0) { - const vals = rows[0].values || {} - for (let m = 13; m <= 24; m++) { - if (!vals[`m${m}`]) vals[`m${m}`] = 1500 - } - await pool.query(`UPDATE fp_betriebliche_aufwendungen SET values=$1 WHERE id=$2`, [JSON.stringify(vals), rows[0].id]) - results.push('ISO cert from Jan 2027') - } - - // Recompute if requested - if (body.recompute !== false) { - const r = await computeFinanzplan(pool, WD) - results.push(`WD cash_m60=${r.liquiditaet?.endstand?.m60}`) - } - } catch (err) { - results.push(`ERROR: ${err instanceof Error ? err.message : String(err)}`) - } - - return NextResponse.json({ ok: true, results }) + const result = await computeFinanzplan(pool, scenarioId) + return NextResponse.json({ success: true, scenarioId, cash_m60: result.liquiditaet?.endstand?.m60 }) } diff --git a/pitch-deck/components/slides/FinanzplanSlide.tsx b/pitch-deck/components/slides/FinanzplanSlide.tsx index ec0878f..85c0009 100644 --- a/pitch-deck/components/slides/FinanzplanSlide.tsx +++ b/pitch-deck/components/slides/FinanzplanSlide.tsx @@ -312,180 +312,242 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId, )} {/* === Charts Tab === */} - {activeSheet === 'charts' && ( + {activeSheet === 'charts' && (() => { + const years = ['y2026','y2027','y2028','y2029','y2030'] + const fmtK = (v: number) => Math.abs(v) >= 1000000 ? `${(v/1000000).toFixed(1)}M` : `${Math.round(v/1000)}k` + const fmtV = (v: number) => v.toLocaleString('de-DE') + + // Chart detail modal + const chartDetails: Record = { + mrr: { title: 'MRR (Monthly Recurring Revenue)', desc: de ? 'Monatlich wiederkehrender Umsatz — der wichtigste KPI für SaaS-Unternehmen. Zeigt die planbaren monatlichen Einnahmen aus laufenden Kundenverträgen. Wachsender MRR = wachsendes Geschäft.' : 'Monthly recurring revenue — the most important KPI for SaaS companies. Shows predictable monthly income from active customer contracts. Growing MRR = growing business.' }, + ebit: { title: 'EBIT (Earnings Before Interest & Taxes)', desc: de ? 'Operatives Ergebnis vor Zinsen und Steuern — zeigt die tatsächliche Profitabilität des Geschäftsbetriebs. Positiver EBIT = das Geschäftsmodell funktioniert.' : 'Operating profit before interest and taxes — shows actual profitability of operations. Positive EBIT = the business model works.' }, + headcount: { title: de ? 'Personalaufbau' : 'Headcount', desc: de ? 'Geplanter Teamaufbau über 5 Jahre. Wir starten lean mit 2 Gründern und wachsen bedarfsorientiert. Jede Einstellung ist an einen konkreten Meilenstein geknüpft.' : 'Planned team growth over 5 years. We start lean with 2 founders and grow based on demand. Each hire is tied to a concrete milestone.' }, + cash: { title: de ? 'Liquidität (Jahresende)' : 'Cash Position (Year-End)', desc: de ? 'Kontostand am Ende jedes Jahres. Zeigt ob genug Geld für den laufenden Betrieb vorhanden ist. Die Liquiditätskurve berücksichtigt alle Einnahmen, Ausgaben, Investitionen und Finanzierungen.' : 'Bank balance at end of each year. Shows if enough cash is available for operations. The liquidity curve accounts for all revenue, expenses, investments and financing.' }, + revcost: { title: de ? 'Umsatz vs. Gesamtkosten' : 'Revenue vs. Total Costs', desc: de ? 'Vergleich zwischen Einnahmen und Ausgaben. Der Schnittpunkt zeigt den Break-Even — ab dann verdient das Unternehmen mehr als es ausgibt.' : 'Comparison between income and expenses. The intersection shows break-even — from then on, the company earns more than it spends.' }, + acv: { title: 'ACV (Average Contract Value)', desc: de ? 'Durchschnittlicher Vertragswert pro Kunde und Jahr. Steigender ACV bedeutet: Kunden kaufen mehr Module oder wechseln in höhere Tiers.' : 'Average contract value per customer per year. Rising ACV means: customers buy more modules or upgrade to higher tiers.' }, + grossMargin: { title: 'Gross Margin', desc: de ? 'Rohertragsmarge — wieviel Prozent vom Umsatz nach Abzug der direkten Kosten (Cloud-Infrastruktur, Lizenzen) übrig bleibt. Bei SaaS typisch 70-90%.' : 'How much revenue remains after direct costs (cloud infrastructure, licenses). Typical for SaaS: 70-90%.' }, + nrr: { title: 'NRR (Net Revenue Retention)', desc: de ? 'Umsatzwachstum durch Bestandskunden — zeigt ob bestehende Kunden mehr ausgeben als sie kündigen. NRR > 100% bedeutet: das Geschäft wächst auch ohne Neukunden.' : 'Revenue growth from existing customers — shows if current customers spend more than they churn. NRR > 100% means: the business grows even without new customers.' }, + ebitMargin: { title: 'EBIT Margin', desc: de ? 'Operatives Ergebnis in Prozent vom Umsatz. Zeigt die Effizienz des Geschäftsmodells. Ziel für SaaS: 20-30% in der Reifephase.' : 'Operating result as percentage of revenue. Shows business model efficiency. Target for SaaS: 20-30% at maturity.' }, + } + const [chartDetail, setChartDetail] = useState(null) + const detailInfo = chartDetail ? chartDetails[chartDetail] : null + + return (
- {/* MRR + Kunden Chart */} - -

{de ? 'MRR & Kundenentwicklung' : 'MRR & Customer Growth'}

-
- {(() => { - const years = ['y2026','y2027','y2028','y2029','y2030'] - const data = years.map(y => ({ year: y.slice(1), mrr: fpKPIs[y]?.mrr || 0, cust: fpKPIs[y]?.customers || 0 })) - const maxMrr = Math.max(...data.map(d => d.mrr), 1) - const maxCust = Math.max(...data.map(d => d.cust), 1) - return data.map(d => ({ ...d, max_mrr: maxMrr, max_cust: maxCust })) - })().map((d, idx) => ( -
-
- {/* MRR bar */} -
-
- {d.mrr >= 100000 ? `${Math.round(d.mrr/1000)}k` : d.mrr.toLocaleString('de-DE')} -
-
- {/* Kunden bar */} -
-
{d.cust}
+ {/* Detail overlay */} + {detailInfo && ( +
setChartDetail(null)}> +
+
e.stopPropagation()}> + +

{detailInfo.title}

+

{detailInfo.desc}

+ {chartDetail && fpKPIs.y2026 && ( +
+
+ {[2026,2027,2028,2029,2030].map(y => { + const key = chartDetail === 'cash' ? 'liquiditaet' : chartDetail === 'revcost' ? 'revenue' : chartDetail + const v = fpKPIs[`y${y}`]?.[key as keyof typeof fpKPIs['y2026']] as number || 0 + return ( +
+
{y}
+
{typeof v === 'number' && Math.abs(v) >= 1000 ? fmtK(v) : `${v}`}
+
+ ) + })}
- {d.year} -
- ))} + )} +
+
+ )} + + {/* MRR + Kunden Chart */} + setChartDetail('mrr')}> +
+

{de ? 'MRR & Kundenentwicklung' : 'MRR & Customer Growth'}

+ {de ? 'Klicken für Details' : 'Click for details'} +
+
+ {/* Y-axis labels */} +
+ {(() => { const m = Math.max(...years.map(y => fpKPIs[y]?.mrr || 0), 1); return [m, Math.round(m/2), 0].map((v,i) => {fmtK(v)} €) })()} +
+
+ {years.map((y, idx) => { + const d = { mrr: fpKPIs[y]?.mrr || 0, cust: fpKPIs[y]?.customers || 0 } + const maxMrr = Math.max(...years.map(k => fpKPIs[k]?.mrr || 0), 1) + const maxCust = Math.max(...years.map(k => fpKPIs[k]?.customers || 0), 1) + return ( +
+
+
+
{fmtK(d.mrr)}
+
+
+
{d.cust}
+
+
+ {y.slice(1)} +
+ ) + })} +
- MRR (€) - {de ? 'Kunden' : 'Customers'} + MRR (€/Mon) + {de ? 'Bestandskunden (Dez)' : 'Customers (Dec)'}
- {/* EBIT + Cash Chart */} + {/* EBIT + Headcount */}
- -

EBIT

-
- {(() => { - const years = ['y2026','y2027','y2028','y2029','y2030'] - const data = years.map(y => ({ year: y.slice(1), ebit: fpKPIs[y]?.ebit || 0 })) - const maxAbs = Math.max(...data.map(d => Math.abs(d.ebit)), 1) - return data.map((k, idx) => { - const h = Math.abs(k.ebit) / maxAbs * 100 + setChartDetail('ebit')}> +
+

EBIT

+ €/Jahr +
+
+
+ {(() => { const vals = years.map(y => fpKPIs[y]?.ebit || 0); const mx = Math.max(...vals.map(Math.abs), 1); return [fmtK(mx), '0', fmtK(-mx)].map((v,i) => {v}) })()} +
+
+ {years.map((y, idx) => { + const v = fpKPIs[y]?.ebit || 0 + const maxAbs = Math.max(...years.map(k => Math.abs(fpKPIs[k]?.ebit || 0)), 1) + const h = Math.abs(v) / maxAbs * 90 return (
-
- {k.ebit >= 0 ? ( -
-
{Math.round(k.ebit/1000)}k
-
- ) : ( -
-
-
{Math.round(k.ebit/1000)}k
-
-
- )} +
+
= 0 ? 'bg-emerald-500/60 rounded-t' : 'bg-red-500/60 rounded-b'} w-full`} style={{ height: `${h}px` }}> +
= 0 ? 'text-emerald-300' : 'text-red-300'} text-center -mt-3.5 whitespace-nowrap font-semibold`}>{fmtK(v)}
+
- {k.year} + {y.slice(1)}
) - }) - })()} + })} +
- -

{de ? 'Personalaufbau' : 'Headcount'}

-
- {(() => { - const years = ['y2026','y2027','y2028','y2029','y2030'] - const data = years.map(y => ({ year: y.slice(1), val: fpKPIs[y]?.headcount || 0 })) - const maxEmp = Math.max(...data.map(d => d.val), 1) - return data.map((d, idx) => ( + setChartDetail('headcount')}> +
+

{de ? 'Personalaufbau' : 'Headcount'}

+ {de ? 'Mitarbeiter (Dez)' : 'Employees (Dec)'} +
+
+
+ {(() => { const m = Math.max(...years.map(y => fpKPIs[y]?.headcount || 0), 1); return [m, Math.round(m/2), 0].map((v,i) => {v}) })()} +
+
+ {years.map((y, idx) => { + const v = fpKPIs[y]?.headcount || 0 + const mx = Math.max(...years.map(k => fpKPIs[k]?.headcount || 0), 1) + return (
-
-
-
{d.val}
+
+
+
{v}
- {d.year} + {y.slice(1)}
- )) - })()} + ) + })} +
- {/* Liquidität Chart */} - -

{de ? 'Liquidität (Jahresende)' : 'Cash Position (Year-End)'}

-
- {(() => { - const years = ['y2026','y2027','y2028','y2029','y2030'] - const data = years.map(y => ({ year: y.slice(1), val: fpKPIs[y]?.liquiditaet || 0 })) - const maxAbs = Math.max(...data.map(d => Math.abs(d.val)), 1) - return data.map((d, idx) => { - const h = Math.abs(d.val) / maxAbs * 100 - const label = Math.abs(d.val) >= 1000000 ? `${(d.val/1000000).toFixed(1)}M` : `${Math.round(d.val/1000)}k` - return ( -
-
-
= 0 ? 'bg-cyan-500/60 rounded-t' : 'bg-red-500/60 rounded-b'} w-full`} style={{ height: `${Math.max(h, 4)}px` }}> -
= 0 ? 'text-cyan-300' : 'text-red-300'} text-center -mt-4 whitespace-nowrap font-semibold`}>{label}
+ {/* Liquidität + Revenue vs Costs */} +
+ setChartDetail('cash')}> +
+

{de ? 'Liquidität' : 'Cash Position'}

+ {de ? 'Jahresende' : 'Year-End'} +
+
+
+ {(() => { const m = Math.max(...years.map(y => Math.abs(fpKPIs[y]?.liquiditaet || 0)), 1); return [fmtK(m), fmtK(m/2), '0'].map((v,i) => {v}) })()} +
+
+ {years.map((y, idx) => { + const v = fpKPIs[y]?.liquiditaet || 0 + const mx = Math.max(...years.map(k => Math.abs(fpKPIs[k]?.liquiditaet || 0)), 1) + const h = Math.abs(v) / mx * 90 + return ( +
+
+
= 0 ? 'bg-cyan-500/60 rounded-t' : 'bg-red-500/60 rounded-b'} w-full`} style={{ height: `${Math.max(h, 4)}px` }}> +
= 0 ? 'text-cyan-300' : 'text-red-300'} text-center -mt-3.5 whitespace-nowrap font-semibold`}>{fmtK(v)}
+
+ {y.slice(1)}
- {d.year} -
- ) - }) - })()} -
-
+ ) + })} +
+
+ - {/* Umsatz vs. Kosten Chart */} - -

{de ? 'Umsatz vs. Gesamtkosten' : 'Revenue vs. Total Costs'}

-
- {(() => { - const years = ['y2026','y2027','y2028','y2029','y2030'] - const data = years.map(y => ({ - year: y.slice(1), - rev: fpKPIs[y]?.revenue || 0, - costs: (fpKPIs[y]?.personal || 0) + Math.abs(fpKPIs[y]?.ebit || 0) + (fpKPIs[y]?.revenue || 0) - (fpKPIs[y]?.ebit || 0) - (fpKPIs[y]?.revenue || 0), - })) - // Simpler: costs = revenue - ebit - const data2 = years.map(y => ({ - year: y.slice(1), - rev: fpKPIs[y]?.revenue || 0, - costs: (fpKPIs[y]?.revenue || 0) - (fpKPIs[y]?.ebit || 0), - })) - const maxVal = Math.max(...data2.map(d => Math.max(d.rev, d.costs)), 1) - return data2.map((d, idx) => ( -
-
-
-
{d.rev >= 1000000 ? `${(d.rev/1000000).toFixed(1)}M` : `${Math.round(d.rev/1000)}k`}
+ setChartDetail('revcost')}> +
+

{de ? 'Umsatz vs. Kosten' : 'Revenue vs. Costs'}

+ €/Jahr +
+
+
+ {(() => { + const d = years.map(y => ({ rev: fpKPIs[y]?.revenue || 0, costs: (fpKPIs[y]?.revenue || 0) - (fpKPIs[y]?.ebit || 0) })) + const mx = Math.max(...d.map(x => Math.max(x.rev, x.costs)), 1) + return [fmtK(mx), fmtK(mx/2), '0'].map((v,i) => {v}) + })()} +
+
+ {(() => { + const data = years.map(y => ({ year: y.slice(1), rev: fpKPIs[y]?.revenue || 0, costs: (fpKPIs[y]?.revenue || 0) - (fpKPIs[y]?.ebit || 0) })) + const mx = Math.max(...data.map(d => Math.max(d.rev, d.costs)), 1) + return data.map((d, idx) => ( +
+
+
+
{fmtK(d.rev)}
+
+
+
{fmtK(d.costs)}
+
+
+ {d.year}
-
-
{d.costs >= 1000000 ? `${(d.costs/1000000).toFixed(1)}M` : `${Math.round(d.costs/1000)}k`}
-
-
- {d.year} -
- )) - })()} -
-
- {de ? 'Umsatz' : 'Revenue'} - {de ? 'Kosten' : 'Costs'} -
- + )) + })()} +
+
+
+ {de ? 'Umsatz' : 'Revenue'} + {de ? 'Kosten' : 'Costs'} +
+ +
- {/* Unit Economics Chart */} + {/* Unit Economics — clickable cards */}

Unit Economics (2026–2030)

{[ - { label: 'ACV', key: 'arpu', unit: '€', color: 'text-indigo-300', bg: 'bg-indigo-500/60' }, - { label: 'Gross Margin', key: 'grossMargin', unit: '%', color: 'text-emerald-300', bg: 'bg-emerald-500/60' }, - { label: 'NRR', key: 'nrr', unit: '%', color: 'text-purple-300', bg: 'bg-purple-500/60' }, - { label: 'EBIT Margin', key: 'ebitMargin', unit: '%', color: 'text-amber-300', bg: 'bg-amber-500/60' }, + { label: 'ACV', key: 'arpu', unit: '€', color: 'text-indigo-300', bg: 'bg-indigo-500/60', detail: 'acv' }, + { label: 'Gross Margin', key: 'grossMargin', unit: '%', color: 'text-emerald-300', bg: 'bg-emerald-500/60', detail: 'grossMargin' }, + { label: 'NRR', key: 'nrr', unit: '%', color: 'text-purple-300', bg: 'bg-purple-500/60', detail: 'nrr' }, + { label: 'EBIT Margin', key: 'ebitMargin', unit: '%', color: 'text-amber-300', bg: 'bg-amber-500/60', detail: 'ebitMargin' }, ].map((metric, mIdx) => ( -
+
setChartDetail(metric.detail)}>

{metric.label}

-
+
{[2026,2027,2028,2029,2030].map((y, idx) => { const val = fpKPIs[`y${y}`]?.[metric.key as keyof typeof fpKPIs['y2026']] as number || 0 const maxVal = Math.max(...[2026,2027,2028,2029,2030].map(yr => Math.abs(fpKPIs[`y${yr}`]?.[metric.key as keyof typeof fpKPIs['y2026']] as number || 0)), 1) - const h = Math.abs(val) / maxVal * 60 + const h = Math.abs(val) / maxVal * 50 return (
= 0 ? metric.bg + ' rounded-t' : 'bg-red-500/60 rounded-b'}`} style={{ height: `${Math.max(h, 2)}px` }} /> @@ -497,7 +559,7 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId,

{(() => { const v = fpKPIs.y2030?.[metric.key as keyof typeof fpKPIs['y2026']] as number || 0 - return metric.unit === '€' ? `${v.toLocaleString('de-DE')}${metric.unit}` : `${v}${metric.unit}` + return metric.unit === '€' ? `${fmtV(v)}${metric.unit}` : `${v}${metric.unit}` })()}

2030

@@ -506,7 +568,8 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId,
- )} + ) + })()} {/* Year Navigation — not for GuV, KPIs, Charts */} {!['guv', 'kpis', 'charts'].includes(activeSheet) && ( diff --git a/pitch-deck/middleware.ts b/pitch-deck/middleware.ts index fc6dfe4..2cfd3ba 100644 --- a/pitch-deck/middleware.ts +++ b/pitch-deck/middleware.ts @@ -6,7 +6,6 @@ const PUBLIC_PATHS = [ '/auth', // investor login pages '/api/auth', // investor auth API '/api/health', - '/api/admin/fp-patch', '/api/admin-auth', // admin login API '/pitch-admin/login', // admin login page '/_next',