diff --git a/pitch-deck/app/pitch-admin/(authed)/versions/[id]/page.tsx b/pitch-deck/app/pitch-admin/(authed)/versions/[id]/page.tsx index a8cc669..72d66ae 100644 --- a/pitch-deck/app/pitch-admin/(authed)/versions/[id]/page.tsx +++ b/pitch-deck/app/pitch-admin/(authed)/versions/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useCallback } from 'react' import { useParams, useRouter } from 'next/navigation' import Link from 'next/link' -import { ArrowLeft, Lock, Save, GitFork, Eye, Code } from 'lucide-react' +import { ArrowLeft, Lock, Save, GitFork, Eye, Code, FileText, BarChart3 } from 'lucide-react' import TabEditor from './_components/TabEditors' const TABLE_LABELS: Record = { @@ -134,6 +134,20 @@ export default function VersionEditorPage() { > Preview + + Export PDF + + + Export PDF + Financial + {isDraft && ( + + + + +
+ + + + + + + + + + {/* Page 9: standard last page OR financial annex start */} + {!financial && } + + {financial && ( + <> + {annualRows.length > 0 + ? + : + } + + {hasCapTable && } + + + )} +
+ + ) +} diff --git a/pitch-deck/app/pitch-print/[versionId]/_components/PrintFinancialSlides.tsx b/pitch-deck/app/pitch-print/[versionId]/_components/PrintFinancialSlides.tsx new file mode 100644 index 0000000..d9f976c --- /dev/null +++ b/pitch-deck/app/pitch-print/[versionId]/_components/PrintFinancialSlides.tsx @@ -0,0 +1,277 @@ +import { PrintPage, SectionTitle, PrintTable, Badge, COLORS } from './PrintLayout' +import { Language, FMResult, FMAssumption } from '@/lib/types' + +interface SlideBase { lang: Language; pageNum: number; totalPages: number; versionName: string } + +interface AnnualPLRow { + year: number + revenue_eur: number + cogs_eur: number + gross_profit_eur: number + personnel_eur: number + marketing_eur: number + infra_eur: number + ebitda_eur: number + total_customers: number + employees_count: number +} + +export function aggregateAnnualRows(results: FMResult[]): AnnualPLRow[] { + const byYear = new Map() + for (const r of results) { + if (!byYear.has(r.year)) byYear.set(r.year, []) + byYear.get(r.year)!.push(r) + } + return Array.from(byYear.entries()) + .sort(([a], [b]) => a - b) + .map(([year, rows]) => { + const last = rows[rows.length - 1] + const revenue = rows.reduce((s, r) => s + r.revenue_eur, 0) + const cogs = rows.reduce((s, r) => s + r.cogs_eur, 0) + const personnel = rows.reduce((s, r) => s + r.personnel_eur, 0) + const marketing = rows.reduce((s, r) => s + r.marketing_eur, 0) + const infra = rows.reduce((s, r) => s + r.infra_eur, 0) + const gross = revenue - cogs + const ebitda = gross - personnel - marketing - infra + return { + year, + revenue_eur: revenue, + cogs_eur: cogs, + gross_profit_eur: gross, + personnel_eur: personnel, + marketing_eur: marketing, + infra_eur: infra, + ebitda_eur: ebitda, + total_customers: last?.total_customers ?? 0, + employees_count: last?.employees_count ?? 0, + } + }) +} + +function fmtEur(n: number) { + const abs = Math.abs(n) + if (abs >= 1_000_000) return `${(n / 1_000_000).toLocaleString('de-DE', { maximumFractionDigits: 1 })}M` + if (abs >= 1_000) return `${(n / 1_000).toLocaleString('de-DE', { maximumFractionDigits: 0 })}k` + return n.toLocaleString('de-DE', { maximumFractionDigits: 0 }) +} + +function colorEur(n: number) { return n >= 0 ? '#16a34a' : '#dc2626' } + +export function PrintFinancialsPage({ annualRows, lang, pageNum, totalPages, versionName }: SlideBase & { annualRows: AnnualPLRow[] }) { + const de = lang === 'de' + const headers = [ + de ? 'Jahr' : 'Year', + de ? 'Umsatz' : 'Revenue', + de ? 'Rohertrag' : 'Gross Profit', + de ? 'Personal' : 'Personnel', + de ? 'Marketing' : 'Marketing', + 'Infra', + 'EBITDA', + de ? 'Kunden' : 'Customers', + de ? 'MA' : 'FTE', + ] + const rows = annualRows.map(r => [ + {r.year}, + {fmtEur(r.revenue_eur)}, + fmtEur(r.gross_profit_eur), + `(${fmtEur(r.personnel_eur)})`, + `(${fmtEur(r.marketing_eur)})`, + `(${fmtEur(r.infra_eur)})`, + {fmtEur(r.ebitda_eur)}, + r.total_customers.toString(), + r.employees_count.toString(), + ]) + + const finalYear = annualRows[annualRows.length - 1] + const breakEvenYear = annualRows.find(r => r.ebitda_eur > 0)?.year + + return ( + + + {de ? 'Finanzprognose (Planzahlen)' : 'Financial Projections (Plan)'} + +
+ +
+ {(finalYear || breakEvenYear) && ( +
+ {finalYear &&
+

{de ? 'ARR (letztes Jahr)' : 'ARR (final year)'}

+

{fmtEur(finalYear.revenue_eur)}

+
} + {finalYear &&
+

{de ? 'Kunden (letztes Jahr)' : 'Customers (final year)'}

+

{finalYear.total_customers}

+
} + {breakEvenYear &&
+

{de ? 'Break-Even' : 'Break-Even'}

+

{breakEvenYear}

+
} +
+ )} +

+ {de ? '* Planzahlen · Szenario: Base Case · In Klammern = Kosten' : '* Projections · Scenario: Base Case · Parentheses = costs'} +

+
+ ) +} + +export function PrintAssumptionsPage({ assumptions, lang, pageNum, totalPages, versionName }: SlideBase & { assumptions: FMAssumption[] }) { + const de = lang === 'de' + const scalars = assumptions + .filter(a => a.value_type === 'scalar') + .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) + .slice(0, 28) + + const byCategory = scalars.reduce>((acc, a) => { + const cat = a.category || 'General' + if (!acc[cat]) acc[cat] = [] + acc[cat].push(a) + return acc + }, {}) + + return ( + + + {de ? 'Finanzielle Annahmen' : 'Financial Assumptions'} + +
+ {Object.entries(byCategory).slice(0, 4).map(([cat, items]) => ( +
+

+ {cat} +

+ + + {items.map(a => ( + + + + + ))} + +
{de ? a.label_de : a.label_en} + {typeof a.value === 'number' ? a.value.toLocaleString('de-DE') : String(a.value)} + {a.unit && {a.unit}} +
+
+ ))} +
+
+ ) +} + +const CAP_TABLE_DATA = [ + { name: 'Benjamin Bönisch (CEO)', pct: 37.3, color: '#6366f1' }, + { name: 'Sharang Parnerkar (CTO)', pct: 37.3, color: '#8b5cf6' }, + { name: 'Pre-Seed Investor', pct: 20.0, color: '#f59e0b' }, + { name: 'ESOP Pool', pct: 5.4, color: '#94a3b8' }, +] + +export function PrintCapTablePage({ lang, pageNum, totalPages, versionName }: SlideBase) { + const de = lang === 'de' + return ( + + + {de ? 'Investition & Anteilsverteilung' : 'Investment & Share Distribution'} + +
+ {/* Stacked bar */} +
+

+ {de ? 'Anteilsverteilung nach Pre-Seed' : 'Share Distribution Post Pre-Seed'} +

+
+ {CAP_TABLE_DATA.map(d => ( +
+ ))} +
+ {CAP_TABLE_DATA.map(d => ( +
+
+ {d.name} + {d.pct}% +
+ ))} +
+ + {/* Deal terms */} +
+

+ {de ? 'Konditionen' : 'Deal Terms'} +

+ +
+
+ + ) +} + +const DISCLAIMER_DE = { + heading: 'Rechtlicher Hinweis', + h1: 'Haftungsausschluss', + p1: 'Dieses Dokument wird vorgelegt von Benjamin Boenisch, wohnhaft in Bodman, Deutschland, und Sharang Parnerkar, wohnhaft in Engen, Deutschland (nachfolgend „Gründer"). Die Gründer beabsichtigen die Gründung der BreakPilot GmbH im dritten Quartal 2026. Zum Zeitpunkt der Erstellung dieses Dokuments ist die Gesellschaft weder gegründet noch im Handelsregister eingetragen.', + p2: 'Dieses Dokument stellt weder ein Angebot zum Verkauf noch eine Aufforderung zur Abgabe eines Angebots zum Erwerb von Wertpapieren dar. Es handelt sich nicht um einen Wertpapierprospekt im Sinne des VermAnlG oder der EU-Prospektverordnung.', + p3: 'Dieses Dokument enthält zukunftsgerichtete Aussagen, die auf gegenwärtigen Erwartungen und Annahmen beruhen. Sämtliche Finanzangaben sind Planzahlen und stellen keine Garantie für künftige Ergebnisse dar.', + p4: 'Eine Beteiligung an einem jungen Unternehmen ist mit erheblichen Risiken verbunden, einschließlich des Risikos eines Totalverlusts des eingesetzten Kapitals.', + h2: 'Vertraulichkeit', + p5: 'Dieses Dokument ist vertraulich und wurde ausschließlich für den namentlich eingeladenen Empfänger erstellt. Durch die Kenntnisnahme erklärt sich der Empfänger mit folgenden Bedingungen einverstanden:', + pa: '(a) Geheimhaltung — Inhalt vertraulich behandeln und nicht an Dritte weitergeben.', + pb: '(b) Zweckbindung — Ausschließlich zur Bewertung einer möglichen Beteiligung verwenden.', + pc: '(c) Geltungsdauer — Diese Vertraulichkeitsverpflichtung gilt für drei (3) Jahre ab Übermittlung. Gerichtsstand ist Konstanz, Deutschland.', + footer: 'Stand: April 2026 · Dieser Hinweis ersetzt keine Rechtsberatung.', +} + +const DISCLAIMER_EN = { + heading: 'Legal Notice', + h1: 'Disclaimer', + p1: 'This document is presented by Benjamin Boenisch, residing in Bodman, Germany, and Sharang Parnerkar, residing in Engen, Germany (hereinafter "Founders"). The Founders intend to establish BreakPilot GmbH in Q3 2026. At the time of this document, the company is neither founded nor registered.', + p2: 'This document constitutes neither an offer to sell nor a solicitation of an offer to acquire securities. It is not a securities prospectus within the meaning of VermAnlG or the EU Prospectus Regulation.', + p3: 'This document contains forward-looking statements based on current expectations. All financial figures are projections and do not constitute a guarantee of future results.', + p4: 'An investment in a young company involves significant risks, including the risk of total loss of invested capital.', + h2: 'Confidentiality', + p5: 'This document is confidential and prepared exclusively for the personally invited recipient. By accessing, the recipient agrees to:', + pa: '(a) Confidentiality — Treat contents confidentially and not disclose to third parties.', + pb: '(b) Purpose limitation — Use only for evaluating a possible participation.', + pc: '(c) Duration — This obligation applies for three (3) years from transmission. Place of jurisdiction is Konstanz, Germany.', + footer: 'As of: April 2026 · This notice does not replace legal advice.', +} + +export function PrintDisclaimerPage({ lang, pageNum, totalPages, versionName }: SlideBase) { + const d = lang === 'de' ? DISCLAIMER_DE : DISCLAIMER_EN + const sectionStyle = { padding: '10px 12px', border: `1px solid ${COLORS.border}`, borderRadius: '6px', marginBottom: '8px' } + const pStyle = { fontSize: '8px', color: COLORS.med, lineHeight: 1.55, margin: '4px 0 0' } + const hStyle = { fontSize: '9px', fontWeight: 700, color: COLORS.indigo, margin: 0, textTransform: 'uppercase' as const, letterSpacing: '0.05em' } + return ( + + {d.heading} +
+
+

{d.h1}

+

{d.p1}

+

{d.p2}

+

{d.p3}

+

{d.p4}

+
+
+

{d.h2}

+

{d.p5}

+

{d.pa}

+

{d.pb}

+

{d.pc}

+
+
+

{d.footer}

+
+ ) +} diff --git a/pitch-deck/app/pitch-print/[versionId]/_components/PrintLayout.tsx b/pitch-deck/app/pitch-print/[versionId]/_components/PrintLayout.tsx new file mode 100644 index 0000000..69a17d1 --- /dev/null +++ b/pitch-deck/app/pitch-print/[versionId]/_components/PrintLayout.tsx @@ -0,0 +1,151 @@ +import React from 'react' + +const INDIGO = '#6366f1' +const INDIGO_LIGHT = '#eef2ff' +const TEXT_DARK = '#1e1b4b' +const TEXT_MED = '#374151' +const TEXT_LIGHT = '#6b7280' +const BORDER = '#e0e7ff' + +interface PrintPageProps { + title: string + pageNum: number + totalPages: number + versionName: string + children: React.ReactNode +} + +export function PrintPage({ title, pageNum, totalPages, versionName, children }: PrintPageProps) { + return ( +
+ {/* Header bar */} +
+ BreakPilot + {title} +
+ + {/* Content area — must stretch to fill all remaining height */} +
+ {children} +
+ + {/* Footer bar */} +
+ {versionName} + CONFIDENTIAL + {pageNum} / {totalPages} +
+
+ ) +} + +interface SectionTitleProps { children: React.ReactNode; subtitle?: string } + +export function SectionTitle({ children, subtitle }: SectionTitleProps) { + return ( +
+

+ {children} +

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+ ) +} + +interface TableProps { + headers: string[] + rows: (string | React.ReactNode)[][] + colWidths?: string[] +} + +export function PrintTable({ headers, rows, colWidths }: TableProps) { + return ( + + + + {headers.map((h, i) => ( + + ))} + + + + {rows.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {h} +
+ {cell} +
+ ) +} + +export function Badge({ children, color = INDIGO }: { children: React.ReactNode; color?: string }) { + return ( + + {children} + + ) +} + +export const COLORS = { indigo: INDIGO, indigoLight: INDIGO_LIGHT, dark: TEXT_DARK, med: TEXT_MED, light: TEXT_LIGHT, border: BORDER } diff --git a/pitch-deck/app/pitch-print/[versionId]/page.tsx b/pitch-deck/app/pitch-print/[versionId]/page.tsx new file mode 100644 index 0000000..737749e --- /dev/null +++ b/pitch-deck/app/pitch-print/[versionId]/page.tsx @@ -0,0 +1,101 @@ +import { redirect, notFound } from 'next/navigation' +import pool from '@/lib/db' +import { getAdminFromCookie } from '@/lib/admin-auth' +import { + Language, PitchData, PitchCompany, PitchTeamMember, PitchFinancial, PitchMarket, + PitchCompetitor, PitchFeature, PitchMilestone, PitchMetric, PitchFunding, PitchProduct, + FpScenarioRef, FMResult, FMAssumption, +} from '@/lib/types' +import PrintDeck from './_components/PrintDeck' + +interface Ctx { + params: Promise<{ versionId: string }> + searchParams: Promise<{ financial?: string; lang?: string }> +} + +export default async function PitchPrintPage({ params, searchParams }: Ctx) { + const admin = await getAdminFromCookie() + if (!admin) redirect('/pitch-admin/login') + + const { versionId } = await params + const { financial: finParam, lang: langParam } = await searchParams + const financial = finParam === 'true' || finParam === '1' + const lang: Language = langParam === 'en' ? 'en' : 'de' + + // Version metadata + const verRes = await pool.query( + `SELECT name FROM pitch_versions WHERE id = $1`, + [versionId], + ) + if (verRes.rows.length === 0) notFound() + const versionName: string = verRes.rows[0].name + + // Version data tables (snapshot) + const dataRes = await pool.query( + `SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`, + [versionId], + ) + + const map: Record = {} + for (const row of dataRes.rows) { + map[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data + } + + if (Object.keys(map).length === 0) { + return ( +
+

Version has no data

+

Please make sure the version has been populated with pitch data before exporting.

+ ← Back to version +
+ ) + } + + const pitchData: PitchData = { + company: ((map.company || [])[0] || {}) as PitchCompany, + team: (map.team || []) as PitchTeamMember[], + financials: (map.financials || []) as PitchFinancial[], + market: (map.market || []) as PitchMarket[], + competitors: (map.competitors || []) as PitchCompetitor[], + features: (map.features || []) as PitchFeature[], + milestones: (map.milestones || []) as PitchMilestone[], + metrics: (map.metrics || []) as PitchMetric[], + funding: ((map.funding || [])[0] || {}) as PitchFunding, + products: (map.products || []) as PitchProduct[], + fp_scenarios: (map.fm_scenarios || []) as FpScenarioRef[], + } + + // Financial variant: fetch FM results + parse assumptions + let fmResults: FMResult[] = [] + let fmAssumptions: FMAssumption[] = [] + + if (financial) { + const scenarios = (map.fm_scenarios || []) as FpScenarioRef[] + const defaultScenario = scenarios.find(s => s.is_default) ?? scenarios[0] ?? null + + if (defaultScenario?.id) { + const resultsRes = await pool.query( + `SELECT * FROM pitch_fm_results WHERE scenario_id = $1 ORDER BY month`, + [defaultScenario.id], + ) + fmResults = resultsRes.rows as FMResult[] + } + + const rawAssumptions = (map.fm_assumptions || []) as Array> + fmAssumptions = rawAssumptions.map(a => ({ + ...a, + value: typeof a.value === 'string' ? JSON.parse(a.value as string) : a.value, + })) as FMAssumption[] + } + + return ( + + ) +}