Files
breakpilot-core/pitch-deck/components/slides/FinanzplanSlide.tsx
T
Sharang Parnerkar f2184be02f
Build pitch-deck / build-push-deploy (push) Successful in 1m34s
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 36s
CI / test-python-voice (push) Successful in 35s
CI / test-bqas (push) Successful in 32s
fix: tab row counts use investor's scenario, not always Base Case
/api/finanzplan now accepts ?scenarioId and uses it for the per-sheet
row counts (the numbers in brackets on the tab bar). FinanzplanSlide
passes fpBaseScenarioId when fetching the sheet list, so Wandeldarlehen
investors see e.g. Personalkosten (9) instead of (35).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:21:40 +02:00

245 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Language, FpScenarioRef } from '@/lib/types'
import ProjectionFooter from '../ui/ProjectionFooter'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import GlassCard from '../ui/GlassCard'
import { BarChart3, Target } from 'lucide-react'
import KPIsTab from './FinanzplanSlide.kpis'
import ChartsTab from './FinanzplanSlide.charts'
import SKRTab from './FinanzplanSlide.skr'
import {
SheetMeta, SheetRow, FpScenario,
} from './FinanzplanSlide.helpers'
import { GuvTable, MonthlyGrid } from './FinanzplanSlide.datagrid'
interface FinanzplanSlideProps {
lang: Language
investorId?: string | null
preferredScenarioId?: string | null
isWandeldarlehen?: boolean
fpBaseScenarioId?: string | null
fpScenarios?: FpScenarioRef[]
}
export default function FinanzplanSlide({ lang, investorId, preferredScenarioId, isWandeldarlehen, fpBaseScenarioId, fpScenarios }: FinanzplanSlideProps) {
const [sheets, setSheets] = useState<SheetMeta[]>([])
const [scenarios, setScenarios] = useState<FpScenario[]>([])
const [openCats, setOpenCats] = useState<Set<string>>(new Set())
const toggleCat = (cat: string) => setOpenCats(prev => { const n = new Set(prev); n.has(cat) ? n.delete(cat) : n.add(cat); return n })
const [selectedScenarioId, setSelectedScenarioId] = useState<string>(fpBaseScenarioId ?? '')
const [activeSheet, setActiveSheet] = useState<string>('guv')
const [rows, setRows] = useState<SheetRow[]>([])
const [loading, setLoading] = useState(false)
const [yearOffset, setYearOffset] = useState(0) // 0=2026, 1=2027, ...
const [chartDetail, setChartDetail] = useState<string | null>(null)
const de = lang === 'de'
// KPIs loaded directly from fp_* tables (source of truth)
const [fpKPIs, setFpKPIs] = useState<Record<string, Record<string, number>>>({})
useEffect(() => {
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
// MRR = December monthly revenue from Liquidität (not annual average)
const liqUmsatz = findLiq('Umsatzerlöse')
const mrr = Math.round(liqUmsatz?.values?.[mk] || 0)
const arr = mrr * 12 // ARR = December MRR × 12 (annualized run-rate)
const arpu = customers > 0 ? Math.round(revenue / customers) : 0 // ACV = annual revenue / customers
const revPerEmp = headcount > 0 ? Math.round(revenue / headcount) : 0
const ebitMargin = revenue > 0 ? Math.round((ebit / revenue) * 100) : 0
const burnRate = ebit < 0 ? Math.round(Math.abs(ebit / 12)) : 0 // show when EBIT negative, not cash
const material = findGuv('Summe Materialaufwand')?.values?.[yk] || 0
const grossMargin = revenue > 0 ? Math.round(((revenue - material) / revenue) * 100) : 0
const prevRevenue = y > 2026 ? (findGuv('Umsatzerlöse')?.values?.[`y${y - 1}`] || 0) : 0
const nrr = prevRevenue > 0 ? Math.round((revenue / prevRevenue) * 100) : 0 // Revenue Growth (proxy for NRR)
kpis[yk] = { revenue, ebit, personal, netIncome, steuern, liquiditaet, customers, headcount, mrr, arr, arpu, revPerEmp, ebitMargin, burnRate, grossMargin, nrr }
}
setFpKPIs(kpis)
} catch { /* ignore */ }
}
loadKPIs()
}, [selectedScenarioId])
// Load sheet list; populate scenario selector from version data or API fallback
useEffect(() => {
const listParam = fpBaseScenarioId ? `?scenarioId=${fpBaseScenarioId}` : ''
fetch(`/api/finanzplan${listParam}`, { cache: 'no-store' })
.then(r => r.json())
.then(data => {
setSheets(data.sheets || [])
// Use version fp_scenarios if available, else fall back to API list
const scens: FpScenario[] = fpScenarios && fpScenarios.length > 0
? fpScenarios.map(s => ({ id: s.id, name: s.name, is_default: s.is_default ?? false, color: s.color ?? '#6366f1', description: s.description ?? '' }))
: data.scenarios || []
setScenarios(scens)
if (!selectedScenarioId) {
const def = scens.find(s => s.is_default) ?? scens[0]
if (def) setSelectedScenarioId(def.id)
}
})
.catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const scenarioParam = selectedScenarioId ? `?scenarioId=${selectedScenarioId}` : ''
// Load sheet data
const loadSheet = useCallback(async (name: string) => {
if (name === 'kpis' || name === 'charts' || name === 'skr') {
setRows([])
setLoading(false)
return
}
setLoading(true)
try {
const r = await fetch(`/api/finanzplan/${name}${scenarioParam}`, { cache: 'no-store' })
const data = await r.json()
setRows(data.rows || [])
} catch { /* ignore */ }
setLoading(false)
}, [scenarioParam])
useEffect(() => { loadSheet(activeSheet) }, [activeSheet, loadSheet])
return (
<div className="max-w-[95vw] mx-auto">
<FadeInView className="text-center mb-4">
<h2 className="text-3xl md:text-4xl font-bold mb-1">
<GradientText>{de ? 'Finanzplan' : 'Financial Plan'}</GradientText>
</h2>
<p className="text-sm text-white/40">{de ? '20262030 · Alle Werte in EUR · Monatliche Granularität' : '20262030 · All values in EUR · Monthly granularity'}</p>
</FadeInView>
{/* Tab Bar */}
<div className="flex items-center gap-1 mb-3 overflow-x-auto pb-1">
{sheets.map(s => (
<button
key={s.name}
onClick={() => setActiveSheet(s.name)}
className={`px-3 py-1.5 text-[10px] font-medium rounded-lg whitespace-nowrap transition-colors ${
activeSheet === s.name
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
: 'text-white/40 hover:text-white/70 hover:bg-white/[0.05]'
}`}
>
{de ? s.label_de : s.label_en}
{s.rows > 0 && <span className="ml-1 text-[8px] opacity-50">({s.rows})</span>}
</button>
))}
{/* KPIs + Grafiken Tabs */}
<span className="text-white/10 mx-1">|</span>
{[
{ id: 'kpis', label: 'KPIs', icon: Target },
{ id: 'charts', label: de ? 'Grafiken' : 'Charts', icon: BarChart3 },
{ id: 'skr', label: 'Kontenrahmen (SKR04)', icon: BarChart3 },
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveSheet(tab.id)}
className={`px-3 py-1.5 text-[10px] font-medium rounded-lg whitespace-nowrap transition-colors flex items-center gap-1 ${
activeSheet === tab.id
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
: 'text-white/40 hover:text-white/70 hover:bg-white/[0.05]'
}`}
>
<tab.icon className="w-3 h-3" />
{tab.label}
</button>
))}
</div>
{/* === KPIs Tab === */}
{activeSheet === 'kpis' && <KPIsTab fpKPIs={fpKPIs} de={de} />}
{/* === Charts Tab === */}
{activeSheet === 'charts' && (
<ChartsTab fpKPIs={fpKPIs} de={de} chartDetail={chartDetail} setChartDetail={setChartDetail} />
)}
{/* === Kontenrahmen SKR04 === */}
{activeSheet === 'skr' && <SKRTab de={de} />}
{/* Year Navigation — not for GuV, KPIs, Charts */}
{!['guv', 'kpis', 'charts', 'skr'].includes(activeSheet) && (
<div className="flex items-center justify-center gap-1 mb-2">
<button
onClick={() => setYearOffset(-1)}
className={`px-3 py-1 text-[10px] font-medium rounded-lg transition-colors ${yearOffset === -1 ? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30' : 'text-white/40 hover:text-white/70 hover:bg-white/[0.05]'}`}
>
{de ? 'Alle Jahre' : 'All Years'}
</button>
{[2026, 2027, 2028, 2029, 2030].map((y, idx) => (
<button
key={y}
onClick={() => setYearOffset(idx)}
className={`px-3 py-1 text-[10px] font-medium rounded-lg transition-colors ${yearOffset === idx ? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30' : 'text-white/40 hover:text-white/70 hover:bg-white/[0.05]'}`}
>
{y}
</button>
))}
</div>
)}
{/* Data Grid — not shown for KPIs and Charts */}
{!['kpis', 'charts', 'skr'].includes(activeSheet) && (
<GlassCard hover={false} className="p-2 overflow-x-auto">
{loading ? (
<div className="text-center py-8 text-white/30 text-sm">{de ? 'Lade...' : 'Loading...'}</div>
) : activeSheet === 'guv' ? (
<GuvTable rows={rows} de={de} />
) : (
<MonthlyGrid
rows={rows}
activeSheet={activeSheet}
de={de}
yearOffset={yearOffset}
openCats={openCats}
toggleCat={toggleCat}
/>
)}
</GlassCard>
)}
<ProjectionFooter lang={lang} />
</div>
)
}