Migrated pitch-deck from breakpilot-pwa to breakpilot-core. Container: bp-core-pitch-deck on port 3012. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
143 lines
5.3 KiB
TypeScript
143 lines
5.3 KiB
TypeScript
'use client'
|
|
|
|
import { motion } from 'framer-motion'
|
|
import { FMResult } from '@/lib/types'
|
|
|
|
interface AnnualPLTableProps {
|
|
results: FMResult[]
|
|
lang: 'de' | 'en'
|
|
}
|
|
|
|
interface AnnualRow {
|
|
year: number
|
|
revenue: number
|
|
cogs: number
|
|
grossProfit: number
|
|
grossMarginPct: number
|
|
personnel: number
|
|
marketing: number
|
|
infra: number
|
|
totalOpex: number
|
|
ebitda: number
|
|
ebitdaMarginPct: number
|
|
customers: number
|
|
employees: 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')
|
|
}
|
|
|
|
export default function AnnualPLTable({ results, lang }: AnnualPLTableProps) {
|
|
// 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,
|
|
}
|
|
})
|
|
|
|
const de = lang === 'de'
|
|
|
|
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' },
|
|
]
|
|
|
|
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 ? '' : ''}`}
|
|
>
|
|
<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>
|
|
)
|
|
}
|