Files
breakpilot-core/pitch-deck/components/ui/AnnualPLTable.tsx
Benjamin Admin 92c86ec6ba [split-required] [guardrail-change] Enforce 500 LOC budget across all services
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>
2026-04-27 00:09:30 +02:00

180 lines
5.8 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { motion } from 'framer-motion'
import { Maximize2 } from 'lucide-react'
import { FMResult } from '@/lib/types'
import {
AnnualPLTableProps,
AnnualRow,
MonthlyRow,
AccountingStandard,
} from './AnnualPLTable.types'
import { AnnualTable, FullscreenOverlay } from './AnnualPLTable.parts'
export default function AnnualPLTable({ results, lang }: AnnualPLTableProps) {
const [isFullscreen, setIsFullscreen] = useState(false)
const [expandedYear, setExpandedYear] = useState<number | null>(null)
const [standard, setStandard] = useState<AccountingStandard>('hgb')
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null)
const de = lang === 'de'
// Portal mount point
useEffect(() => {
setPortalRoot(document.body)
}, [])
// Aggregate monthly results into annual
const annualMap = new Map<number, FMResult[]>()
for (const r of results) {
if (!annualMap.has(r.year)) annualMap.set(r.year, [])
annualMap.get(r.year)!.push(r)
}
const rows: AnnualRow[] = Array.from(annualMap.entries()).map(([year, months]) => {
const revenue = months.reduce((s, m) => s + m.revenue_eur, 0)
const cogs = months.reduce((s, m) => s + m.cogs_eur, 0)
const grossProfit = revenue - cogs
const personnel = months.reduce((s, m) => s + m.personnel_eur, 0)
const marketing = months.reduce((s, m) => s + m.marketing_eur, 0)
const infra = months.reduce((s, m) => s + m.infra_eur, 0)
const totalOpex = personnel + marketing + infra
const ebitda = grossProfit - totalOpex
const lastMonth = months[months.length - 1]
return {
year,
revenue,
cogs,
grossProfit,
grossMarginPct: revenue > 0 ? (grossProfit / revenue) * 100 : 0,
personnel,
marketing,
infra,
totalOpex,
ebitda,
ebitdaMarginPct: revenue > 0 ? (ebitda / revenue) * 100 : 0,
customers: lastMonth.total_customers,
employees: lastMonth.employees_count,
}
})
// Build monthly data for drill-down
const monthlyData = new Map<number, MonthlyRow[]>()
for (const [year, months] of annualMap.entries()) {
monthlyData.set(year, months.map(m => {
const grossProfit = m.revenue_eur - m.cogs_eur
const totalCosts = m.personnel_eur + m.marketing_eur + m.infra_eur
const ebitda = grossProfit - totalCosts
return {
month: m.month,
monthInYear: m.month_in_year,
revenue: m.revenue_eur,
cogs: m.cogs_eur,
grossProfit,
grossMarginPct: m.revenue_eur > 0 ? (grossProfit / m.revenue_eur) * 100 : 0,
personnel: m.personnel_eur,
marketing: m.marketing_eur,
infra: m.infra_eur,
totalCosts,
ebitda,
ebitdaMarginPct: m.revenue_eur > 0 ? (ebitda / m.revenue_eur) * 100 : 0,
customers: m.total_customers,
employees: m.employees_count,
mrr: m.mrr_eur,
cashBalance: m.cash_balance_eur,
}
}))
}
const handleToggleYear = (year: number) => {
setExpandedYear(prev => prev === year ? null : year)
}
const tableContent = (
<AnnualTable
rows={rows}
lang={lang}
expandedYear={expandedYear}
onToggleYear={handleToggleYear}
monthlyData={monthlyData}
isFullscreen={isFullscreen}
standard={standard}
/>
)
return (
<>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div className="flex items-center justify-between mb-2">
{/* HGB / US GAAP Toggle (inline) */}
<div className="flex items-center bg-white/[0.04] border border-white/5 rounded-lg overflow-hidden">
<button
onClick={() => setStandard('hgb')}
className={`px-2 py-0.5 text-[10px] font-medium transition-all ${
standard === 'hgb'
? 'bg-indigo-500/20 text-indigo-300'
: 'text-white/30 hover:text-white/50'
}`}
>
HGB
</button>
<button
onClick={() => setStandard('usgaap')}
className={`px-2 py-0.5 text-[10px] font-medium transition-all ${
standard === 'usgaap'
? 'bg-indigo-500/20 text-indigo-300'
: 'text-white/30 hover:text-white/50'
}`}
>
US GAAP
</button>
</div>
<button
onClick={() => setIsFullscreen(true)}
className="flex items-center gap-1.5 px-2 py-1 bg-white/[0.06] hover:bg-white/[0.1] border border-white/10 rounded-lg text-[10px] text-white/50 hover:text-white/80 transition-all"
title={de ? 'Vollbild' : 'Fullscreen'}
>
<Maximize2 className="w-3 h-3" />
{de ? 'Vollbild' : 'Fullscreen'}
</button>
</div>
<div className="overflow-x-auto">
{tableContent}
</div>
<p className="text-[9px] text-white/20 mt-2 text-center">
{de
? 'Klicke auf ein Jahr fuer Monatsdetails'
: 'Click on a year for monthly details'}
</p>
</motion.div>
{/* Fullscreen via Portal — escapes parent stacking context */}
{isFullscreen && portalRoot && createPortal(
<FullscreenOverlay
onClose={() => setIsFullscreen(false)}
lang={lang}
standard={standard}
onStandardChange={setStandard}
>
<AnnualTable
rows={rows}
lang={lang}
expandedYear={expandedYear}
onToggleYear={handleToggleYear}
monthlyData={monthlyData}
isFullscreen={true}
standard={standard}
/>
</FullscreenOverlay>,
portalRoot
)}
</>
)
}