Merge branch 'main' of ssh://gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-core
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 47s
CI / test-python-voice (push) Successful in 38s
CI / test-bqas (push) Successful in 37s
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 47s
CI / test-python-voice (push) Successful in 38s
CI / test-bqas (push) Successful in 37s
This commit is contained in:
@@ -19,7 +19,7 @@ export async function GET(request: NextRequest, ctx: RouteContext) {
|
||||
pool.query(
|
||||
`SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count,
|
||||
i.created_at, i.updated_at, i.first_activity_at, i.data_masked_at,
|
||||
i.assigned_version_id,
|
||||
i.assigned_version_id, i.is_showcase,
|
||||
v.name AS version_name, v.status AS version_status
|
||||
FROM pitch_investors i
|
||||
LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id
|
||||
@@ -68,14 +68,14 @@ export async function PATCH(request: NextRequest, ctx: RouteContext) {
|
||||
|
||||
const { id } = await ctx.params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { name, company, assigned_version_id } = body
|
||||
const { name, company, assigned_version_id, is_showcase } = body
|
||||
|
||||
if (name === undefined && company === undefined && assigned_version_id === undefined) {
|
||||
return NextResponse.json({ error: 'name, company, or assigned_version_id required' }, { status: 400 })
|
||||
if (name === undefined && company === undefined && assigned_version_id === undefined && is_showcase === undefined) {
|
||||
return NextResponse.json({ error: 'name, company, assigned_version_id, or is_showcase required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const before = await pool.query(
|
||||
`SELECT name, company, assigned_version_id FROM pitch_investors WHERE id = $1`,
|
||||
`SELECT name, company, assigned_version_id, is_showcase FROM pitch_investors WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
if (before.rows.length === 0) {
|
||||
@@ -99,15 +99,18 @@ export async function PATCH(request: NextRequest, ctx: RouteContext) {
|
||||
// Use null to clear version assignment, undefined to leave unchanged
|
||||
const versionValue = assigned_version_id === undefined ? before.rows[0].assigned_version_id : (assigned_version_id || null)
|
||||
|
||||
const showcaseValue = is_showcase !== undefined ? Boolean(is_showcase) : before.rows[0].is_showcase
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pitch_investors SET
|
||||
name = COALESCE($1, name),
|
||||
company = COALESCE($2, company),
|
||||
assigned_version_id = $4,
|
||||
is_showcase = $5,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING id, email, name, company, status, assigned_version_id`,
|
||||
[name ?? null, company ?? null, id, versionValue],
|
||||
RETURNING id, email, name, company, status, assigned_version_id, is_showcase`,
|
||||
[name ?? null, company ?? null, id, versionValue, showcaseValue],
|
||||
)
|
||||
|
||||
const action = assigned_version_id !== undefined && assigned_version_id !== before.rows[0].assigned_version_id
|
||||
|
||||
@@ -14,7 +14,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, company, status, last_login_at, login_count, created_at
|
||||
`SELECT id, email, name, company, status, last_login_at, login_count, created_at, is_showcase
|
||||
FROM pitch_investors WHERE id = $1`,
|
||||
[session.sub]
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { getSessionFromCookie } from '@/lib/auth'
|
||||
import { SLIDE_ORDER } from '@/lib/slide-order'
|
||||
import { SLIDE_ORDER, SHOWCASE_HIDDEN_SLIDES } from '@/lib/slide-order'
|
||||
|
||||
const LITELLM_URL = process.env.LITELLM_URL || 'https://llm-dev.meghsakha.com'
|
||||
const LITELLM_MODEL = process.env.LITELLM_MODEL || 'gpt-oss-120b'
|
||||
@@ -115,6 +115,19 @@ KONKRETES BEISPIEL einer vollständigen Antwort:
|
||||
|
||||
WICHTIG: Vergiss NIEMALS die Folgefragen! Sie sind PFLICHT.`
|
||||
|
||||
// Prepended to system content for showcase (customer demo) sessions
|
||||
const SHOWCASE_GUARD = `## MODUS: PRODUKT-DEMO (KEIN INVESTOREN-PITCH)
|
||||
Du bist im Showcase-Modus. Du beantwortest Fragen potenzieller Kunden über das Produkt — NICHT über Investitionsdetails.
|
||||
|
||||
ABSOLUTES VERBOT — niemals erwähnen oder beantworten:
|
||||
- Finanzierungssumme, Investitionsbedarf, The Ask, Bewertung, Equity, Cap Table
|
||||
- Finanzprognosen, Umsatzziele, Cashflow, Liquidität, Burn Rate
|
||||
- Wandeldarlehen, SAFE, Gesellschafteranteile oder Kapitalstruktur
|
||||
- Irgendwelche Zahlen aus dem Finanzplan oder Finanzierungsrunden
|
||||
|
||||
Wenn danach gefragt wird: "Für geschäftliche Details wenden Sie sich gerne direkt an unser Team."
|
||||
Fokus: Produkt-Features, Compliance-Module, technische Architektur, Kundennutzen, Markt, Team.`
|
||||
|
||||
async function loadFpLiquiditaetSummary(scenarioName: string): Promise<string> {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
@@ -199,7 +212,7 @@ function extractMeta(
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPitchContext(versionId?: string | null): Promise<PitchContextResult> {
|
||||
async function loadPitchContext(versionId?: string | null, isShowcase = false): Promise<PitchContextResult> {
|
||||
try {
|
||||
// Version-specific data path
|
||||
if (versionId) {
|
||||
@@ -225,11 +238,11 @@ async function loadPitchContext(versionId?: string | null): Promise<PitchContext
|
||||
|
||||
const versionName = vNameRes.rows[0]?.name ?? ''
|
||||
const meta = extractMeta(versionName, fmScenarios, funding, financials)
|
||||
const fpSummary = meta.scenarioName ? await loadFpLiquiditaetSummary(meta.scenarioName) : ''
|
||||
const fpSummary = (!isShowcase && meta.scenarioName) ? await loadFpLiquiditaetSummary(meta.scenarioName) : ''
|
||||
|
||||
return {
|
||||
contextString: buildContextString(company, team, financials, market, products, funding, features, fpSummary),
|
||||
meta,
|
||||
contextString: buildContextString(company, team, financials, market, products, funding, features, fpSummary, isShowcase),
|
||||
meta: isShowcase ? { ...DEFAULT_META, versionName } : meta,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,9 +262,9 @@ async function loadPitchContext(versionId?: string | null): Promise<PitchContext
|
||||
return {
|
||||
contextString: buildContextString(
|
||||
company.rows[0], team.rows, financials.rows, market.rows,
|
||||
products.rows, funding.rows[0], features.rows, ''
|
||||
products.rows, funding.rows[0], features.rows, '', isShowcase
|
||||
),
|
||||
meta,
|
||||
meta: isShowcase ? DEFAULT_META : meta,
|
||||
}
|
||||
} finally {
|
||||
client.release()
|
||||
@@ -264,8 +277,19 @@ async function loadPitchContext(versionId?: string | null): Promise<PitchContext
|
||||
|
||||
function buildContextString(
|
||||
company: unknown, team: unknown, financials: unknown, market: unknown,
|
||||
products: unknown, funding: unknown, features: unknown, fpSummary: string
|
||||
products: unknown, funding: unknown, features: unknown, fpSummary: string,
|
||||
omitFinancials = false
|
||||
): string {
|
||||
const finSection = omitFinancials ? '' : `
|
||||
### Finanzprognosen (5-Jahres-Plan)
|
||||
${JSON.stringify(financials, null, 2)}
|
||||
`
|
||||
const fundSection = omitFinancials ? '' : `
|
||||
### Finanzierung
|
||||
${JSON.stringify(funding, null, 2)}
|
||||
`
|
||||
const fpSection = omitFinancials ? '' : (fpSummary ? '\n' + fpSummary : '')
|
||||
|
||||
return `
|
||||
## Unternehmensdaten (für präzise Antworten nutzen)
|
||||
|
||||
@@ -274,22 +298,16 @@ ${JSON.stringify(company, null, 2)}
|
||||
|
||||
### Team
|
||||
${JSON.stringify(team, null, 2)}
|
||||
|
||||
### Finanzprognosen (5-Jahres-Plan)
|
||||
${JSON.stringify(financials, null, 2)}
|
||||
|
||||
${finSection}
|
||||
### Markt (TAM/SAM/SOM)
|
||||
${JSON.stringify(market, null, 2)}
|
||||
|
||||
### Produkte
|
||||
${JSON.stringify(products, null, 2)}
|
||||
|
||||
### Finanzierung
|
||||
${JSON.stringify(funding, null, 2)}
|
||||
|
||||
${fundSection}
|
||||
### Differenzierende Features (nur bei ComplAI)
|
||||
${JSON.stringify(features, null, 2)}
|
||||
${fpSummary ? '\n' + fpSummary : ''}
|
||||
${fpSection}
|
||||
`
|
||||
}
|
||||
|
||||
@@ -303,22 +321,24 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Resolve investor's assigned version so the AI sees the correct scenario data
|
||||
// Resolve investor's assigned version and showcase flag
|
||||
let versionId: string | null = null
|
||||
let isShowcase = false
|
||||
try {
|
||||
const session = await getSessionFromCookie()
|
||||
if (session?.sub) {
|
||||
const inv = await pool.query(
|
||||
`SELECT assigned_version_id FROM pitch_investors WHERE id = $1`,
|
||||
`SELECT assigned_version_id, is_showcase FROM pitch_investors WHERE id = $1`,
|
||||
[session.sub]
|
||||
)
|
||||
versionId = inv.rows[0]?.assigned_version_id ?? null
|
||||
isShowcase = inv.rows[0]?.is_showcase === true
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: fall back to base tables
|
||||
}
|
||||
|
||||
const { contextString, meta } = await loadPitchContext(versionId)
|
||||
const { contextString, meta } = await loadPitchContext(versionId, isShowcase)
|
||||
|
||||
// Build dynamic VERSIONS-ISOLATION and Kernbotschaft #9 from actual version data
|
||||
const fmt = (n: number) => n.toLocaleString('de-DE')
|
||||
@@ -340,40 +360,51 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const dynamicFinanzplanKernbotschaft = `9. Finanzplan: "Gründung August 2026. Pre-Seed über ${fundingStr}. ${customersStr} und ${revM} Umsatz bis 2030. ${employeesStr}."`
|
||||
|
||||
let systemContent = SYSTEM_PROMPT_PART1
|
||||
+ '\n' + dynamicFinanzplanKernbotschaft
|
||||
+ SYSTEM_PROMPT_PART2
|
||||
+ '\n\n' + dynamicVersionIsolation
|
||||
+ SYSTEM_PROMPT_PART3
|
||||
let systemContent: string
|
||||
if (isShowcase) {
|
||||
// Showcase: product-only context, no financial details anywhere
|
||||
systemContent = SHOWCASE_GUARD
|
||||
+ '\n\n' + SYSTEM_PROMPT_PART1
|
||||
+ SYSTEM_PROMPT_PART2
|
||||
+ SYSTEM_PROMPT_PART3
|
||||
} else {
|
||||
systemContent = SYSTEM_PROMPT_PART1
|
||||
+ '\n' + dynamicFinanzplanKernbotschaft
|
||||
+ SYSTEM_PROMPT_PART2
|
||||
+ '\n\n' + dynamicVersionIsolation
|
||||
+ SYSTEM_PROMPT_PART3
|
||||
}
|
||||
|
||||
if (contextString) {
|
||||
systemContent += '\n' + contextString
|
||||
}
|
||||
|
||||
// FAQ context: relevant pre-researched answers as basis for the LLM
|
||||
// IMPORTANT: FAQ entries contain hardcoded numbers written for specific scenarios.
|
||||
// They are hints only — the version-specific Unternehmensdaten above always take precedence.
|
||||
if (faqContext && typeof faqContext === 'string') {
|
||||
// FAQ context — skip for showcase (may contain financial hints)
|
||||
if (!isShowcase && faqContext && typeof faqContext === 'string') {
|
||||
systemContent += '\n' + faqContext
|
||||
systemContent += '\n\n## Versions-Datenvorrang (ABSOLUT VERBINDLICH)\nWenn die vorrecherchierten Antworten oben Zahlen, Beträge oder Details nennen, die von den "Unternehmensdaten" oder dem "Finanzplan-Liquidität" weiter oben abweichen, haben die Unternehmensdaten IMMER Vorrang. Die FAQ-Antworten sind allgemein formuliert und könnten veraltete oder szenario-fremde Zahlen enthalten. Nutze sie nur für Struktur und Formulierung — die konkreten Zahlen kommen ausschließlich aus den Unternehmensdaten dieses Investors.'
|
||||
}
|
||||
|
||||
// Slide context for contextual awareness
|
||||
// Slide context for contextual awareness — filter hidden slides for showcase
|
||||
const visibleSlideOrder = isShowcase
|
||||
? SLIDE_ORDER.filter(id => !SHOWCASE_HIDDEN_SLIDES.has(id))
|
||||
: SLIDE_ORDER
|
||||
|
||||
if (slideContext) {
|
||||
const visited: number[] = slideContext.visitedSlides || []
|
||||
const currentSlideId = slideContext.currentSlide
|
||||
const currentSlideName = SLIDE_DISPLAY_NAMES[currentSlideId]?.[lang] || currentSlideId
|
||||
const notYetSeen = SLIDE_ORDER
|
||||
const notYetSeen = visibleSlideOrder
|
||||
.map((id, idx) => ({ id, idx, name: SLIDE_DISPLAY_NAMES[id]?.[lang] || id }))
|
||||
.filter(s => !visited.includes(s.idx))
|
||||
.map(s => `${s.idx + 1}. ${s.name}`)
|
||||
|
||||
systemContent += `\n\n## Slide-Kontext (WICHTIG für kontextuelle Antworten)
|
||||
- Aktuelle Slide: "${currentSlideName}" (Nr. ${slideContext.currentIndex + 1} von ${slideCount})
|
||||
- Bereits besuchte Slides: ${visited.map((i: number) => SLIDE_DISPLAY_NAMES[SLIDE_ORDER[i]]?.[lang] || SLIDE_ORDER[i]).filter(Boolean).join(', ')}
|
||||
- Aktuelle Slide: "${currentSlideName}" (Nr. ${slideContext.currentIndex + 1} von ${visibleSlideOrder.length})
|
||||
- Bereits besuchte Slides: ${visited.map((i: number) => SLIDE_DISPLAY_NAMES[visibleSlideOrder[i]]?.[lang] || visibleSlideOrder[i]).filter(Boolean).join(', ')}
|
||||
- Noch nicht gesehene Slides: ${notYetSeen.join(', ')}
|
||||
- Ist Erstbesuch: ${visited.length <= 1 ? 'JA — Investor hat gerade erst den Pitch geöffnet' : 'Nein'}
|
||||
- Verfügbare Slide-IDs für [GOTO:id]: ${SLIDE_ORDER.join(', ')}
|
||||
- Ist Erstbesuch: ${visited.length <= 1 ? 'JA — Besucher hat gerade erst die Präsentation geöffnet' : 'Nein'}
|
||||
- Verfügbare Slide-IDs für [GOTO:id]: ${visibleSlideOrder.join(', ')}
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export async function GET() {
|
||||
metrics: map.metrics || [],
|
||||
funding: (map.funding || [])[0] || null,
|
||||
products: map.products || [],
|
||||
fp_scenarios: map.fm_scenarios || [],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -48,9 +48,7 @@ export async function GET(
|
||||
return NextResponse.json({ error: `Unknown sheet: ${sheetName}` }, { status: 400 })
|
||||
}
|
||||
|
||||
// Only admin callers may query an arbitrary scenarioId; investors always see the default
|
||||
const isAdmin = validateAdminSecret(request)
|
||||
const scenarioId = isAdmin ? request.nextUrl.searchParams.get('scenarioId') : null
|
||||
const scenarioId = request.nextUrl.searchParams.get('scenarioId')
|
||||
|
||||
try {
|
||||
let query = `SELECT * FROM ${table}`
|
||||
|
||||
@@ -3,25 +3,33 @@ import pool from '@/lib/db'
|
||||
import { SHEET_LIST } from '@/lib/finanzplan/types'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Only expose scenario list to admin callers (bearer token)
|
||||
// Only expose full scenario list to admin callers (bearer token)
|
||||
const secret = process.env.PITCH_ADMIN_SECRET
|
||||
const auth = request.headers.get('authorization') ?? ''
|
||||
const isAdmin = secret && auth === `Bearer ${secret}`
|
||||
|
||||
// Allow callers to pass a scenarioId for row counts (e.g. investor's assigned scenario)
|
||||
const scenarioId = request.nextUrl.searchParams.get('scenarioId')
|
||||
|
||||
try {
|
||||
// Investors see only the default scenario — no names of other scenarios leaked
|
||||
const scenarios = isAdmin
|
||||
? await pool.query('SELECT * FROM fp_scenarios ORDER BY is_default DESC, name')
|
||||
: await pool.query('SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1')
|
||||
|
||||
// Get row counts per sheet
|
||||
// Get row counts per sheet using the caller's scenario
|
||||
const sheets = await Promise.all(
|
||||
SHEET_LIST.map(async (s) => {
|
||||
const tableName = `fp_${s.name}`
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE is_editable = true) as editable FROM ${tableName} WHERE scenario_id = (SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1)`
|
||||
)
|
||||
const { rows } = scenarioId
|
||||
? await pool.query(
|
||||
`SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE is_editable = true) as editable FROM ${tableName} WHERE scenario_id = $1`,
|
||||
[scenarioId]
|
||||
)
|
||||
: await pool.query(
|
||||
`SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE is_editable = true) as editable FROM ${tableName} WHERE scenario_id = (SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1)`
|
||||
)
|
||||
return { ...s, rows: parseInt(rows[0]?.total || '0'), editable_rows: parseInt(rows[0]?.editable || '0') }
|
||||
} catch {
|
||||
return s
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function GET(request: NextRequest, ctx: Ctx) {
|
||||
metrics: map.metrics || [],
|
||||
funding: (map.funding || [])[0] || null,
|
||||
products: map.products || [],
|
||||
fm_scenarios: map.fm_scenarios || [],
|
||||
fp_scenarios: map.fm_scenarios || [],
|
||||
fm_assumptions: map.fm_assumptions || [],
|
||||
_version: { name: ver.rows[0].name, status: ver.rows[0].status },
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ interface InvestorDetail {
|
||||
assigned_version_id: string | null
|
||||
version_name: string | null
|
||||
version_status: string | null
|
||||
is_showcase: boolean
|
||||
}
|
||||
sessions: Array<{
|
||||
id: string
|
||||
@@ -293,7 +294,7 @@ export default function InvestorDetailPage() {
|
||||
{/* Version assignment */}
|
||||
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
|
||||
<h2 className="text-sm font-semibold text-white mb-3">Pitch Version</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<select
|
||||
value={inv.assigned_version_id || ''}
|
||||
onChange={async (e) => {
|
||||
@@ -318,10 +319,35 @@ export default function InvestorDetailPage() {
|
||||
</select>
|
||||
<span className="text-xs text-white/40">
|
||||
{inv.assigned_version_id
|
||||
? `Investor sees version "${inv.version_name || ''}"`
|
||||
: 'Investor sees default pitch data'}
|
||||
? `Sees version "${inv.version_name || ''}"`
|
||||
: 'Sees default pitch data'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Showcase toggle */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-white/[0.06]">
|
||||
<div>
|
||||
<div className="text-sm text-white font-medium">Showcase mode</div>
|
||||
<div className="text-xs text-white/40 mt-0.5">Hides financials, The Ask, and investor-only slides — for customer demos</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
const res = await fetch(`/api/admin/investors/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_showcase: !inv.is_showcase }),
|
||||
})
|
||||
setBusy(false)
|
||||
if (res.ok) { flashToast(inv.is_showcase ? 'Switched to investor mode' : 'Switched to showcase mode'); load() }
|
||||
else { flashToast('Update failed') }
|
||||
}}
|
||||
disabled={busy}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-indigo-500 ${inv.is_showcase ? 'bg-indigo-500' : 'bg-white/10'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform duration-200 ${inv.is_showcase ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Audit log for this investor */}
|
||||
|
||||
@@ -13,6 +13,8 @@ interface NavigationFABProps {
|
||||
onGoToSlide: (index: number) => void
|
||||
lang: Language
|
||||
onToggleLanguage: () => void
|
||||
slideNames?: string[]
|
||||
onPresenterStart?: () => void
|
||||
}
|
||||
|
||||
export default function NavigationFAB({
|
||||
@@ -22,6 +24,8 @@ export default function NavigationFAB({
|
||||
onGoToSlide,
|
||||
lang,
|
||||
onToggleLanguage,
|
||||
slideNames: slideNamesProp,
|
||||
onPresenterStart,
|
||||
}: NavigationFABProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
@@ -35,6 +39,7 @@ export default function NavigationFAB({
|
||||
})
|
||||
}, [])
|
||||
const i = t(lang)
|
||||
const activeSlideNames = slideNamesProp ?? i.slideNames
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!document.fullscreenElement) {
|
||||
@@ -71,12 +76,12 @@ export default function NavigationFAB({
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="w-[300px] max-h-[80vh] rounded-2xl overflow-hidden
|
||||
className="w-[300px] max-h-[80vh] flex flex-col rounded-2xl overflow-hidden
|
||||
bg-black/80 backdrop-blur-xl border border-white/10
|
||||
shadow-2xl shadow-black/50"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-3 border-b border-white/10">
|
||||
<span className="text-sm font-semibold text-white">{i.nav.slides}</span>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
@@ -88,8 +93,8 @@ export default function NavigationFAB({
|
||||
</div>
|
||||
|
||||
{/* Slide List */}
|
||||
<div className="overflow-y-auto max-h-[55vh] py-2">
|
||||
{i.slideNames.map((name, idx) => {
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{activeSlideNames.map((name, idx) => {
|
||||
const isActive = idx === currentIndex
|
||||
const isVisited = visitedSlides.has(idx)
|
||||
const isAI = idx === totalSlides - 1
|
||||
@@ -129,7 +134,7 @@ export default function NavigationFAB({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-white/10 px-4 py-3 space-y-2">
|
||||
<div className="shrink-0 border-t border-white/10 px-4 py-3 space-y-2">
|
||||
{/* Language Toggle */}
|
||||
<button
|
||||
onClick={onToggleLanguage}
|
||||
@@ -164,6 +169,18 @@ export default function NavigationFAB({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* AI Presenter */}
|
||||
{onPresenterStart && (
|
||||
<button
|
||||
onClick={() => { onPresenterStart(); setIsOpen(false) }}
|
||||
className="w-full flex items-center justify-between px-3 py-2 rounded-lg
|
||||
bg-indigo-500/10 hover:bg-indigo-500/20 transition-colors text-sm"
|
||||
>
|
||||
<span className="text-indigo-300">{lang === 'de' ? 'KI-Präsentation starten' : 'Start AI Presenter'}</span>
|
||||
<Bot className="w-4 h-4 text-indigo-400" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { useSlideNavigation } from '@/lib/hooks/useSlideNavigation'
|
||||
import { useSlideNavigation, SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
|
||||
import { SHOWCASE_HIDDEN_SLIDES } from '@/lib/slide-order'
|
||||
import { useKeyboard } from '@/lib/hooks/useKeyboard'
|
||||
import { usePitchData } from '@/lib/hooks/usePitchData'
|
||||
import { usePresenterMode } from '@/lib/hooks/usePresenterMode'
|
||||
import { useAuditTracker } from '@/lib/hooks/useAuditTracker'
|
||||
import { Language, PitchData } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import { Investor } from '@/lib/hooks/useAuth'
|
||||
|
||||
import Link from 'next/link'
|
||||
@@ -68,18 +70,28 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
||||
const data = previewData || fetched.data
|
||||
const loading = previewData ? false : fetched.loading
|
||||
const error = previewData ? null : fetched.error
|
||||
const nav = useSlideNavigation()
|
||||
const [fabOpen, setFabOpen] = useState(false)
|
||||
const isWandeldarlehen = (data?.funding?.instrument || '').toLowerCase() === 'wandeldarlehen'
|
||||
const isShowcase = investor?.is_showcase === true
|
||||
|
||||
// For version previews: use the version's default FM scenario instead of base table default
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const fmScenarios = (previewData as any)?.fm_scenarios as Array<{ id: string; is_default?: boolean }> | undefined
|
||||
const preferredScenarioId = fmScenarios?.[0]?.is_default
|
||||
? fmScenarios[0].id
|
||||
: fmScenarios?.length === 1
|
||||
? fmScenarios[0].id
|
||||
: null
|
||||
// Derive fp_scenario IDs from version snapshot (fm_scenarios stores fp_scenario IDs directly)
|
||||
const fpScenarios = data?.fp_scenarios || []
|
||||
const fpBaseScenarioId = fpScenarios.find(s => s.is_default)?.id ?? fpScenarios[0]?.id ?? null
|
||||
const preferredScenarioId = fpBaseScenarioId
|
||||
|
||||
// Showcase mode: filter out investor/financial slides
|
||||
const activeSlideOrder = isShowcase
|
||||
? SLIDE_ORDER.filter(s => !SHOWCASE_HIDDEN_SLIDES.has(s))
|
||||
: SLIDE_ORDER
|
||||
|
||||
const nav = useSlideNavigation(activeSlideOrder)
|
||||
|
||||
// Map active slide IDs → localized names for sidebar/overview
|
||||
const i18n = t(lang)
|
||||
const activeSlideNames = activeSlideOrder.map(id => {
|
||||
const idx = SLIDE_ORDER.indexOf(id)
|
||||
return idx >= 0 ? i18n.slideNames[idx] : id
|
||||
})
|
||||
|
||||
// Skip cap-table slide for Wandeldarlehen versions
|
||||
useEffect(() => {
|
||||
@@ -93,6 +105,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
||||
currentSlide: nav.currentIndex,
|
||||
totalSlides: nav.totalSlides,
|
||||
language: lang,
|
||||
slideOrder: activeSlideOrder,
|
||||
})
|
||||
|
||||
// Audit tracking
|
||||
@@ -163,7 +176,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
||||
/>
|
||||
)
|
||||
case 'executive-summary':
|
||||
return <ExecutiveSummarySlide lang={lang} data={data} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} />
|
||||
return <ExecutiveSummarySlide lang={lang} data={data} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} fpBaseScenarioId={fpBaseScenarioId} />
|
||||
case 'cover':
|
||||
return <CoverSlide lang={lang} onNext={nav.nextSlide} funding={data.funding} />
|
||||
case 'problem':
|
||||
@@ -189,7 +202,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
||||
case 'team':
|
||||
return <TeamSlide lang={lang} team={data.team} />
|
||||
case 'financials':
|
||||
return <FinancialsSlide lang={lang} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} />
|
||||
return <FinancialsSlide lang={lang} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} fpBaseScenarioId={fpBaseScenarioId} />
|
||||
case 'the-ask':
|
||||
return <TheAskSlide lang={lang} funding={data.funding} isWandeldarlehen={isWandeldarlehen} />
|
||||
case 'cap-table':
|
||||
@@ -200,7 +213,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
||||
case 'ai-qa':
|
||||
return <AIQASlide lang={lang} />
|
||||
case 'annex-assumptions':
|
||||
return <AssumptionsSlide lang={lang} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} />
|
||||
return <AssumptionsSlide lang={lang} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} fpScenarios={fpScenarios} />
|
||||
case 'annex-architecture':
|
||||
return <ArchitectureSlide lang={lang} />
|
||||
case 'annex-gtm':
|
||||
@@ -216,7 +229,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
||||
case 'annex-strategy':
|
||||
return <StrategySlide lang={lang} isWandeldarlehen={isWandeldarlehen} />
|
||||
case 'annex-finanzplan':
|
||||
return <FinanzplanSlide lang={lang} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} />
|
||||
return <FinanzplanSlide lang={lang} investorId={investor?.id || null} preferredScenarioId={preferredScenarioId} isWandeldarlehen={isWandeldarlehen} fpBaseScenarioId={fpBaseScenarioId} fpScenarios={fpScenarios} />
|
||||
case 'annex-glossary':
|
||||
return <GlossarySlide lang={lang} />
|
||||
case 'risks':
|
||||
@@ -239,8 +252,8 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
||||
{/* Investor watermark */}
|
||||
{investor && <Watermark text={investor.email} />}
|
||||
|
||||
{/* Data Room link — only for real investor sessions, not preview */}
|
||||
{investor && !previewData && (
|
||||
{/* Data Room link — only for real investor sessions, not preview, not showcase */}
|
||||
{investor && !previewData && !isShowcase && (
|
||||
<Link
|
||||
href="/dataroom"
|
||||
className="fixed top-4 right-4 z-40 flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-white/[0.06] border border-white/[0.08] text-white/50 hover:text-white/80 hover:bg-white/[0.1] backdrop-blur-sm transition-all text-xs"
|
||||
@@ -280,6 +293,8 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
||||
onGoToSlide={nav.goToSlide}
|
||||
lang={lang}
|
||||
onToggleLanguage={onToggleLanguage}
|
||||
slideNames={activeSlideNames}
|
||||
onPresenterStart={isShowcase ? presenter.start : undefined}
|
||||
/>
|
||||
|
||||
{/* Presenter UI */}
|
||||
@@ -305,6 +320,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
||||
onGoToSlide={nav.goToSlide}
|
||||
onClose={() => nav.setShowOverview(false)}
|
||||
lang={lang}
|
||||
slideNames={activeSlideNames}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -9,10 +9,12 @@ interface SlideOverviewProps {
|
||||
onGoToSlide: (index: number) => void
|
||||
onClose: () => void
|
||||
lang: Language
|
||||
slideNames?: string[]
|
||||
}
|
||||
|
||||
export default function SlideOverview({ currentIndex, onGoToSlide, onClose, lang }: SlideOverviewProps) {
|
||||
export default function SlideOverview({ currentIndex, onGoToSlide, onClose, lang, slideNames: slideNamesProp }: SlideOverviewProps) {
|
||||
const i = t(lang)
|
||||
const activeSlideNames = slideNamesProp ?? i.slideNames
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -28,7 +30,7 @@ export default function SlideOverview({ currentIndex, onGoToSlide, onClose, lang
|
||||
className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 max-w-5xl w-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{i.slideNames.map((name, idx) => (
|
||||
{activeSlideNames.map((name, idx) => (
|
||||
<motion.button
|
||||
key={idx}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Language } from '@/lib/types'
|
||||
import { Language, FpScenarioRef } from '@/lib/types'
|
||||
import { t } from '@/lib/i18n'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
@@ -13,6 +13,7 @@ interface AssumptionsSlideProps {
|
||||
investorId?: string | null
|
||||
preferredScenarioId?: string | null
|
||||
isWandeldarlehen?: boolean
|
||||
fpScenarios?: FpScenarioRef[]
|
||||
}
|
||||
|
||||
interface SheetRow {
|
||||
@@ -72,7 +73,7 @@ async function loadScenarioKPIs(scenarioId: string | null): Promise<ScenarioKPIs
|
||||
}
|
||||
}
|
||||
|
||||
export default function AssumptionsSlide({ lang, isWandeldarlehen }: AssumptionsSlideProps) {
|
||||
export default function AssumptionsSlide({ lang, isWandeldarlehen, fpScenarios }: AssumptionsSlideProps) {
|
||||
const i = t(lang)
|
||||
const de = lang === 'de'
|
||||
|
||||
@@ -80,14 +81,19 @@ export default function AssumptionsSlide({ lang, isWandeldarlehen }: Assumptions
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const baseId = isWandeldarlehen ? 'c0000000-0000-0000-0000-000000000200' : null
|
||||
const bearId = isWandeldarlehen ? 'd0000000-0000-0000-0000-000000000201' : 'd0000000-0000-0000-0000-000000000301'
|
||||
const bullId = isWandeldarlehen ? 'd0000000-0000-0000-0000-000000000202' : 'd0000000-0000-0000-0000-000000000302'
|
||||
const scenarios = fpScenarios || []
|
||||
const find = (role: 'bear' | 'bull' | 'base') => {
|
||||
if (role === 'base') return scenarios.find(s => s.is_default)?.id ?? null
|
||||
return scenarios.find(s => s.name.toLowerCase().includes(role))?.id ?? null
|
||||
}
|
||||
const baseId = find('base')
|
||||
const bearId = find('bear')
|
||||
const bullId = find('bull')
|
||||
const [bear, base, bull] = await Promise.all([loadScenarioKPIs(bearId), loadScenarioKPIs(baseId), loadScenarioKPIs(bullId)])
|
||||
setScenarioData({ bear, base, bull })
|
||||
}
|
||||
load()
|
||||
}, [isWandeldarlehen])
|
||||
}, [fpScenarios])
|
||||
|
||||
const bear = scenarioData?.bear || { arr: 0, customers: 0, headcount: 0, cash: 0, breakEvenYear: '—' }
|
||||
const base = scenarioData?.base || { arr: 0, customers: 0, headcount: 0, cash: 0, breakEvenYear: '—' }
|
||||
|
||||
@@ -16,15 +16,16 @@ interface ExecutiveSummarySlideProps {
|
||||
investorId?: string | null
|
||||
preferredScenarioId?: string | null
|
||||
isWandeldarlehen?: boolean
|
||||
fpBaseScenarioId?: string | null
|
||||
}
|
||||
|
||||
export default function ExecutiveSummarySlide({ lang, data, investorId, preferredScenarioId, isWandeldarlehen }: ExecutiveSummarySlideProps) {
|
||||
export default function ExecutiveSummarySlide({ lang, data, investorId, preferredScenarioId, isWandeldarlehen, fpBaseScenarioId }: ExecutiveSummarySlideProps) {
|
||||
const i = t(lang)
|
||||
const es = i.executiveSummary
|
||||
const de = lang === 'de'
|
||||
|
||||
// Unternehmensentwicklung from fp_* tables (source of truth)
|
||||
const { kpis: fpKPIs } = useFpKPIs(isWandeldarlehen)
|
||||
const { kpis: fpKPIs } = useFpKPIs(fpBaseScenarioId)
|
||||
|
||||
// Pipeline stats from DB
|
||||
const [pipelineStats, setPipelineStats] = useState<Record<string, { value: number }>>({})
|
||||
|
||||
@@ -25,9 +25,10 @@ interface FinancialsSlideProps {
|
||||
investorId: string | null
|
||||
preferredScenarioId?: string | null
|
||||
isWandeldarlehen?: boolean
|
||||
fpBaseScenarioId?: string | null
|
||||
}
|
||||
|
||||
export default function FinancialsSlide({ lang, investorId, preferredScenarioId, isWandeldarlehen }: FinancialsSlideProps) {
|
||||
export default function FinancialsSlide({ lang, investorId, preferredScenarioId, isWandeldarlehen, fpBaseScenarioId }: FinancialsSlideProps) {
|
||||
const i = t(lang)
|
||||
const fm = useFinancialModel(investorId, preferredScenarioId)
|
||||
const [activeTab, setActiveTab] = useState<FinTab>('overview')
|
||||
@@ -38,7 +39,7 @@ export default function FinancialsSlide({ lang, investorId, preferredScenarioId,
|
||||
const lastResult = activeResults?.results[activeResults.results.length - 1]
|
||||
|
||||
// KPI cards from fp_* tables (source of truth)
|
||||
const { last: fpLast, kpis: fpKPIs } = useFpKPIs(isWandeldarlehen)
|
||||
const { last: fpLast, kpis: fpKPIs } = useFpKPIs(fpBaseScenarioId)
|
||||
const kpiArr = fpLast?.arr || summary?.final_arr || 0
|
||||
const kpiCustomers = fpLast?.customers || summary?.final_customers || 0
|
||||
const kpiEbit = fpKPIs?.y2029?.ebit // First profitable year
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Language } from '@/lib/types'
|
||||
import { Language, FpScenarioRef } from '@/lib/types'
|
||||
import ProjectionFooter from '../ui/ProjectionFooter'
|
||||
import GradientText from '../ui/GradientText'
|
||||
import FadeInView from '../ui/FadeInView'
|
||||
@@ -20,14 +20,16 @@ interface FinanzplanSlideProps {
|
||||
investorId?: string | null
|
||||
preferredScenarioId?: string | null
|
||||
isWandeldarlehen?: boolean
|
||||
fpBaseScenarioId?: string | null
|
||||
fpScenarios?: FpScenarioRef[]
|
||||
}
|
||||
|
||||
export default function FinanzplanSlide({ lang, investorId, preferredScenarioId, isWandeldarlehen }: FinanzplanSlideProps) {
|
||||
export default function FinanzplanSlide({ lang, investorId, preferredScenarioId, isWandeldarlehen, fpBaseScenarioId, fpScenarios }: FinanzplanSlideProps) {
|
||||
const [sheets, setSheets] = useState<SheetMeta[]>([])
|
||||
const [scenarios, setScenarios] = useState<FpScenario[]>([])
|
||||
const [openCats, setOpenCats] = useState<Set<string>>(new Set())
|
||||
const toggleCat = (cat: string) => setOpenCats(prev => { const n = new Set(prev); n.has(cat) ? n.delete(cat) : n.add(cat); return n })
|
||||
const [selectedScenarioId, setSelectedScenarioId] = useState<string>('')
|
||||
const [selectedScenarioId, setSelectedScenarioId] = useState<string>(fpBaseScenarioId ?? '')
|
||||
const [activeSheet, setActiveSheet] = useState<string>('guv')
|
||||
const [rows, setRows] = useState<SheetRow[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -96,18 +98,20 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId,
|
||||
loadKPIs()
|
||||
}, [selectedScenarioId])
|
||||
|
||||
// Load sheet list + scenarios
|
||||
// Load sheet list; populate scenario selector from version data or API fallback
|
||||
useEffect(() => {
|
||||
fetch('/api/finanzplan', { cache: 'no-store' })
|
||||
const listParam = fpBaseScenarioId ? `?scenarioId=${fpBaseScenarioId}` : ''
|
||||
fetch(`/api/finanzplan${listParam}`, { cache: 'no-store' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setSheets(data.sheets || [])
|
||||
const scens: FpScenario[] = data.scenarios || []
|
||||
// Use version fp_scenarios if available, else fall back to API list
|
||||
const scens: FpScenario[] = fpScenarios && fpScenarios.length > 0
|
||||
? fpScenarios.map(s => ({ id: s.id, name: s.name, is_default: s.is_default ?? false, color: s.color ?? '#6366f1', description: s.description ?? '' }))
|
||||
: data.scenarios || []
|
||||
setScenarios(scens)
|
||||
// Pick scenario: Wandeldarlehen version → WD scenario, otherwise default
|
||||
if (!selectedScenarioId) {
|
||||
const wdScenario = isWandeldarlehen ? scens.find(s => s.name.toLowerCase().includes('wandeldarlehen') && !s.name.toLowerCase().includes('bear') && !s.name.toLowerCase().includes('bull')) : null
|
||||
const def = wdScenario ?? scens.find(s => s.is_default) ?? scens[0]
|
||||
const def = scens.find(s => s.is_default) ?? scens[0]
|
||||
if (def) setSelectedScenarioId(def.id)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Investor {
|
||||
last_login_at: string | null
|
||||
login_count: number
|
||||
created_at: string
|
||||
is_showcase: boolean
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
|
||||
@@ -31,14 +31,14 @@ interface SheetRow {
|
||||
* Loads annual KPIs directly from fp_* tables (source of truth).
|
||||
* Returns a map of year keys (y2026-y2030) to KPI objects.
|
||||
*/
|
||||
export function useFpKPIs(isWandeldarlehen?: boolean) {
|
||||
export function useFpKPIs(fpBaseScenarioId?: string | null) {
|
||||
const [kpis, setKpis] = useState<Record<string, FpAnnualKPIs>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const param = isWandeldarlehen ? '?scenarioId=c0000000-0000-0000-0000-000000000200' : ''
|
||||
const param = fpBaseScenarioId ? `?scenarioId=${fpBaseScenarioId}` : ''
|
||||
const [guvRes, liqRes, persRes, kundenRes] = await Promise.all([
|
||||
fetch(`/api/finanzplan/guv${param}`, { cache: 'no-store' }),
|
||||
fetch(`/api/finanzplan/liquiditaet${param}`, { cache: 'no-store' }),
|
||||
@@ -102,7 +102,7 @@ export function useFpKPIs(isWandeldarlehen?: boolean) {
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [isWandeldarlehen])
|
||||
}, [fpBaseScenarioId])
|
||||
|
||||
// Use of Funds: compute spending breakdown m8-m24 (funding period)
|
||||
const [useOfFunds, setUseOfFunds] = useState<Array<{ category: string; label_de: string; label_en: string; percentage: number }>>([])
|
||||
@@ -110,7 +110,7 @@ export function useFpKPIs(isWandeldarlehen?: boolean) {
|
||||
useEffect(() => {
|
||||
async function loadUoF() {
|
||||
try {
|
||||
const param = isWandeldarlehen ? '?scenarioId=c0000000-0000-0000-0000-000000000200' : ''
|
||||
const param = fpBaseScenarioId ? `?scenarioId=${fpBaseScenarioId}` : ''
|
||||
const [persRes, betriebRes, investRes] = await Promise.all([
|
||||
fetch(`/api/finanzplan/personalkosten${param}`, { cache: 'no-store' }),
|
||||
fetch(`/api/finanzplan/betriebliche${param}`, { cache: 'no-store' }),
|
||||
@@ -168,7 +168,7 @@ export function useFpKPIs(isWandeldarlehen?: boolean) {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
loadUoF()
|
||||
}, [isWandeldarlehen])
|
||||
}, [fpBaseScenarioId])
|
||||
|
||||
const last = kpis.y2030
|
||||
return { kpis, loading, last, useOfFunds }
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Language } from '../types'
|
||||
import { PresenterState, SlideScript } from '../presenter/types'
|
||||
import { PRESENTER_SCRIPT } from '../presenter/presenter-script'
|
||||
import { SLIDE_ORDER } from './useSlideNavigation'
|
||||
import { SlideId } from '../types'
|
||||
|
||||
interface UsePresenterModeConfig {
|
||||
goToSlide: (index: number) => void
|
||||
@@ -12,6 +13,7 @@ interface UsePresenterModeConfig {
|
||||
totalSlides: number
|
||||
language: Language
|
||||
ttsEnabled?: boolean
|
||||
slideOrder?: SlideId[]
|
||||
}
|
||||
|
||||
interface UsePresenterModeReturn {
|
||||
@@ -57,8 +59,8 @@ interface SlideAudioPlan {
|
||||
segments: AudioSegment[]
|
||||
}
|
||||
|
||||
function buildSlideAudioPlan(slideIdx: number, lang: Language): SlideAudioPlan | null {
|
||||
const slideId = SLIDE_ORDER[slideIdx]
|
||||
function buildSlideAudioPlan(slideIdx: number, lang: Language, activeSlideOrder: SlideId[]): SlideAudioPlan | null {
|
||||
const slideId = activeSlideOrder[slideIdx]
|
||||
const script = PRESENTER_SCRIPT.find(s => s.slideId === slideId)
|
||||
if (!script || script.paragraphs.length === 0) return null
|
||||
|
||||
@@ -121,7 +123,9 @@ export function usePresenterMode({
|
||||
totalSlides,
|
||||
language,
|
||||
ttsEnabled: initialTtsEnabled = true,
|
||||
slideOrder: slideOrderProp,
|
||||
}: UsePresenterModeConfig): UsePresenterModeReturn {
|
||||
const activeSlideOrder = slideOrderProp ?? SLIDE_ORDER
|
||||
const [state, setState] = useState<PresenterState>('idle')
|
||||
const [currentParagraph, setCurrentParagraph] = useState(0)
|
||||
const [displayText, setDisplayText] = useState('')
|
||||
@@ -193,7 +197,7 @@ export function usePresenterMode({
|
||||
playSlideRef.current = async (slideIdx: number) => {
|
||||
if (stateRef.current !== 'presenting') return
|
||||
|
||||
const plan = buildSlideAudioPlan(slideIdx, language)
|
||||
const plan = buildSlideAudioPlan(slideIdx, language, activeSlideOrder)
|
||||
if (!plan) {
|
||||
// No script for this slide — skip to next
|
||||
if (slideIdx < totalSlides - 1) {
|
||||
@@ -216,7 +220,7 @@ export function usePresenterMode({
|
||||
|
||||
// Pre-fetch next slide's audio in background
|
||||
if (slideIdx < totalSlides - 1) {
|
||||
const nextPlan = buildSlideAudioPlan(slideIdx + 1, language)
|
||||
const nextPlan = buildSlideAudioPlan(slideIdx + 1, language, activeSlideOrder)
|
||||
if (nextPlan) fetchAudio(nextPlan.fullText, language).catch(() => {})
|
||||
}
|
||||
|
||||
@@ -329,7 +333,7 @@ export function usePresenterMode({
|
||||
setIsSpeaking(false)
|
||||
}
|
||||
}
|
||||
}, [language, totalSlides, goToSlide, ttsAvailable, ttsEnabled])
|
||||
}, [language, totalSlides, goToSlide, ttsAvailable, ttsEnabled, activeSlideOrder])
|
||||
|
||||
const start = useCallback(() => {
|
||||
unlockAudio()
|
||||
@@ -410,14 +414,18 @@ export function usePresenterMode({
|
||||
}
|
||||
}, [unlockAudio, start, stop])
|
||||
|
||||
// Calculate overall progress
|
||||
// Calculate overall progress against the active slide order's scripts
|
||||
const progress = (() => {
|
||||
if (state === 'idle') return 0
|
||||
const totalScripts = PRESENTER_SCRIPT.length
|
||||
const currentScriptIdx = PRESENTER_SCRIPT.findIndex(s => s.slideId === SLIDE_ORDER[currentSlide])
|
||||
const currentSlideId = activeSlideOrder[currentSlide]
|
||||
const activeScripts = activeSlideOrder
|
||||
.map(id => PRESENTER_SCRIPT.find(s => s.slideId === id))
|
||||
.filter(Boolean) as typeof PRESENTER_SCRIPT
|
||||
const totalScripts = activeScripts.length || 1
|
||||
const currentScriptIdx = activeScripts.findIndex(s => s.slideId === currentSlideId)
|
||||
if (currentScriptIdx < 0) return (currentSlide / totalSlides) * 100
|
||||
|
||||
const script = PRESENTER_SCRIPT[currentScriptIdx]
|
||||
const script = activeScripts[currentScriptIdx]
|
||||
const slideProgress = script.paragraphs.length > 0
|
||||
? currentParagraph / script.paragraphs.length
|
||||
: 0
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { SlideId } from '../types'
|
||||
import { SLIDE_ORDER, TOTAL_SLIDES } from '../slide-order'
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export { SLIDE_ORDER, TOTAL_SLIDES }
|
||||
|
||||
export function useSlideNavigation() {
|
||||
export function useSlideNavigation(slideOrder: SlideId[] = SLIDE_ORDER) {
|
||||
const total = slideOrder.length
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [direction, setDirection] = useState(0)
|
||||
const [visitedSlides, setVisitedSlides] = useState<Set<number>>(new Set([0]))
|
||||
const [showOverview, setShowOverview] = useState(false)
|
||||
|
||||
const currentSlide = SLIDE_ORDER[currentIndex]
|
||||
const currentSlide = slideOrder[currentIndex]
|
||||
|
||||
const goToSlide = useCallback((index: number) => {
|
||||
if (index < 0 || index >= TOTAL_SLIDES) return
|
||||
if (index < 0 || index >= total) return
|
||||
setDirection(index > currentIndex ? 1 : -1)
|
||||
setCurrentIndex(index)
|
||||
setVisitedSlides(prev => new Set([...prev, index]))
|
||||
@@ -23,10 +25,10 @@ export function useSlideNavigation() {
|
||||
}, [currentIndex])
|
||||
|
||||
const nextSlide = useCallback(() => {
|
||||
if (currentIndex < TOTAL_SLIDES - 1) {
|
||||
if (currentIndex < total - 1) {
|
||||
goToSlide(currentIndex + 1)
|
||||
}
|
||||
}, [currentIndex, goToSlide])
|
||||
}, [currentIndex, goToSlide, total])
|
||||
|
||||
const prevSlide = useCallback(() => {
|
||||
if (currentIndex > 0) {
|
||||
@@ -35,7 +37,7 @@ export function useSlideNavigation() {
|
||||
}, [currentIndex, goToSlide])
|
||||
|
||||
const goToFirst = useCallback(() => goToSlide(0), [goToSlide])
|
||||
const goToLast = useCallback(() => goToSlide(TOTAL_SLIDES - 1), [goToSlide])
|
||||
const goToLast = useCallback(() => goToSlide(total - 1), [goToSlide, total])
|
||||
|
||||
const toggleOverview = useCallback(() => {
|
||||
setShowOverview(prev => !prev)
|
||||
@@ -47,8 +49,8 @@ export function useSlideNavigation() {
|
||||
direction,
|
||||
visitedSlides,
|
||||
showOverview,
|
||||
totalSlides: TOTAL_SLIDES,
|
||||
slideOrder: SLIDE_ORDER,
|
||||
totalSlides: total,
|
||||
slideOrder,
|
||||
goToSlide,
|
||||
nextSlide,
|
||||
prevSlide,
|
||||
@@ -57,6 +59,6 @@ export function useSlideNavigation() {
|
||||
toggleOverview,
|
||||
setShowOverview,
|
||||
isFirst: currentIndex === 0,
|
||||
isLast: currentIndex === TOTAL_SLIDES - 1,
|
||||
isLast: currentIndex === total - 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { SlideId } from './types'
|
||||
|
||||
// Slides hidden in showcase (customer) mode — financial and investor-specific content
|
||||
export const SHOWCASE_HIDDEN_SLIDES = new Set<SlideId>([
|
||||
'financials',
|
||||
'the-ask',
|
||||
'cap-table',
|
||||
'annex-assumptions',
|
||||
'annex-finanzplan',
|
||||
'risks',
|
||||
])
|
||||
|
||||
export const SLIDE_ORDER: SlideId[] = [
|
||||
'intro-presenter',
|
||||
'executive-summary',
|
||||
|
||||
@@ -114,6 +114,14 @@ export interface PitchProduct {
|
||||
operating_cost_eur: number
|
||||
}
|
||||
|
||||
export interface FpScenarioRef {
|
||||
id: string
|
||||
name: string
|
||||
is_default?: boolean
|
||||
color?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface PitchData {
|
||||
company: PitchCompany
|
||||
team: PitchTeamMember[]
|
||||
@@ -125,6 +133,7 @@ export interface PitchData {
|
||||
metrics: PitchMetric[]
|
||||
funding: PitchFunding
|
||||
products: PitchProduct[]
|
||||
fp_scenarios?: FpScenarioRef[]
|
||||
}
|
||||
|
||||
// Financial Model Types
|
||||
|
||||
Reference in New Issue
Block a user