feat: Add DevSecOps tools, Woodpecker proxy, Vault persistent storage, pitch-deck annex slides
All checks were successful
CI / test-bqas (push) Successful in 32s
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 46s
CI / test-python-voice (push) Successful in 38s
All checks were successful
CI / test-bqas (push) Successful in 32s
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 46s
CI / test-python-voice (push) Successful in 38s
- Install Gitleaks, Trivy, Grype, Syft, Semgrep, Bandit in backend-core Dockerfile - Add Woodpecker SQLite proxy API (fallback without API token) - Mount woodpecker_data volume read-only to backend-core - Add backend proxy fallback in admin-core Woodpecker route - Add Vault file-based persistent storage (config.hcl, init-vault.sh) - Auto-init, unseal and root-token persistence for Vault - Add 6 pitch-deck annex slides (Assumptions, Architecture, GTM, Regulatory, Engineering, AI Pipeline) - Dynamic margin/amortization KPIs in BusinessModelSlide - Market sources modal with citations in MarketSlide - Redesign nginx landing page to 3-column layout (Lehrer/Compliance/Core) - Extend MkDocs nav with Services and SDK documentation sections - Add SDK Protection architecture doc Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { FMResult } from '@/lib/types'
|
||||
import { Maximize2, Minimize2, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface AnnualPLTableProps {
|
||||
results: FMResult[]
|
||||
lang: 'de' | 'en'
|
||||
}
|
||||
|
||||
type AccountingStandard = 'hgb' | 'usgaap'
|
||||
|
||||
interface AnnualRow {
|
||||
year: number
|
||||
revenue: number
|
||||
@@ -24,13 +29,397 @@ interface AnnualRow {
|
||||
employees: number
|
||||
}
|
||||
|
||||
interface MonthlyRow {
|
||||
month: number
|
||||
monthInYear: number
|
||||
revenue: number
|
||||
cogs: number
|
||||
grossProfit: number
|
||||
grossMarginPct: number
|
||||
personnel: number
|
||||
marketing: number
|
||||
infra: number
|
||||
totalCosts: number
|
||||
ebitda: number
|
||||
ebitdaMarginPct: number
|
||||
customers: number
|
||||
employees: number
|
||||
mrr: number
|
||||
cashBalance: number
|
||||
}
|
||||
|
||||
function fmt(v: number): string {
|
||||
if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
|
||||
if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(0)}k`
|
||||
return Math.round(v).toLocaleString('de-DE')
|
||||
}
|
||||
|
||||
function fmtMonth(v: number): string {
|
||||
return Math.round(v).toLocaleString('de-DE')
|
||||
}
|
||||
|
||||
const MONTH_NAMES_DE = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||
const MONTH_NAMES_EN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
|
||||
function getLineItems(lang: 'de' | 'en', standard: AccountingStandard) {
|
||||
const de = lang === 'de'
|
||||
const hgb = standard === 'hgb'
|
||||
|
||||
return [
|
||||
{
|
||||
label: hgb
|
||||
? (de ? 'Umsatzerloese' : 'Revenue (Umsatzerloese)')
|
||||
: (de ? 'Revenue' : 'Revenue'),
|
||||
key: 'revenue' as keyof AnnualRow,
|
||||
monthKey: 'revenue' as keyof MonthlyRow,
|
||||
isBold: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? '- Herstellungskosten' : '- Cost of Production')
|
||||
: (de ? '- COGS' : '- Cost of Goods Sold'),
|
||||
key: 'cogs' as keyof AnnualRow,
|
||||
monthKey: 'cogs' as keyof MonthlyRow,
|
||||
isNegative: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? '= Rohertrag' : '= Gross Profit')
|
||||
: (de ? '= Gross Profit' : '= Gross Profit'),
|
||||
key: 'grossProfit' as keyof AnnualRow,
|
||||
monthKey: 'grossProfit' as keyof MonthlyRow,
|
||||
isBold: true,
|
||||
isSeparator: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? ' Rohertragsmarge' : ' Gross Margin')
|
||||
: (de ? ' Gross Margin' : ' Gross Margin'),
|
||||
key: 'grossMarginPct' as keyof AnnualRow,
|
||||
monthKey: 'grossMarginPct' as keyof MonthlyRow,
|
||||
isPercent: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? '- Personalaufwand' : '- Personnel Expenses')
|
||||
: (de ? '- Personnel' : '- Personnel'),
|
||||
key: 'personnel' as keyof AnnualRow,
|
||||
monthKey: 'personnel' as keyof MonthlyRow,
|
||||
isNegative: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? '- Vertrieb & Marketing' : '- Sales & Marketing')
|
||||
: (de ? '- Sales & Marketing' : '- Sales & Marketing'),
|
||||
key: 'marketing' as keyof AnnualRow,
|
||||
monthKey: 'marketing' as keyof MonthlyRow,
|
||||
isNegative: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? '- sonstige betriebl. Aufwendungen' : '- Other Operating Expenses')
|
||||
: (de ? '- Infrastructure' : '- Infrastructure'),
|
||||
key: 'infra' as keyof AnnualRow,
|
||||
monthKey: 'infra' as keyof MonthlyRow,
|
||||
isNegative: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? '= Betriebsaufwand gesamt' : '= Total Operating Expenses')
|
||||
: (de ? '= Total OpEx' : '= Total OpEx'),
|
||||
key: 'totalOpex' as keyof AnnualRow,
|
||||
monthKey: 'totalCosts' as keyof MonthlyRow,
|
||||
isBold: true,
|
||||
isSeparator: true,
|
||||
isNegative: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? 'Betriebsergebnis (EBITDA)' : 'Operating Result (EBITDA)')
|
||||
: 'EBITDA',
|
||||
key: 'ebitda' as keyof AnnualRow,
|
||||
monthKey: 'ebitda' as keyof MonthlyRow,
|
||||
isBold: true,
|
||||
isSeparator: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? ' EBITDA-Marge' : ' EBITDA Margin')
|
||||
: (de ? ' EBITDA Margin' : ' EBITDA Margin'),
|
||||
key: 'ebitdaMarginPct' as keyof AnnualRow,
|
||||
monthKey: 'ebitdaMarginPct' as keyof MonthlyRow,
|
||||
isPercent: true,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? 'Kunden (Stichtag)' : 'Customers (Reporting Date)')
|
||||
: (de ? 'Kunden (Jahresende)' : 'Customers (Year End)'),
|
||||
key: 'customers' as keyof AnnualRow,
|
||||
monthKey: 'customers' as keyof MonthlyRow,
|
||||
},
|
||||
{
|
||||
label: hgb
|
||||
? (de ? 'Mitarbeiter (VZAe)' : 'Employees (FTE)')
|
||||
: (de ? 'Mitarbeiter' : 'Employees'),
|
||||
key: 'employees' as keyof AnnualRow,
|
||||
monthKey: 'employees' as keyof MonthlyRow,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function AnnualTable({
|
||||
rows,
|
||||
lang,
|
||||
expandedYear,
|
||||
onToggleYear,
|
||||
monthlyData,
|
||||
isFullscreen,
|
||||
standard,
|
||||
}: {
|
||||
rows: AnnualRow[]
|
||||
lang: 'de' | 'en'
|
||||
expandedYear: number | null
|
||||
onToggleYear: (year: number) => void
|
||||
monthlyData: Map<number, MonthlyRow[]>
|
||||
isFullscreen: boolean
|
||||
standard: AccountingStandard
|
||||
}) {
|
||||
const de = lang === 'de'
|
||||
const monthNames = de ? MONTH_NAMES_DE : MONTH_NAMES_EN
|
||||
const lineItems = getLineItems(lang, standard)
|
||||
|
||||
const monthlyExtraItems: { label: string; key: keyof MonthlyRow; isBold?: boolean }[] = isFullscreen ? [
|
||||
{ label: 'MRR', key: 'mrr', isBold: true },
|
||||
{ label: de ? 'Cash-Bestand' : 'Cash Balance', key: 'cashBalance', isBold: true },
|
||||
] : []
|
||||
|
||||
const textSize = isFullscreen ? 'text-xs' : 'text-[11px]'
|
||||
const minColWidth = isFullscreen ? 'min-w-[70px]' : 'min-w-[80px]'
|
||||
|
||||
return (
|
||||
<table className={`w-full ${textSize}`}>
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className={`text-left py-2 pr-4 text-white/40 font-medium ${isFullscreen ? 'min-w-[220px]' : 'min-w-[180px]'}`}>
|
||||
{standard === 'hgb'
|
||||
? (de ? 'GuV-Position (HGB)' : 'P&L Line Item (HGB)')
|
||||
: (de ? 'GuV-Position (US GAAP)' : 'P&L Line Item (US GAAP)')
|
||||
}
|
||||
</th>
|
||||
{rows.map(r => (
|
||||
<th
|
||||
key={r.year}
|
||||
className={`text-right py-2 px-2 text-white/50 font-semibold ${minColWidth} cursor-pointer hover:text-indigo-400 transition-colors`}
|
||||
onClick={() => onToggleYear(r.year)}
|
||||
title={de ? 'Klicken fuer Monatsdetails' : 'Click for monthly details'}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{expandedYear === r.year ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 opacity-30" />
|
||||
)}
|
||||
{r.year}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineItems.map((item) => (
|
||||
<tr
|
||||
key={item.key}
|
||||
className={item.isSeparator ? 'border-t border-white/10' : ''}
|
||||
>
|
||||
<td className={`py-1.5 pr-4 ${item.isBold ? 'text-white font-semibold' : 'text-white/50'} ${item.isPercent ? 'italic text-white/30' : ''}`}>
|
||||
{item.label}
|
||||
</td>
|
||||
{rows.map(r => {
|
||||
const val = r[item.key] as number
|
||||
return (
|
||||
<td
|
||||
key={r.year}
|
||||
className={`text-right py-1.5 px-2 font-mono
|
||||
${item.isBold ? 'font-semibold' : ''}
|
||||
${item.isPercent ? 'text-white/30 italic' : ''}
|
||||
${!item.isPercent && val < 0 ? 'text-red-400/80' : ''}
|
||||
${!item.isPercent && val > 0 && item.key === 'ebitda' ? 'text-emerald-400' : ''}
|
||||
${!item.isPercent && !item.isBold ? 'text-white/60' : 'text-white'}
|
||||
`}
|
||||
>
|
||||
{item.isPercent
|
||||
? `${val.toFixed(1)}%`
|
||||
: (item.isNegative && val > 0 ? '-' : '') + fmt(Math.abs(val))
|
||||
}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
{/* Monthly Drill-Down */}
|
||||
{expandedYear && monthlyData.has(expandedYear) && (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={rows.length + 1} className="pt-4 pb-1">
|
||||
<div className="border-t border-indigo-500/30 pt-3">
|
||||
<p className="text-xs font-semibold text-indigo-400 mb-2">
|
||||
{de ? `Monatsdetails ${expandedYear}` : `Monthly Details ${expandedYear}`}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-b border-white/10">
|
||||
<td className="text-left py-1 pr-4 text-white/40 font-medium text-[10px]">
|
||||
{de ? 'Monat' : 'Month'}
|
||||
</td>
|
||||
{monthlyData.get(expandedYear)!.map(m => (
|
||||
<td key={m.monthInYear} className="text-right py-1 px-1 text-white/40 font-medium text-[10px]">
|
||||
{monthNames[m.monthInYear - 1]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
{lineItems.map((item) => {
|
||||
const mKey = item.monthKey
|
||||
if (!mKey) return null
|
||||
return (
|
||||
<tr key={`monthly-${item.key}`} className={item.isSeparator ? 'border-t border-white/5' : ''}>
|
||||
<td className={`py-1 pr-4 text-[10px] ${item.isBold ? 'text-white/70 font-medium' : 'text-white/30'} ${item.isPercent ? 'italic' : ''}`}>
|
||||
{item.label}
|
||||
</td>
|
||||
{monthlyData.get(expandedYear)!.map(m => {
|
||||
const val = m[mKey] as number
|
||||
return (
|
||||
<td
|
||||
key={m.monthInYear}
|
||||
className={`text-right py-1 px-1 font-mono text-[10px]
|
||||
${item.isPercent ? 'text-white/20 italic' : ''}
|
||||
${!item.isPercent && val < 0 ? 'text-red-400/60' : ''}
|
||||
${!item.isPercent && val > 0 && item.key === 'ebitda' ? 'text-emerald-400/70' : ''}
|
||||
${!item.isPercent ? 'text-white/40' : ''}
|
||||
`}
|
||||
>
|
||||
{item.isPercent
|
||||
? `${val.toFixed(0)}%`
|
||||
: (item.isNegative && val > 0 ? '-' : '') + fmtMonth(Math.abs(val))
|
||||
}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{monthlyExtraItems.map((item) => (
|
||||
<tr key={`monthly-extra-${item.key}`} className="border-t border-white/5">
|
||||
<td className="py-1 pr-4 text-[10px] text-indigo-300/70 font-medium">{item.label}</td>
|
||||
{monthlyData.get(expandedYear)!.map(m => {
|
||||
const val = m[item.key] as number
|
||||
return (
|
||||
<td key={m.monthInYear} className="text-right py-1 px-1 font-mono text-[10px] text-indigo-300/50">
|
||||
{fmtMonth(Math.round(val))}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function FullscreenOverlay({
|
||||
children,
|
||||
onClose,
|
||||
lang,
|
||||
standard,
|
||||
onStandardChange,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClose: () => void
|
||||
lang: 'de' | 'en'
|
||||
standard: AccountingStandard
|
||||
onStandardChange: (s: AccountingStandard) => void
|
||||
}) {
|
||||
const de = lang === 'de'
|
||||
|
||||
// ESC to close
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] bg-slate-950/98 backdrop-blur-xl overflow-auto p-6">
|
||||
<div className="max-w-[1400px] mx-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{de ? 'Gewinn- und Verlustrechnung' : 'Profit & Loss Statement'}
|
||||
</h2>
|
||||
<p className="text-xs text-white/40">
|
||||
{de
|
||||
? 'Klicke auf ein Jahr um die Monatsdetails zu sehen · ESC zum Schliessen'
|
||||
: 'Click on a year to see monthly details · ESC to close'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* HGB / US GAAP Toggle */}
|
||||
<div className="flex items-center bg-white/[0.06] border border-white/10 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => onStandardChange('hgb')}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
standard === 'hgb'
|
||||
? 'bg-indigo-500/20 text-indigo-300'
|
||||
: 'text-white/40 hover:text-white/60'
|
||||
}`}
|
||||
>
|
||||
HGB
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onStandardChange('usgaap')}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
standard === 'usgaap'
|
||||
? 'bg-indigo-500/20 text-indigo-300'
|
||||
: 'text-white/40 hover:text-white/60'
|
||||
}`}
|
||||
>
|
||||
US GAAP
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white/[0.08] hover:bg-white/[0.12] border border-white/10 rounded-xl text-sm text-white/70 hover:text-white transition-all"
|
||||
>
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
{de ? 'Schliessen' : 'Close'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/[0.03] border border-white/10 rounded-2xl p-6 overflow-x-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -66,77 +455,120 @@ export default function AnnualPLTable({ results, lang }: AnnualPLTableProps) {
|
||||
}
|
||||
})
|
||||
|
||||
const de = lang === 'de'
|
||||
// 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 lineItems: { label: string; key: keyof AnnualRow; isBold?: boolean; isPercent?: boolean; isSeparator?: boolean; isNegative?: boolean }[] = [
|
||||
{ label: de ? 'Umsatzerloese' : 'Revenue', key: 'revenue', isBold: true },
|
||||
{ label: de ? '- Herstellungskosten (COGS)' : '- Cost of Goods Sold', key: 'cogs', isNegative: true },
|
||||
{ label: de ? '= Rohertrag (Gross Profit)' : '= Gross Profit', key: 'grossProfit', isBold: true, isSeparator: true },
|
||||
{ label: de ? ' Rohertragsmarge' : ' Gross Margin', key: 'grossMarginPct', isPercent: true },
|
||||
{ label: de ? '- Personalkosten' : '- Personnel', key: 'personnel', isNegative: true },
|
||||
{ label: de ? '- Marketing & Vertrieb' : '- Marketing & Sales', key: 'marketing', isNegative: true },
|
||||
{ label: de ? '- Infrastruktur' : '- Infrastructure', key: 'infra', isNegative: true },
|
||||
{ label: de ? '= OpEx gesamt' : '= Total OpEx', key: 'totalOpex', isBold: true, isSeparator: true, isNegative: true },
|
||||
{ label: 'EBITDA', key: 'ebitda', isBold: true, isSeparator: true },
|
||||
{ label: de ? ' EBITDA-Marge' : ' EBITDA Margin', key: 'ebitdaMarginPct', isPercent: true },
|
||||
{ label: de ? 'Kunden (Jahresende)' : 'Customers (Year End)', key: 'customers' },
|
||||
{ label: de ? 'Mitarbeiter' : 'Employees', key: 'employees' },
|
||||
]
|
||||
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 }}
|
||||
className="overflow-x-auto"
|
||||
>
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="text-left py-2 pr-4 text-white/40 font-medium min-w-[180px]">
|
||||
{de ? 'GuV-Position' : 'P&L Line Item'}
|
||||
</th>
|
||||
{rows.map(r => (
|
||||
<th key={r.year} className="text-right py-2 px-2 text-white/50 font-semibold min-w-[80px]">
|
||||
{r.year}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineItems.map((item) => (
|
||||
<tr
|
||||
key={item.key}
|
||||
className={`${item.isSeparator ? 'border-t border-white/10' : ''} ${item.isBold ? '' : ''}`}
|
||||
<>
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
<td className={`py-1.5 pr-4 ${item.isBold ? 'text-white font-semibold' : 'text-white/50'} ${item.isPercent ? 'italic text-white/30' : ''}`}>
|
||||
{item.label}
|
||||
</td>
|
||||
{rows.map(r => {
|
||||
const val = r[item.key] as number
|
||||
const isNeg = val < 0 || item.isNegative
|
||||
return (
|
||||
<td
|
||||
key={r.year}
|
||||
className={`text-right py-1.5 px-2 font-mono
|
||||
${item.isBold ? 'font-semibold' : ''}
|
||||
${item.isPercent ? 'text-white/30 italic' : ''}
|
||||
${!item.isPercent && val < 0 ? 'text-red-400/80' : ''}
|
||||
${!item.isPercent && val > 0 && item.key === 'ebitda' ? 'text-emerald-400' : ''}
|
||||
${!item.isPercent && !item.isBold ? 'text-white/60' : 'text-white'}
|
||||
`}
|
||||
>
|
||||
{item.isPercent
|
||||
? `${val.toFixed(1)}%`
|
||||
: (item.isNegative && val > 0 ? '-' : '') + fmt(Math.abs(val))
|
||||
}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</motion.div>
|
||||
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
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user