Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook) and split all 44 files exceeding 500 LOC into domain-focused modules: - consent-service (Go): models, handlers, services, database splits - backend-core (Python): security_api, rbac_api, pdf_service, auth splits - admin-core (TypeScript): 5 page.tsx + sidebar extractions - pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits - voice-service (Python): enhanced_task_orchestrator split Result: 0 violations, 36 exempted (pipeline, tests, pure-data files). Go build verified clean. No behavior changes — pure structural splits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
241 lines
11 KiB
TypeScript
241 lines
11 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useEffect, useState } from 'react'
|
||
import { Language } 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
|
||
}
|
||
|
||
export default function FinanzplanSlide({ lang, investorId, preferredScenarioId, isWandeldarlehen }: 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>('')
|
||
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 + scenarios
|
||
useEffect(() => {
|
||
fetch('/api/finanzplan', { cache: 'no-store' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
setSheets(data.sheets || [])
|
||
const scens: FpScenario[] = data.scenarios || []
|
||
setScenarios(scens)
|
||
// Pick scenario: Wandeldarlehen version → WD scenario, otherwise default
|
||
if (!selectedScenarioId) {
|
||
const wdScenario = isWandeldarlehen ? scens.find(s => s.name.toLowerCase().includes('wandeldarlehen') && !s.name.toLowerCase().includes('bear') && !s.name.toLowerCase().includes('bull')) : null
|
||
const def = wdScenario ?? 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 ? '2026–2030 · Alle Werte in EUR · Monatliche Granularität' : '2026–2030 · 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>
|
||
)
|
||
}
|