fix(pitch-deck): KPIs + Charts on Folie 28 now read from fp_* tables directly
All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m12s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 34s
CI / test-python-voice (push) Successful in 34s
CI / test-bqas (push) Successful in 29s

Previously KPIs/Charts used useFinancialModel (simplified model) which had
different assumptions than the fp_* tables (source of truth).

Now: KPIs tab loads from fp_guv, fp_liquiditaet, fp_personalkosten, fp_kunden
via API. Charts (MRR, EBIT, Headcount) also use fp_* data.

Removed dependency on useFinancialModel and computeAnnualKPIs for this slide.
Added Liquidität (Dez) row to KPIs table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-19 08:37:46 +02:00
parent 3b8f9b595e
commit 607dab4f26

View File

@@ -1,10 +1,8 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Language } from '@/lib/types' import { Language } from '@/lib/types'
import { t } from '@/lib/i18n' import { t } from '@/lib/i18n'
import { useFinancialModel } from '@/lib/hooks/useFinancialModel'
import { computeAnnualKPIs } from '@/lib/finanzplan/annual-kpis'
import ProjectionFooter from '../ui/ProjectionFooter' import ProjectionFooter from '../ui/ProjectionFooter'
import GradientText from '../ui/GradientText' import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView' import FadeInView from '../ui/FadeInView'
@@ -103,12 +101,60 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId
const [yearOffset, setYearOffset] = useState(0) // 0=2026, 1=2027, ... const [yearOffset, setYearOffset] = useState(0) // 0=2026, 1=2027, ...
const de = lang === 'de' const de = lang === 'de'
// Financial model — same source as FinancialsSlide (Slide 15) // KPIs loaded directly from fp_* tables (source of truth)
const fm = useFinancialModel(investorId || null, preferredScenarioId) const [fpKPIs, setFpKPIs] = useState<Record<string, Record<string, number>>>({})
const annualKPIs = useMemo(
() => computeAnnualKPIs(fm.activeResults?.results || []), useEffect(() => {
[fm.activeResults], async function loadKPIs() {
) const param = selectedScenarioId ? `?scenarioId=${selectedScenarioId}` : ''
try {
const [guvRes, liqRes, persRes, kundenRes] = await Promise.all([
fetch(`/api/finanzplan/guv${param}`, { cache: 'no-store' }),
fetch(`/api/finanzplan/liquiditaet${param}`, { cache: 'no-store' }),
fetch(`/api/finanzplan/personalkosten${param}`, { cache: 'no-store' }),
fetch(`/api/finanzplan/kunden${param}`, { cache: 'no-store' }),
])
const [guv, liq, pers, kunden] = await Promise.all([guvRes.json(), liqRes.json(), persRes.json(), kundenRes.json()])
const guvRows = guv.rows || []
const liqRows = liq.rows || []
const persRows = pers.rows || []
const kundenRows = kunden.rows || []
const findGuv = (label: string) => guvRows.find((r: SheetRow) => (r.row_label || '').includes(label))
const findLiq = (label: string) => liqRows.find((r: SheetRow) => (r.row_label || '').includes(label))
const kundenGesamt = kundenRows.find((r: SheetRow) => r.row_label === 'Bestandskunden gesamt')
const years = [2026, 2027, 2028, 2029, 2030]
const kpis: Record<string, Record<string, number>> = {}
for (const y of years) {
const yk = `y${y}`
const m12 = (y - 2026) * 12 + 12 // December of each year
const mk = `m${m12}`
const revenue = findGuv('Umsatzerlöse')?.values?.[yk] || 0
const ebit = findGuv('EBIT')?.values?.[yk] || 0
const personal = findGuv('Summe Personalaufwand')?.values?.[yk] || 0
const netIncome = findGuv('Jahresüberschuss')?.values?.[yk] || findGuv('Jahresueber')?.values?.[yk] || 0
const steuern = findGuv('Steuern gesamt')?.values?.[yk] || 0
const liquiditaet = findLiq('LIQUIDIT')?.values?.[mk] || findLiq('LIQUIDITAET')?.values?.[mk] || 0
const customers = kundenGesamt?.values?.[mk] || 0
const headcount = persRows.filter((r: SheetRow) => ((r.values_total || r.values)?.[mk] || 0) > 0).length
const mrr = revenue > 0 ? Math.round(revenue / 12) : 0
const arr = mrr * 12
const arpu = customers > 0 ? Math.round(mrr / customers) : 0
const revPerEmp = headcount > 0 ? Math.round(revenue / headcount) : 0
const ebitMargin = revenue > 0 ? Math.round((ebit / revenue) * 100) : 0
const burnRate = liquiditaet < 0 ? Math.round(Math.abs(ebit / 12)) : 0
kpis[yk] = { revenue, ebit, personal, netIncome, steuern, liquiditaet, customers, headcount, mrr, arr, arpu, revPerEmp, ebitMargin, burnRate }
}
setFpKPIs(kpis)
} catch { /* ignore */ }
}
loadKPIs()
}, [selectedScenarioId])
// Load sheet list + scenarios // Load sheet list + scenarios
useEffect(() => { useEffect(() => {
@@ -247,25 +293,25 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId
</thead> </thead>
<tbody> <tbody>
{(() => { {(() => {
if (annualKPIs.length === 0) return ( const years = ['y2026', 'y2027', 'y2028', 'y2029', 'y2030']
<tr><td colSpan={6} className="text-center py-4 text-white/30">{de ? 'Finanzmodell wird geladen...' : 'Loading financial model...'}</td></tr> if (!fpKPIs['y2026']) return (
<tr><td colSpan={6} className="text-center py-4 text-white/30">{de ? 'Finanzplan wird geladen...' : 'Loading financial plan...'}</td></tr>
) )
const v = (yk: string, key: string) => fpKPIs[yk]?.[key] || 0
const kpiRows = [ const kpiRows = [
{ label: 'MRR (Dez)', values: annualKPIs.map(k => k.mrr), unit: '€', bold: true }, { label: 'MRR (Dez)', values: years.map(y => v(y, 'mrr')), unit: '€', bold: true },
{ label: 'ARR', values: annualKPIs.map(k => k.arr), unit: '€', bold: true }, { label: 'ARR', values: years.map(y => v(y, 'arr')), unit: '€', bold: true },
{ label: de ? 'Kunden (Dez)' : 'Customers (Dec)', values: annualKPIs.map(k => k.customers), unit: '', bold: false }, { label: de ? 'Kunden (Dez)' : 'Customers (Dec)', values: years.map(y => v(y, 'customers')), unit: '', bold: false },
{ label: 'ARPU (MRR/Kunden)', values: annualKPIs.map(k => k.arpu), unit: '€', bold: false }, { label: 'ARPU (MRR/Kunden)', values: years.map(y => v(y, 'arpu')), unit: '€', bold: false },
{ label: de ? 'Mitarbeiter' : 'Employees', values: annualKPIs.map(k => k.employees), unit: '', bold: false }, { label: de ? 'Mitarbeiter' : 'Employees', values: years.map(y => v(y, 'headcount')), unit: '', bold: false },
{ label: de ? 'Umsatz/Mitarbeiter' : 'Revenue/Employee', values: annualKPIs.map(k => k.revenuePerEmployee), unit: '€', bold: false }, { label: de ? 'Umsatz/Mitarbeiter' : 'Revenue/Employee', values: years.map(y => v(y, 'revPerEmp')), unit: '€', bold: false },
{ label: de ? 'Personalkosten' : 'Personnel Costs', values: annualKPIs.map(k => k.personnelCosts), unit: '€', bold: false }, { label: de ? 'Personalkosten' : 'Personnel Costs', values: years.map(y => v(y, 'personal')), unit: '€', bold: false },
{ label: 'EBIT', values: annualKPIs.map(k => k.ebit), unit: '€', bold: true }, { label: 'EBIT', values: years.map(y => v(y, 'ebit')), unit: '€', bold: true },
{ label: de ? 'EBIT-Marge' : 'EBIT Margin', values: annualKPIs.map(k => k.ebitMargin), unit: '%', bold: false }, { label: de ? 'EBIT-Marge' : 'EBIT Margin', values: years.map(y => v(y, 'ebitMargin')), unit: '%', bold: false },
{ label: de ? 'Steuern' : 'Taxes', values: annualKPIs.map(k => k.taxes), unit: '€', bold: false }, { label: de ? 'Steuern' : 'Taxes', values: years.map(y => v(y, 'steuern')), unit: '€', bold: false },
{ label: de ? 'Jahresueberschuss' : 'Net Income', values: annualKPIs.map(k => k.netIncome), unit: '€', bold: true }, { label: de ? 'Jahresüberschuss' : 'Net Income', values: years.map(y => v(y, 'netIncome')), unit: '€', bold: true },
{ label: de ? 'Serverkosten/Kunde' : 'Server Cost/Customer', values: annualKPIs.map(k => k.serverCostPerCustomer), unit: '€', bold: false }, { label: de ? 'Liquidität (Dez)' : 'Cash (Dec)', values: years.map(y => v(y, 'liquiditaet')), unit: '€', bold: true },
{ label: de ? 'Bruttomarge' : 'Gross Margin', values: annualKPIs.map(k => k.grossMargin), unit: '%', bold: false }, { label: 'Burn Rate', values: years.map(y => v(y, 'burnRate')), unit: '€/Mo', bold: false },
{ label: 'Burn Rate (Dez)', values: annualKPIs.map(k => k.burnRate), unit: '€/Mo', bold: false },
{ label: de ? 'Runway (Monate)' : 'Runway (months)', values: annualKPIs.map(k => k.runway === null ? '∞' : k.runway), unit: '', bold: false },
] ]
return kpiRows.map((row, idx) => ( return kpiRows.map((row, idx) => (
<tr key={idx} className={`border-b border-white/[0.03] ${row.bold ? 'bg-white/[0.03]' : ''}`}> <tr key={idx} className={`border-b border-white/[0.03] ${row.bold ? 'bg-white/[0.03]' : ''}`}>
@@ -299,9 +345,11 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId
<h3 className="text-xs font-bold text-indigo-400 uppercase tracking-wider mb-3">{de ? 'MRR & Kundenentwicklung' : 'MRR & Customer Growth'}</h3> <h3 className="text-xs font-bold text-indigo-400 uppercase tracking-wider mb-3">{de ? 'MRR & Kundenentwicklung' : 'MRR & Customer Growth'}</h3>
<div className="grid grid-cols-5 gap-1 items-end h-48"> <div className="grid grid-cols-5 gap-1 items-end h-48">
{(() => { {(() => {
const maxMrr = Math.max(...annualKPIs.map(k => k.mrr), 1) const years = ['y2026','y2027','y2028','y2029','y2030']
const maxCust = Math.max(...annualKPIs.map(k => k.customers), 1) const data = years.map(y => ({ year: y.slice(1), mrr: fpKPIs[y]?.mrr || 0, cust: fpKPIs[y]?.customers || 0 }))
return annualKPIs.map(k => ({ year: String(k.year), mrr: k.mrr, cust: k.customers, max_mrr: maxMrr, max_cust: maxCust })) 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) => ( })().map((d, idx) => (
<div key={idx} className="flex flex-col items-center gap-1"> <div key={idx} className="flex flex-col items-center gap-1">
<div className="flex items-end gap-1 w-full justify-center" style={{ height: '160px' }}> <div className="flex items-end gap-1 w-full justify-center" style={{ height: '160px' }}>
@@ -332,8 +380,10 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId
<h3 className="text-xs font-bold text-purple-400 uppercase tracking-wider mb-3">EBIT</h3> <h3 className="text-xs font-bold text-purple-400 uppercase tracking-wider mb-3">EBIT</h3>
<div className="grid grid-cols-5 gap-1 items-end h-36"> <div className="grid grid-cols-5 gap-1 items-end h-36">
{(() => { {(() => {
const maxAbs = Math.max(...annualKPIs.map(k => Math.abs(k.ebit)), 1) const years = ['y2026','y2027','y2028','y2029','y2030']
return annualKPIs.map((k, idx) => { 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 const h = Math.abs(k.ebit) / maxAbs * 100
return ( return (
<div key={idx} className="flex flex-col items-center"> <div key={idx} className="flex flex-col items-center">
@@ -362,9 +412,10 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId
<h3 className="text-xs font-bold text-amber-400 uppercase tracking-wider mb-3">{de ? 'Personalaufbau' : 'Headcount'}</h3> <h3 className="text-xs font-bold text-amber-400 uppercase tracking-wider mb-3">{de ? 'Personalaufbau' : 'Headcount'}</h3>
<div className="grid grid-cols-5 gap-1 items-end h-36"> <div className="grid grid-cols-5 gap-1 items-end h-36">
{(() => { {(() => {
const maxEmp = Math.max(...annualKPIs.map(k => k.employees), 1) const years = ['y2026','y2027','y2028','y2029','y2030']
return annualKPIs.map(k => ({ year: String(k.year), val: k.employees })) const data = years.map(y => ({ year: y.slice(1), val: fpKPIs[y]?.headcount || 0 }))
.map((d, idx) => ( const maxEmp = Math.max(...data.map(d => d.val), 1)
return data.map((d, idx) => (
<div key={idx} className="flex flex-col items-center"> <div key={idx} className="flex flex-col items-center">
<div className="w-10 flex flex-col justify-end" style={{ height: '110px' }}> <div className="w-10 flex flex-col justify-end" style={{ height: '110px' }}>
<div className="bg-amber-500/60 rounded-t w-full" style={{ height: `${(d.val / maxEmp) * 100}px` }}> <div className="bg-amber-500/60 rounded-t w-full" style={{ height: `${(d.val / maxEmp) * 100}px` }}>