fix(pitch-print): cover layout, Finanzplan data source, target_date
Build pitch-deck / build-push-deploy (push) Successful in 1m34s
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 30s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 28s

Three critical fixes after reviewing the rendered PDF:

Cover (was: indigo block collapsed to top, white content stacked below):
- The .print-page class in print.css forces flex-direction: column !important,
  which broke the horizontal split. Wrap the cover content in a single grid
  container — the column-flex parent then has only one child so direction is
  irrelevant. Indigo block now runs full-height on the left.
- Title reduced 88pt -> 60pt so "BreakPilot ComplAI." fits without wrapping.
- Funding amount formatter now handles sub-€1M cases (€200k vs €0.2M).

Finanzplan (was: "nicht verfügbar" on both pages 20-21):
- page.tsx was querying the legacy pitch_fm_results table which isn't populated
  by the current pipeline. The interactive deck reads from fp_* tables.
- Wire in lib/finanzplan/adapter.ts (finanzplanToFMResults) which bridges the
  live fp_* tables to FMResult[] — same source the interactive deck uses.
- Fall back to live default fp_scenario if the version snapshot's fm_scenarios
  is empty.
- adapter.ts: populate total_customers + new_customers from fp_kunden_summary
  (was hardcoded 0).

The Ask:
- target_date was rendering as raw ISO timestamp "2026-08-01T00:00:00.000Z";
  now formatted as "Aug 2026" (locale-aware).
- Hero funding amount uses same sub-€1M formatter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-05-20 10:01:53 +02:00
parent bb85ee2e27
commit f30ac73b79
4 changed files with 133 additions and 88 deletions
@@ -10,93 +10,103 @@ export function PrintCoverPage({ company, funding, lang, versionName }: { compan
const instrument = funding?.instrument || 'Pre-Seed'
const amount = funding?.amount_eur || 1_000_000
const tagline = de ? (company?.tagline_de || 'Kontinuierliche Compliance für europäische Unternehmen.') : (company?.tagline_en || 'Continuous compliance for European companies.')
const amountLabel = amount >= 1_000_000
? '€' + (amount / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'
: '€' + Math.round(amount / 1_000) + 'k'
return (
<div className="print-page-break">
<div className="print-page" style={{ width: '297mm', height: '210mm', background: '#ffffff', color: COLORS.slate900, fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", display: 'flex', boxSizing: 'border-box', margin: '0 auto 24px', boxShadow: '0 4px 24px rgba(15,23,42,0.10)', overflow: 'hidden', padding: 0 }}>
<div className="print-page" style={{ width: '297mm', height: '210mm', background: '#ffffff', color: COLORS.slate900, fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", boxSizing: 'border-box', margin: '0 auto 24px', boxShadow: '0 4px 24px rgba(15,23,42,0.10)', overflow: 'hidden', padding: 0 }}>
{/*
The .print-page class in print.css forces flex-direction: column !important,
which would collapse a horizontal flex split. We sidestep that by putting a
single full-size grid container as the only child — the column-flex parent
has just one item so direction no longer matters.
*/}
<div style={{ width: '100%', height: '100%', display: 'grid', gridTemplateColumns: '95mm 1fr' }}>
{/* LEFT INDIGO BLOCK */}
<div style={{
width: '95mm',
background: COLORS.indigo600,
color: '#ffffff',
padding: '16mm 12mm',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
WebkitPrintColorAdjust: 'exact',
printColorAdjust: 'exact',
}}>
<div>
<div style={{ fontSize: '8pt', fontWeight: 700, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.7)' }}>
{de ? 'Investor Brief' : 'Investor Brief'}
{/* LEFT INDIGO BLOCK */}
<div style={{
background: COLORS.indigo600,
color: '#ffffff',
padding: '16mm 12mm',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
WebkitPrintColorAdjust: 'exact',
printColorAdjust: 'exact',
}}>
<div>
<div style={{ fontSize: '8pt', fontWeight: 700, letterSpacing: '0.18em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.75)' }}>
{de ? 'Investor Brief' : 'Investor Brief'}
</div>
<div style={{ marginTop: '6mm', height: '1px', background: 'rgba(255,255,255,0.35)', width: '32mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
<div style={{ marginTop: '6mm', fontSize: '11pt', color: '#ffffff', lineHeight: 1.5, fontWeight: 500 }}>
{de
? 'DSGVO-konforme KI-Plattform für kontinuierliche Code-Security und automatisierte Compliance. Souverän gehostet, integriert in europäische Workflows.'
: 'GDPR-compliant AI platform for continuous code security and automated compliance. Sovereign-hosted, integrated into European workflows.'}
</div>
</div>
<div style={{ marginTop: '6mm', height: '1px', background: 'rgba(255,255,255,0.3)', width: '32mm' }} />
<div style={{ marginTop: '6mm', fontSize: '11pt', color: 'rgba(255,255,255,0.85)', lineHeight: 1.55, fontWeight: 500 }}>
{de
? 'DSGVO-konforme KI-Plattform für kontinuierliche Code-Security und automatisierte Compliance. Souverän gehostet, integriert in europäische Workflows.'
: 'GDPR-compliant AI platform for continuous code security and automated compliance. Sovereign-hosted, integrated into European workflows.'}
{/* Mid stats */}
<div style={{ paddingTop: '8mm', borderTop: '1px solid rgba(255,255,255,0.25)' }}>
<div style={{ fontSize: '8pt', fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.75)', marginBottom: '4mm' }}>{de ? 'Auf einen Blick' : 'At a glance'}</div>
<div style={{ fontSize: '9.5pt', color: '#ffffff', lineHeight: 1.7, fontWeight: 500 }}>
{de ? '25 000 + atomare Prüfaspekte' : '25 000 + atomic audit aspects'}<br />
{de ? '380 + Regularien · 10 Branchen' : '380 + regulations · 10 industries'}<br />
{de ? '500 K + Lines of Code · 45 Container' : '500 K + lines of code · 45 containers'}<br />
{de ? '100 % EU-Hosting · BSI Cloud DE' : '100 % EU hosting · BSI cloud DE'}
</div>
</div>
{/* Footer */}
<div>
<div style={{ fontSize: '7pt', letterSpacing: '0.16em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.6)', fontWeight: 700 }}>{versionName}</div>
<div style={{ marginTop: '2mm', fontSize: '7pt', letterSpacing: '0.16em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.6)', fontWeight: 700 }}>{de ? 'Vertraulich · Nur Investoren' : 'Confidential · Investors only'}</div>
</div>
</div>
{/* Hero stat */}
<div>
<div style={{ fontSize: '7.5pt', fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.7)' }}>
{de ? '25 000 + atomare Prüfaspekte' : '25 000 + atomic audit aspects'}
{/* RIGHT WHITE PANE */}
<div style={{ padding: '18mm 16mm', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', minWidth: 0 }}>
<div>
<div style={{ fontSize: '10pt', fontWeight: 700, color: COLORS.indigo600, textTransform: 'uppercase', letterSpacing: '0.16em' }}>
{instrument} &middot; Q4 2026
</div>
<h1 style={{ fontSize: '60pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 0.95, letterSpacing: '-0.03em', margin: '8mm 0 4mm' }}>
{company?.name || 'BreakPilot'}<span style={{ color: COLORS.indigo600 }}>.</span>
</h1>
<div style={{ height: '2px', width: '40mm', background: COLORS.indigo600, marginBottom: '6mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
<p style={{ fontSize: '15pt', fontWeight: 500, color: COLORS.slate700, lineHeight: 1.3, maxWidth: '170mm', margin: 0, letterSpacing: '-0.008em' }}>
{tagline}
</p>
</div>
<div style={{ fontSize: '7.5pt', fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.7)', marginTop: '1.5mm' }}>
{de ? '380 + Regularien · 10 Branchen' : '380 + regulations · 10 industries'}
</div>
<div style={{ fontSize: '7.5pt', fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.7)', marginTop: '1.5mm' }}>
{de ? '500 K + Lines of Code · 45 Container' : '500 K + lines of code · 45 containers'}
{/* Key terms */}
<div>
<div style={{ fontSize: '7.5pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: '3mm', paddingBottom: '2mm', borderBottom: `1px solid ${COLORS.slate200}` }}>
{de ? 'Key Terms' : 'Key terms'}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '5mm' }}>
{([
[de ? 'Funding' : 'Funding', amountLabel],
[de ? 'Pre-Money' : 'Pre-money', '€4.0M'],
[de ? 'Instrument' : 'Instrument', instrument],
[de ? 'Standort' : 'HQ', company?.hq_city || 'Bodman'],
] as [string, string][]).map(([label, val]) => (
<div key={label}>
<div style={{ fontSize: '7pt', color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{label}</div>
<div style={{ fontSize: '16pt', fontWeight: 800, color: COLORS.slate900, marginTop: '1.5mm', fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.01em', lineHeight: 1.1 }}>{val}</div>
</div>
))}
</div>
<div style={{ marginTop: '5mm', fontSize: '8pt', color: COLORS.slate500, lineHeight: 1.5 }}>
{de
? 'Gründerteam Benjamin Bönisch (CEO) und Sharang Parnerkar (CTO). Markeneintragung DPMA · EUIPO-Anmeldung in Bearbeitung · GmbH-Gründung August 2026.'
: 'Founding team Benjamin Bönisch (CEO) and Sharang Parnerkar (CTO). Trademark DPMA registered · EUIPO filing in progress · GmbH incorporation August 2026.'}
</div>
</div>
</div>
{/* Bottom: version + confidential */}
<div>
<div style={{ fontSize: '7pt', letterSpacing: '0.16em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.55)', fontWeight: 600 }}>{versionName}</div>
<div style={{ marginTop: '2mm', fontSize: '7pt', letterSpacing: '0.16em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.55)', fontWeight: 600 }}>{de ? 'Vertraulich · Nur Investoren' : 'Confidential · Investors only'}</div>
</div>
</div>
{/* RIGHT WHITE PANE */}
<div style={{ flex: 1, padding: '16mm 16mm', display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div>
<div style={{ fontSize: '10pt', fontWeight: 700, color: COLORS.indigo600, textTransform: 'uppercase', letterSpacing: '0.16em' }}>
{instrument} &middot; Q4 2026
</div>
<h1 style={{ fontSize: '88pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 0.92, letterSpacing: '-0.035em', margin: '8mm 0 6mm' }}>
{company?.name || 'BreakPilot'}<span style={{ color: COLORS.indigo600 }}>.</span>
</h1>
<div style={{ height: '2px', width: '40mm', background: COLORS.indigo600, marginBottom: '6mm', WebkitPrintColorAdjust: 'exact', printColorAdjust: 'exact' }} />
<p style={{ fontSize: '16pt', fontWeight: 500, color: COLORS.slate700, lineHeight: 1.3, maxWidth: '160mm', margin: 0, letterSpacing: '-0.008em' }}>
{tagline}
</p>
</div>
{/* Key terms */}
<div>
<div style={{ fontSize: '7.5pt', fontWeight: 700, color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: '3mm', paddingBottom: '2mm', borderBottom: `1px solid ${COLORS.slate200}` }}>
{de ? 'Key Terms' : 'Key terms'}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '6mm' }}>
{([
[de ? 'Funding' : 'Funding', '€' + (amount / 1_000_000).toFixed(1) + 'M'],
[de ? 'Pre-Money' : 'Pre-money', '€4.0M'],
[de ? 'Instrument' : 'Instrument', instrument],
[de ? 'Standort' : 'HQ', company?.hq_city || 'Bodman'],
] as [string, string][]).map(([label, val]) => (
<div key={label}>
<div style={{ fontSize: '7pt', color: COLORS.slate500, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{label}</div>
<div style={{ fontSize: '18pt', fontWeight: 800, color: COLORS.slate900, marginTop: '1.5mm', fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.01em' }}>{val}</div>
</div>
))}
</div>
<div style={{ marginTop: '5mm', fontSize: '8pt', color: COLORS.slate500, lineHeight: 1.5 }}>
{de
? 'Gründerteam Benjamin Bönisch (CEO) und Sharang Parnerkar (CTO). Markeneintragung DPMA · EUIPO-Anmeldung in Bearbeitung · GmbH-Gründung August 2026.'
: 'Founding team Benjamin Bönisch (CEO) and Sharang Parnerkar (CTO). Trademark DPMA registered · EUIPO filing in progress · GmbH incorporation August 2026.'}
</div>
</div>
</div>
</div>
</div>
@@ -217,6 +217,25 @@ export function PrintTeamPage({ team, lang, pageNum, totalPages, versionName }:
/* ===== THE ASK ===== */
function formatTargetDate(raw: string | undefined, de: boolean): string {
if (!raw) return de ? 'Q3 2026' : 'Q3 2026'
// Accept ISO timestamps, ISO dates, or already-formatted strings.
const d = new Date(raw)
if (isNaN(d.getTime())) return raw
const months = de
? ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
return `${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`
}
function formatFunding(amount: number): string {
if (amount >= 1_000_000) {
const m = amount / 1_000_000
return '€' + (m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)) + 'M'
}
return '€' + Math.round(amount / 1_000) + 'k'
}
export function PrintTheAskPage({ funding, lang, pageNum, totalPages, versionName }: SlideBase & { funding: PitchFunding }) {
const de = lang === 'de'
const amount = funding?.amount_eur || 1_000_000
@@ -230,7 +249,7 @@ export function PrintTheAskPage({ funding, lang, pageNum, totalPages, versionNam
]
return (
<Page kicker="14" section={de ? 'THE ASK' : 'THE ASK'} title={de ? `Pre-Seed: ${fmtEur(amount, true)} via ${instrument}.` : `Pre-Seed: ${fmtEur(amount, false)} via ${instrument.toLowerCase()}.`} subtitle={de ? '18 Monate Runway zur Profitabilität. Use of Funds in 5 Buckets. INVEST-Zuschuss 20% rückzahlbar.' : '18-month runway to profitability. Use of funds across 5 buckets. 20% INVEST grant eligible.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
<Page kicker="14" section={de ? 'THE ASK' : 'THE ASK'} title={de ? `${funding?.round_name || 'Pre-Seed'}: ${formatFunding(amount)} via ${instrument}.` : `${funding?.round_name || 'Pre-Seed'}: ${formatFunding(amount)} via ${instrument.toLowerCase()}.`} subtitle={de ? '18 Monate Runway zur Profitabilität. Use of Funds in 5 Buckets. INVEST-Zuschuss 20% rückzahlbar.' : '18-month runway to profitability. Use of funds across 5 buckets. 20% INVEST grant eligible.'} pageNum={pageNum} totalPages={totalPages} versionName={versionName}>
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: '8mm', flex: 1, minHeight: 0 }}>
{/* Hero amount */}
@@ -238,9 +257,9 @@ export function PrintTheAskPage({ funding, lang, pageNum, totalPages, versionNam
<div style={{ borderLeft: `3px solid ${COLORS.indigo600}`, paddingLeft: '5mm', marginBottom: '6mm' }}>
<div style={{ fontSize: '8pt', fontWeight: 700, color: COLORS.indigo600, textTransform: 'uppercase', letterSpacing: '0.12em' }}>{de ? 'Funding' : 'Funding'}</div>
<div style={{ fontSize: '54pt', fontWeight: 800, color: COLORS.slate900, lineHeight: 1, letterSpacing: '-0.03em', fontVariantNumeric: 'tabular-nums', marginTop: '2mm' }}>
{(amount / 1_000_000).toFixed(1)}M
{formatFunding(amount)}
</div>
<div style={{ fontSize: '10pt', color: COLORS.slate600, marginTop: '3mm' }}>{instrument} &middot; {funding?.round_name || 'Pre-Seed'} &middot; {de ? 'Zielabschluss' : 'Target close'}: {funding?.target_date || 'Q3 2026'}</div>
<div style={{ fontSize: '10pt', color: COLORS.slate600, marginTop: '3mm' }}>{instrument} &middot; {funding?.round_name || 'Pre-Seed'} &middot; {de ? 'Zielabschluss' : 'Target close'}: {formatTargetDate(funding?.target_date, de)}</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '4mm' }}>
@@ -6,6 +6,7 @@ import {
PitchCompetitor, PitchFeature, PitchMilestone, PitchMetric, PitchFunding, PitchProduct,
FpScenarioRef, FMResult, FMAssumption,
} from '@/lib/types'
import { finanzplanToFMResults } from '@/lib/finanzplan/adapter'
import PrintDeck from './_components/PrintDeck'
interface Ctx {
@@ -68,17 +69,29 @@ export default async function PitchPrintPage({ params, searchParams }: Ctx) {
// Always fetch FM results + assumptions so the standard PDF can render the
// annex-finanzplan slide. The `financial` flag only adds the extra detail
// P&L page and the cap-table page.
//
// Data source: the live `fp_*` tables (same as the interactive deck), bridged
// to FMResult[] via finanzplanToFMResults. The legacy `pitch_fm_results` table
// is no longer populated by the current pipeline.
let fmResults: FMResult[] = []
let fmAssumptions: FMAssumption[] = []
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[]
// Snapshot stores fp_scenario IDs under `fm_scenarios`; fall back to the live
// default fp scenario if the snapshot is empty (older versions).
let scenarioId: string | null = defaultScenario?.id ? String(defaultScenario.id) : null
if (!scenarioId) {
const liveRes = await pool.query(`SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1`)
scenarioId = liveRes.rows[0]?.id ? String(liveRes.rows[0].id) : null
}
if (scenarioId) {
try {
const fpResponse = await finanzplanToFMResults(pool, scenarioId)
fmResults = fpResponse.results
} catch {
fmResults = []
}
}
const rawAssumptions = (map.fm_assumptions || []) as Array<Record<string, unknown>>
+6 -3
View File
@@ -17,18 +17,20 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
}
// Load computed data
const [personalRes, liquidRes, betriebRes, umsatzRes, materialRes, investRes] = await Promise.all([
const [personalRes, liquidRes, betriebRes, umsatzRes, materialRes, investRes, kundenRes] = await Promise.all([
pool.query("SELECT * FROM fp_personalkosten WHERE scenario_id = $1 ORDER BY sort_order", [sid]),
pool.query("SELECT * FROM fp_liquiditaet WHERE scenario_id = $1 ORDER BY sort_order", [sid]),
pool.query("SELECT * FROM fp_betriebliche_aufwendungen WHERE scenario_id = $1 ORDER BY sort_order", [sid]),
pool.query("SELECT * FROM fp_umsatzerloese WHERE scenario_id = $1 AND section = 'revenue' AND row_label = 'GESAMTUMSATZ' LIMIT 1", [sid]),
pool.query("SELECT * FROM fp_materialaufwand WHERE scenario_id = $1 AND row_label = 'SUMME' LIMIT 1", [sid]),
pool.query("SELECT * FROM fp_investitionen WHERE scenario_id = $1 ORDER BY sort_order", [sid]),
pool.query("SELECT * FROM fp_kunden_summary WHERE scenario_id = $1 AND row_label = 'Bestandskunden gesamt' LIMIT 1", [sid]),
])
const personal = personalRes.rows
const liquid = liquidRes.rows
const betrieb = betriebRes.rows
const customersByMonth = (kundenRes.rows[0]?.values as MonthlyValues) || emptyMonthly()
// Helper to sum a field across personnel
function sumPersonalField(field: string): MonthlyValues {
@@ -92,9 +94,9 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
month: m,
year,
month_in_year: month,
new_customers: 0,
new_customers: Math.max((customersByMonth[`m${m}`] || 0) - prevCustomers, 0),
churned_customers: 0,
total_customers: 0,
total_customers: Math.round(customersByMonth[`m${m}`] || 0),
mrr_eur: Math.round(rev / 1), // monthly
arr_eur: Math.round(rev * 12),
revenue_eur: Math.round(rev),
@@ -113,6 +115,7 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
cash_balance_eur: Math.round(cash),
cumulative_revenue_eur: Math.round(cumulativeRevenue),
})
prevCustomers = customersByMonth[`m${m}`] || 0
}
// Summary