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(
|
pool.query(
|
||||||
`SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count,
|
`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.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
|
v.name AS version_name, v.status AS version_status
|
||||||
FROM pitch_investors i
|
FROM pitch_investors i
|
||||||
LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id
|
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 { id } = await ctx.params
|
||||||
const body = await request.json().catch(() => ({}))
|
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) {
|
if (name === undefined && company === undefined && assigned_version_id === undefined && is_showcase === undefined) {
|
||||||
return NextResponse.json({ error: 'name, company, or assigned_version_id required' }, { status: 400 })
|
return NextResponse.json({ error: 'name, company, assigned_version_id, or is_showcase required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const before = await pool.query(
|
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],
|
[id],
|
||||||
)
|
)
|
||||||
if (before.rows.length === 0) {
|
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
|
// 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 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(
|
const { rows } = await pool.query(
|
||||||
`UPDATE pitch_investors SET
|
`UPDATE pitch_investors SET
|
||||||
name = COALESCE($1, name),
|
name = COALESCE($1, name),
|
||||||
company = COALESCE($2, company),
|
company = COALESCE($2, company),
|
||||||
assigned_version_id = $4,
|
assigned_version_id = $4,
|
||||||
|
is_showcase = $5,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $3
|
WHERE id = $3
|
||||||
RETURNING id, email, name, company, status, assigned_version_id`,
|
RETURNING id, email, name, company, status, assigned_version_id, is_showcase`,
|
||||||
[name ?? null, company ?? null, id, versionValue],
|
[name ?? null, company ?? null, id, versionValue, showcaseValue],
|
||||||
)
|
)
|
||||||
|
|
||||||
const action = assigned_version_id !== undefined && assigned_version_id !== before.rows[0].assigned_version_id
|
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(
|
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`,
|
FROM pitch_investors WHERE id = $1`,
|
||||||
[session.sub]
|
[session.sub]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import pool from '@/lib/db'
|
import pool from '@/lib/db'
|
||||||
import { getSessionFromCookie } from '@/lib/auth'
|
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_URL = process.env.LITELLM_URL || 'https://llm-dev.meghsakha.com'
|
||||||
const LITELLM_MODEL = process.env.LITELLM_MODEL || 'gpt-oss-120b'
|
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.`
|
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> {
|
async function loadFpLiquiditaetSummary(scenarioName: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
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 {
|
try {
|
||||||
// Version-specific data path
|
// Version-specific data path
|
||||||
if (versionId) {
|
if (versionId) {
|
||||||
@@ -225,11 +238,11 @@ async function loadPitchContext(versionId?: string | null): Promise<PitchContext
|
|||||||
|
|
||||||
const versionName = vNameRes.rows[0]?.name ?? ''
|
const versionName = vNameRes.rows[0]?.name ?? ''
|
||||||
const meta = extractMeta(versionName, fmScenarios, funding, financials)
|
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 {
|
return {
|
||||||
contextString: buildContextString(company, team, financials, market, products, funding, features, fpSummary),
|
contextString: buildContextString(company, team, financials, market, products, funding, features, fpSummary, isShowcase),
|
||||||
meta,
|
meta: isShowcase ? { ...DEFAULT_META, versionName } : meta,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,9 +262,9 @@ async function loadPitchContext(versionId?: string | null): Promise<PitchContext
|
|||||||
return {
|
return {
|
||||||
contextString: buildContextString(
|
contextString: buildContextString(
|
||||||
company.rows[0], team.rows, financials.rows, market.rows,
|
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 {
|
} finally {
|
||||||
client.release()
|
client.release()
|
||||||
@@ -264,8 +277,19 @@ async function loadPitchContext(versionId?: string | null): Promise<PitchContext
|
|||||||
|
|
||||||
function buildContextString(
|
function buildContextString(
|
||||||
company: unknown, team: unknown, financials: unknown, market: unknown,
|
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 {
|
): 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 `
|
return `
|
||||||
## Unternehmensdaten (für präzise Antworten nutzen)
|
## Unternehmensdaten (für präzise Antworten nutzen)
|
||||||
|
|
||||||
@@ -274,22 +298,16 @@ ${JSON.stringify(company, null, 2)}
|
|||||||
|
|
||||||
### Team
|
### Team
|
||||||
${JSON.stringify(team, null, 2)}
|
${JSON.stringify(team, null, 2)}
|
||||||
|
${finSection}
|
||||||
### Finanzprognosen (5-Jahres-Plan)
|
|
||||||
${JSON.stringify(financials, null, 2)}
|
|
||||||
|
|
||||||
### Markt (TAM/SAM/SOM)
|
### Markt (TAM/SAM/SOM)
|
||||||
${JSON.stringify(market, null, 2)}
|
${JSON.stringify(market, null, 2)}
|
||||||
|
|
||||||
### Produkte
|
### Produkte
|
||||||
${JSON.stringify(products, null, 2)}
|
${JSON.stringify(products, null, 2)}
|
||||||
|
${fundSection}
|
||||||
### Finanzierung
|
|
||||||
${JSON.stringify(funding, null, 2)}
|
|
||||||
|
|
||||||
### Differenzierende Features (nur bei ComplAI)
|
### Differenzierende Features (nur bei ComplAI)
|
||||||
${JSON.stringify(features, null, 2)}
|
${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 })
|
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 versionId: string | null = null
|
||||||
|
let isShowcase = false
|
||||||
try {
|
try {
|
||||||
const session = await getSessionFromCookie()
|
const session = await getSessionFromCookie()
|
||||||
if (session?.sub) {
|
if (session?.sub) {
|
||||||
const inv = await pool.query(
|
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]
|
[session.sub]
|
||||||
)
|
)
|
||||||
versionId = inv.rows[0]?.assigned_version_id ?? null
|
versionId = inv.rows[0]?.assigned_version_id ?? null
|
||||||
|
isShowcase = inv.rows[0]?.is_showcase === true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Non-fatal: fall back to base tables
|
// 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
|
// Build dynamic VERSIONS-ISOLATION and Kernbotschaft #9 from actual version data
|
||||||
const fmt = (n: number) => n.toLocaleString('de-DE')
|
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}."`
|
const dynamicFinanzplanKernbotschaft = `9. Finanzplan: "Gründung August 2026. Pre-Seed über ${fundingStr}. ${customersStr} und ${revM} Umsatz bis 2030. ${employeesStr}."`
|
||||||
|
|
||||||
let systemContent = SYSTEM_PROMPT_PART1
|
let systemContent: string
|
||||||
+ '\n' + dynamicFinanzplanKernbotschaft
|
if (isShowcase) {
|
||||||
+ SYSTEM_PROMPT_PART2
|
// Showcase: product-only context, no financial details anywhere
|
||||||
+ '\n\n' + dynamicVersionIsolation
|
systemContent = SHOWCASE_GUARD
|
||||||
+ SYSTEM_PROMPT_PART3
|
+ '\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) {
|
if (contextString) {
|
||||||
systemContent += '\n' + contextString
|
systemContent += '\n' + contextString
|
||||||
}
|
}
|
||||||
|
|
||||||
// FAQ context: relevant pre-researched answers as basis for the LLM
|
// FAQ context — skip for showcase (may contain financial hints)
|
||||||
// IMPORTANT: FAQ entries contain hardcoded numbers written for specific scenarios.
|
if (!isShowcase && faqContext && typeof faqContext === 'string') {
|
||||||
// They are hints only — the version-specific Unternehmensdaten above always take precedence.
|
|
||||||
if (faqContext && typeof faqContext === 'string') {
|
|
||||||
systemContent += '\n' + faqContext
|
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.'
|
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) {
|
if (slideContext) {
|
||||||
const visited: number[] = slideContext.visitedSlides || []
|
const visited: number[] = slideContext.visitedSlides || []
|
||||||
const currentSlideId = slideContext.currentSlide
|
const currentSlideId = slideContext.currentSlide
|
||||||
const currentSlideName = SLIDE_DISPLAY_NAMES[currentSlideId]?.[lang] || currentSlideId
|
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 }))
|
.map((id, idx) => ({ id, idx, name: SLIDE_DISPLAY_NAMES[id]?.[lang] || id }))
|
||||||
.filter(s => !visited.includes(s.idx))
|
.filter(s => !visited.includes(s.idx))
|
||||||
.map(s => `${s.idx + 1}. ${s.name}`)
|
.map(s => `${s.idx + 1}. ${s.name}`)
|
||||||
|
|
||||||
systemContent += `\n\n## Slide-Kontext (WICHTIG für kontextuelle Antworten)
|
systemContent += `\n\n## Slide-Kontext (WICHTIG für kontextuelle Antworten)
|
||||||
- Aktuelle Slide: "${currentSlideName}" (Nr. ${slideContext.currentIndex + 1} von ${slideCount})
|
- Aktuelle Slide: "${currentSlideName}" (Nr. ${slideContext.currentIndex + 1} von ${visibleSlideOrder.length})
|
||||||
- Bereits besuchte Slides: ${visited.map((i: number) => SLIDE_DISPLAY_NAMES[SLIDE_ORDER[i]]?.[lang] || SLIDE_ORDER[i]).filter(Boolean).join(', ')}
|
- Bereits besuchte Slides: ${visited.map((i: number) => SLIDE_DISPLAY_NAMES[visibleSlideOrder[i]]?.[lang] || visibleSlideOrder[i]).filter(Boolean).join(', ')}
|
||||||
- Noch nicht gesehene Slides: ${notYetSeen.join(', ')}
|
- Noch nicht gesehene Slides: ${notYetSeen.join(', ')}
|
||||||
- Ist Erstbesuch: ${visited.length <= 1 ? 'JA — Investor hat gerade erst den Pitch geöffnet' : 'Nein'}
|
- Ist Erstbesuch: ${visited.length <= 1 ? 'JA — Besucher hat gerade erst die Präsentation geöffnet' : 'Nein'}
|
||||||
- Verfügbare Slide-IDs für [GOTO:id]: ${SLIDE_ORDER.join(', ')}
|
- Verfügbare Slide-IDs für [GOTO:id]: ${visibleSlideOrder.join(', ')}
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export async function GET() {
|
|||||||
metrics: map.metrics || [],
|
metrics: map.metrics || [],
|
||||||
funding: (map.funding || [])[0] || null,
|
funding: (map.funding || [])[0] || null,
|
||||||
products: map.products || [],
|
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 })
|
return NextResponse.json({ error: `Unknown sheet: ${sheetName}` }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only admin callers may query an arbitrary scenarioId; investors always see the default
|
const scenarioId = request.nextUrl.searchParams.get('scenarioId')
|
||||||
const isAdmin = validateAdminSecret(request)
|
|
||||||
const scenarioId = isAdmin ? request.nextUrl.searchParams.get('scenarioId') : null
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let query = `SELECT * FROM ${table}`
|
let query = `SELECT * FROM ${table}`
|
||||||
|
|||||||
@@ -3,25 +3,33 @@ import pool from '@/lib/db'
|
|||||||
import { SHEET_LIST } from '@/lib/finanzplan/types'
|
import { SHEET_LIST } from '@/lib/finanzplan/types'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
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 secret = process.env.PITCH_ADMIN_SECRET
|
||||||
const auth = request.headers.get('authorization') ?? ''
|
const auth = request.headers.get('authorization') ?? ''
|
||||||
const isAdmin = secret && auth === `Bearer ${secret}`
|
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 {
|
try {
|
||||||
// Investors see only the default scenario — no names of other scenarios leaked
|
// Investors see only the default scenario — no names of other scenarios leaked
|
||||||
const scenarios = isAdmin
|
const scenarios = isAdmin
|
||||||
? await pool.query('SELECT * FROM fp_scenarios ORDER BY is_default DESC, name')
|
? 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')
|
: 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(
|
const sheets = await Promise.all(
|
||||||
SHEET_LIST.map(async (s) => {
|
SHEET_LIST.map(async (s) => {
|
||||||
const tableName = `fp_${s.name}`
|
const tableName = `fp_${s.name}`
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = scenarioId
|
||||||
`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)`
|
? 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') }
|
return { ...s, rows: parseInt(rows[0]?.total || '0'), editable_rows: parseInt(rows[0]?.editable || '0') }
|
||||||
} catch {
|
} catch {
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export async function GET(request: NextRequest, ctx: Ctx) {
|
|||||||
metrics: map.metrics || [],
|
metrics: map.metrics || [],
|
||||||
funding: (map.funding || [])[0] || null,
|
funding: (map.funding || [])[0] || null,
|
||||||
products: map.products || [],
|
products: map.products || [],
|
||||||
fm_scenarios: map.fm_scenarios || [],
|
fp_scenarios: map.fm_scenarios || [],
|
||||||
fm_assumptions: map.fm_assumptions || [],
|
fm_assumptions: map.fm_assumptions || [],
|
||||||
_version: { name: ver.rows[0].name, status: ver.rows[0].status },
|
_version: { name: ver.rows[0].name, status: ver.rows[0].status },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface InvestorDetail {
|
|||||||
assigned_version_id: string | null
|
assigned_version_id: string | null
|
||||||
version_name: string | null
|
version_name: string | null
|
||||||
version_status: string | null
|
version_status: string | null
|
||||||
|
is_showcase: boolean
|
||||||
}
|
}
|
||||||
sessions: Array<{
|
sessions: Array<{
|
||||||
id: string
|
id: string
|
||||||
@@ -293,7 +294,7 @@ export default function InvestorDetailPage() {
|
|||||||
{/* Version assignment */}
|
{/* Version assignment */}
|
||||||
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
|
<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>
|
<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
|
<select
|
||||||
value={inv.assigned_version_id || ''}
|
value={inv.assigned_version_id || ''}
|
||||||
onChange={async (e) => {
|
onChange={async (e) => {
|
||||||
@@ -318,10 +319,35 @@ export default function InvestorDetailPage() {
|
|||||||
</select>
|
</select>
|
||||||
<span className="text-xs text-white/40">
|
<span className="text-xs text-white/40">
|
||||||
{inv.assigned_version_id
|
{inv.assigned_version_id
|
||||||
? `Investor sees version "${inv.version_name || ''}"`
|
? `Sees version "${inv.version_name || ''}"`
|
||||||
: 'Investor sees default pitch data'}
|
: 'Sees default pitch data'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
{/* Audit log for this investor */}
|
{/* Audit log for this investor */}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ interface NavigationFABProps {
|
|||||||
onGoToSlide: (index: number) => void
|
onGoToSlide: (index: number) => void
|
||||||
lang: Language
|
lang: Language
|
||||||
onToggleLanguage: () => void
|
onToggleLanguage: () => void
|
||||||
|
slideNames?: string[]
|
||||||
|
onPresenterStart?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NavigationFAB({
|
export default function NavigationFAB({
|
||||||
@@ -22,6 +24,8 @@ export default function NavigationFAB({
|
|||||||
onGoToSlide,
|
onGoToSlide,
|
||||||
lang,
|
lang,
|
||||||
onToggleLanguage,
|
onToggleLanguage,
|
||||||
|
slideNames: slideNamesProp,
|
||||||
|
onPresenterStart,
|
||||||
}: NavigationFABProps) {
|
}: NavigationFABProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
@@ -35,6 +39,7 @@ export default function NavigationFAB({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
const i = t(lang)
|
const i = t(lang)
|
||||||
|
const activeSlideNames = slideNamesProp ?? i.slideNames
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
const toggleFullscreen = useCallback(() => {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
@@ -71,12 +76,12 @@ export default function NavigationFAB({
|
|||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
transition={{ duration: 0.2 }}
|
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
|
bg-black/80 backdrop-blur-xl border border-white/10
|
||||||
shadow-2xl shadow-black/50"
|
shadow-2xl shadow-black/50"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* 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>
|
<span className="text-sm font-semibold text-white">{i.nav.slides}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
@@ -88,8 +93,8 @@ export default function NavigationFAB({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Slide List */}
|
{/* Slide List */}
|
||||||
<div className="overflow-y-auto max-h-[55vh] py-2">
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
{i.slideNames.map((name, idx) => {
|
{activeSlideNames.map((name, idx) => {
|
||||||
const isActive = idx === currentIndex
|
const isActive = idx === currentIndex
|
||||||
const isVisited = visitedSlides.has(idx)
|
const isVisited = visitedSlides.has(idx)
|
||||||
const isAI = idx === totalSlides - 1
|
const isAI = idx === totalSlides - 1
|
||||||
@@ -129,7 +134,7 @@ export default function NavigationFAB({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* 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 */}
|
{/* Language Toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={onToggleLanguage}
|
onClick={onToggleLanguage}
|
||||||
@@ -164,6 +169,18 @@ export default function NavigationFAB({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</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 */}
|
{/* Fullscreen */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { AnimatePresence } from 'framer-motion'
|
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 { useKeyboard } from '@/lib/hooks/useKeyboard'
|
||||||
import { usePitchData } from '@/lib/hooks/usePitchData'
|
import { usePitchData } from '@/lib/hooks/usePitchData'
|
||||||
import { usePresenterMode } from '@/lib/hooks/usePresenterMode'
|
import { usePresenterMode } from '@/lib/hooks/usePresenterMode'
|
||||||
import { useAuditTracker } from '@/lib/hooks/useAuditTracker'
|
import { useAuditTracker } from '@/lib/hooks/useAuditTracker'
|
||||||
import { Language, PitchData } from '@/lib/types'
|
import { Language, PitchData } from '@/lib/types'
|
||||||
|
import { t } from '@/lib/i18n'
|
||||||
import { Investor } from '@/lib/hooks/useAuth'
|
import { Investor } from '@/lib/hooks/useAuth'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
@@ -68,18 +70,28 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
|||||||
const data = previewData || fetched.data
|
const data = previewData || fetched.data
|
||||||
const loading = previewData ? false : fetched.loading
|
const loading = previewData ? false : fetched.loading
|
||||||
const error = previewData ? null : fetched.error
|
const error = previewData ? null : fetched.error
|
||||||
const nav = useSlideNavigation()
|
|
||||||
const [fabOpen, setFabOpen] = useState(false)
|
const [fabOpen, setFabOpen] = useState(false)
|
||||||
const isWandeldarlehen = (data?.funding?.instrument || '').toLowerCase() === 'wandeldarlehen'
|
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
|
// Derive fp_scenario IDs from version snapshot (fm_scenarios stores fp_scenario IDs directly)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const fpScenarios = data?.fp_scenarios || []
|
||||||
const fmScenarios = (previewData as any)?.fm_scenarios as Array<{ id: string; is_default?: boolean }> | undefined
|
const fpBaseScenarioId = fpScenarios.find(s => s.is_default)?.id ?? fpScenarios[0]?.id ?? null
|
||||||
const preferredScenarioId = fmScenarios?.[0]?.is_default
|
const preferredScenarioId = fpBaseScenarioId
|
||||||
? fmScenarios[0].id
|
|
||||||
: fmScenarios?.length === 1
|
// Showcase mode: filter out investor/financial slides
|
||||||
? fmScenarios[0].id
|
const activeSlideOrder = isShowcase
|
||||||
: null
|
? 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
|
// Skip cap-table slide for Wandeldarlehen versions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -93,6 +105,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
|||||||
currentSlide: nav.currentIndex,
|
currentSlide: nav.currentIndex,
|
||||||
totalSlides: nav.totalSlides,
|
totalSlides: nav.totalSlides,
|
||||||
language: lang,
|
language: lang,
|
||||||
|
slideOrder: activeSlideOrder,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Audit tracking
|
// Audit tracking
|
||||||
@@ -163,7 +176,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case 'executive-summary':
|
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':
|
case 'cover':
|
||||||
return <CoverSlide lang={lang} onNext={nav.nextSlide} funding={data.funding} />
|
return <CoverSlide lang={lang} onNext={nav.nextSlide} funding={data.funding} />
|
||||||
case 'problem':
|
case 'problem':
|
||||||
@@ -189,7 +202,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
|||||||
case 'team':
|
case 'team':
|
||||||
return <TeamSlide lang={lang} team={data.team} />
|
return <TeamSlide lang={lang} team={data.team} />
|
||||||
case 'financials':
|
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':
|
case 'the-ask':
|
||||||
return <TheAskSlide lang={lang} funding={data.funding} isWandeldarlehen={isWandeldarlehen} />
|
return <TheAskSlide lang={lang} funding={data.funding} isWandeldarlehen={isWandeldarlehen} />
|
||||||
case 'cap-table':
|
case 'cap-table':
|
||||||
@@ -200,7 +213,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
|||||||
case 'ai-qa':
|
case 'ai-qa':
|
||||||
return <AIQASlide lang={lang} />
|
return <AIQASlide lang={lang} />
|
||||||
case 'annex-assumptions':
|
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':
|
case 'annex-architecture':
|
||||||
return <ArchitectureSlide lang={lang} />
|
return <ArchitectureSlide lang={lang} />
|
||||||
case 'annex-gtm':
|
case 'annex-gtm':
|
||||||
@@ -216,7 +229,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
|||||||
case 'annex-strategy':
|
case 'annex-strategy':
|
||||||
return <StrategySlide lang={lang} isWandeldarlehen={isWandeldarlehen} />
|
return <StrategySlide lang={lang} isWandeldarlehen={isWandeldarlehen} />
|
||||||
case 'annex-finanzplan':
|
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':
|
case 'annex-glossary':
|
||||||
return <GlossarySlide lang={lang} />
|
return <GlossarySlide lang={lang} />
|
||||||
case 'risks':
|
case 'risks':
|
||||||
@@ -239,8 +252,8 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
|||||||
{/* Investor watermark */}
|
{/* Investor watermark */}
|
||||||
{investor && <Watermark text={investor.email} />}
|
{investor && <Watermark text={investor.email} />}
|
||||||
|
|
||||||
{/* Data Room link — only for real investor sessions, not preview */}
|
{/* Data Room link — only for real investor sessions, not preview, not showcase */}
|
||||||
{investor && !previewData && (
|
{investor && !previewData && !isShowcase && (
|
||||||
<Link
|
<Link
|
||||||
href="/dataroom"
|
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"
|
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}
|
onGoToSlide={nav.goToSlide}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
onToggleLanguage={onToggleLanguage}
|
onToggleLanguage={onToggleLanguage}
|
||||||
|
slideNames={activeSlideNames}
|
||||||
|
onPresenterStart={isShowcase ? presenter.start : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Presenter UI */}
|
{/* Presenter UI */}
|
||||||
@@ -305,6 +320,7 @@ export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout,
|
|||||||
onGoToSlide={nav.goToSlide}
|
onGoToSlide={nav.goToSlide}
|
||||||
onClose={() => nav.setShowOverview(false)}
|
onClose={() => nav.setShowOverview(false)}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
|
slideNames={activeSlideNames}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ interface SlideOverviewProps {
|
|||||||
onGoToSlide: (index: number) => void
|
onGoToSlide: (index: number) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
lang: Language
|
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 i = t(lang)
|
||||||
|
const activeSlideNames = slideNamesProp ?? i.slideNames
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<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"
|
className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 max-w-5xl w-full"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{i.slideNames.map((name, idx) => (
|
{activeSlideNames.map((name, idx) => (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={idx}
|
key={idx}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Language } from '@/lib/types'
|
import { Language, FpScenarioRef } from '@/lib/types'
|
||||||
import { t } from '@/lib/i18n'
|
import { t } from '@/lib/i18n'
|
||||||
import GradientText from '../ui/GradientText'
|
import GradientText from '../ui/GradientText'
|
||||||
import FadeInView from '../ui/FadeInView'
|
import FadeInView from '../ui/FadeInView'
|
||||||
@@ -13,6 +13,7 @@ interface AssumptionsSlideProps {
|
|||||||
investorId?: string | null
|
investorId?: string | null
|
||||||
preferredScenarioId?: string | null
|
preferredScenarioId?: string | null
|
||||||
isWandeldarlehen?: boolean
|
isWandeldarlehen?: boolean
|
||||||
|
fpScenarios?: FpScenarioRef[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SheetRow {
|
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 i = t(lang)
|
||||||
const de = lang === 'de'
|
const de = lang === 'de'
|
||||||
|
|
||||||
@@ -80,14 +81,19 @@ export default function AssumptionsSlide({ lang, isWandeldarlehen }: Assumptions
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
const baseId = isWandeldarlehen ? 'c0000000-0000-0000-0000-000000000200' : null
|
const scenarios = fpScenarios || []
|
||||||
const bearId = isWandeldarlehen ? 'd0000000-0000-0000-0000-000000000201' : 'd0000000-0000-0000-0000-000000000301'
|
const find = (role: 'bear' | 'bull' | 'base') => {
|
||||||
const bullId = isWandeldarlehen ? 'd0000000-0000-0000-0000-000000000202' : 'd0000000-0000-0000-0000-000000000302'
|
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)])
|
const [bear, base, bull] = await Promise.all([loadScenarioKPIs(bearId), loadScenarioKPIs(baseId), loadScenarioKPIs(bullId)])
|
||||||
setScenarioData({ bear, base, bull })
|
setScenarioData({ bear, base, bull })
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
}, [isWandeldarlehen])
|
}, [fpScenarios])
|
||||||
|
|
||||||
const bear = scenarioData?.bear || { arr: 0, customers: 0, headcount: 0, cash: 0, breakEvenYear: '—' }
|
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: '—' }
|
const base = scenarioData?.base || { arr: 0, customers: 0, headcount: 0, cash: 0, breakEvenYear: '—' }
|
||||||
|
|||||||
@@ -16,15 +16,16 @@ interface ExecutiveSummarySlideProps {
|
|||||||
investorId?: string | null
|
investorId?: string | null
|
||||||
preferredScenarioId?: string | null
|
preferredScenarioId?: string | null
|
||||||
isWandeldarlehen?: boolean
|
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 i = t(lang)
|
||||||
const es = i.executiveSummary
|
const es = i.executiveSummary
|
||||||
const de = lang === 'de'
|
const de = lang === 'de'
|
||||||
|
|
||||||
// Unternehmensentwicklung from fp_* tables (source of truth)
|
// Unternehmensentwicklung from fp_* tables (source of truth)
|
||||||
const { kpis: fpKPIs } = useFpKPIs(isWandeldarlehen)
|
const { kpis: fpKPIs } = useFpKPIs(fpBaseScenarioId)
|
||||||
|
|
||||||
// Pipeline stats from DB
|
// Pipeline stats from DB
|
||||||
const [pipelineStats, setPipelineStats] = useState<Record<string, { value: number }>>({})
|
const [pipelineStats, setPipelineStats] = useState<Record<string, { value: number }>>({})
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ interface FinancialsSlideProps {
|
|||||||
investorId: string | null
|
investorId: string | null
|
||||||
preferredScenarioId?: string | null
|
preferredScenarioId?: string | null
|
||||||
isWandeldarlehen?: boolean
|
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 i = t(lang)
|
||||||
const fm = useFinancialModel(investorId, preferredScenarioId)
|
const fm = useFinancialModel(investorId, preferredScenarioId)
|
||||||
const [activeTab, setActiveTab] = useState<FinTab>('overview')
|
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]
|
const lastResult = activeResults?.results[activeResults.results.length - 1]
|
||||||
|
|
||||||
// KPI cards from fp_* tables (source of truth)
|
// 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 kpiArr = fpLast?.arr || summary?.final_arr || 0
|
||||||
const kpiCustomers = fpLast?.customers || summary?.final_customers || 0
|
const kpiCustomers = fpLast?.customers || summary?.final_customers || 0
|
||||||
const kpiEbit = fpKPIs?.y2029?.ebit // First profitable year
|
const kpiEbit = fpKPIs?.y2029?.ebit // First profitable year
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Language } from '@/lib/types'
|
import { Language, FpScenarioRef } from '@/lib/types'
|
||||||
import ProjectionFooter from '../ui/ProjectionFooter'
|
import ProjectionFooter from '../ui/ProjectionFooter'
|
||||||
import GradientText from '../ui/GradientText'
|
import GradientText from '../ui/GradientText'
|
||||||
import FadeInView from '../ui/FadeInView'
|
import FadeInView from '../ui/FadeInView'
|
||||||
@@ -20,14 +20,16 @@ interface FinanzplanSlideProps {
|
|||||||
investorId?: string | null
|
investorId?: string | null
|
||||||
preferredScenarioId?: string | null
|
preferredScenarioId?: string | null
|
||||||
isWandeldarlehen?: boolean
|
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 [sheets, setSheets] = useState<SheetMeta[]>([])
|
||||||
const [scenarios, setScenarios] = useState<FpScenario[]>([])
|
const [scenarios, setScenarios] = useState<FpScenario[]>([])
|
||||||
const [openCats, setOpenCats] = useState<Set<string>>(new Set())
|
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 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 [activeSheet, setActiveSheet] = useState<string>('guv')
|
||||||
const [rows, setRows] = useState<SheetRow[]>([])
|
const [rows, setRows] = useState<SheetRow[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -96,18 +98,20 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId,
|
|||||||
loadKPIs()
|
loadKPIs()
|
||||||
}, [selectedScenarioId])
|
}, [selectedScenarioId])
|
||||||
|
|
||||||
// Load sheet list + scenarios
|
// Load sheet list; populate scenario selector from version data or API fallback
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/finanzplan', { cache: 'no-store' })
|
const listParam = fpBaseScenarioId ? `?scenarioId=${fpBaseScenarioId}` : ''
|
||||||
|
fetch(`/api/finanzplan${listParam}`, { cache: 'no-store' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setSheets(data.sheets || [])
|
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)
|
setScenarios(scens)
|
||||||
// Pick scenario: Wandeldarlehen version → WD scenario, otherwise default
|
|
||||||
if (!selectedScenarioId) {
|
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 = scens.find(s => s.is_default) ?? scens[0]
|
||||||
const def = wdScenario ?? scens.find(s => s.is_default) ?? scens[0]
|
|
||||||
if (def) setSelectedScenarioId(def.id)
|
if (def) setSelectedScenarioId(def.id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface Investor {
|
|||||||
last_login_at: string | null
|
last_login_at: string | null
|
||||||
login_count: number
|
login_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
|
is_showcase: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ interface SheetRow {
|
|||||||
* Loads annual KPIs directly from fp_* tables (source of truth).
|
* Loads annual KPIs directly from fp_* tables (source of truth).
|
||||||
* Returns a map of year keys (y2026-y2030) to KPI objects.
|
* 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 [kpis, setKpis] = useState<Record<string, FpAnnualKPIs>>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const param = isWandeldarlehen ? '?scenarioId=c0000000-0000-0000-0000-000000000200' : ''
|
const param = fpBaseScenarioId ? `?scenarioId=${fpBaseScenarioId}` : ''
|
||||||
const [guvRes, liqRes, persRes, kundenRes] = await Promise.all([
|
const [guvRes, liqRes, persRes, kundenRes] = await Promise.all([
|
||||||
fetch(`/api/finanzplan/guv${param}`, { cache: 'no-store' }),
|
fetch(`/api/finanzplan/guv${param}`, { cache: 'no-store' }),
|
||||||
fetch(`/api/finanzplan/liquiditaet${param}`, { cache: 'no-store' }),
|
fetch(`/api/finanzplan/liquiditaet${param}`, { cache: 'no-store' }),
|
||||||
@@ -102,7 +102,7 @@ export function useFpKPIs(isWandeldarlehen?: boolean) {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
}, [isWandeldarlehen])
|
}, [fpBaseScenarioId])
|
||||||
|
|
||||||
// Use of Funds: compute spending breakdown m8-m24 (funding period)
|
// 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 }>>([])
|
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(() => {
|
useEffect(() => {
|
||||||
async function loadUoF() {
|
async function loadUoF() {
|
||||||
try {
|
try {
|
||||||
const param = isWandeldarlehen ? '?scenarioId=c0000000-0000-0000-0000-000000000200' : ''
|
const param = fpBaseScenarioId ? `?scenarioId=${fpBaseScenarioId}` : ''
|
||||||
const [persRes, betriebRes, investRes] = await Promise.all([
|
const [persRes, betriebRes, investRes] = await Promise.all([
|
||||||
fetch(`/api/finanzplan/personalkosten${param}`, { cache: 'no-store' }),
|
fetch(`/api/finanzplan/personalkosten${param}`, { cache: 'no-store' }),
|
||||||
fetch(`/api/finanzplan/betriebliche${param}`, { cache: 'no-store' }),
|
fetch(`/api/finanzplan/betriebliche${param}`, { cache: 'no-store' }),
|
||||||
@@ -168,7 +168,7 @@ export function useFpKPIs(isWandeldarlehen?: boolean) {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
loadUoF()
|
loadUoF()
|
||||||
}, [isWandeldarlehen])
|
}, [fpBaseScenarioId])
|
||||||
|
|
||||||
const last = kpis.y2030
|
const last = kpis.y2030
|
||||||
return { kpis, loading, last, useOfFunds }
|
return { kpis, loading, last, useOfFunds }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Language } from '../types'
|
|||||||
import { PresenterState, SlideScript } from '../presenter/types'
|
import { PresenterState, SlideScript } from '../presenter/types'
|
||||||
import { PRESENTER_SCRIPT } from '../presenter/presenter-script'
|
import { PRESENTER_SCRIPT } from '../presenter/presenter-script'
|
||||||
import { SLIDE_ORDER } from './useSlideNavigation'
|
import { SLIDE_ORDER } from './useSlideNavigation'
|
||||||
|
import { SlideId } from '../types'
|
||||||
|
|
||||||
interface UsePresenterModeConfig {
|
interface UsePresenterModeConfig {
|
||||||
goToSlide: (index: number) => void
|
goToSlide: (index: number) => void
|
||||||
@@ -12,6 +13,7 @@ interface UsePresenterModeConfig {
|
|||||||
totalSlides: number
|
totalSlides: number
|
||||||
language: Language
|
language: Language
|
||||||
ttsEnabled?: boolean
|
ttsEnabled?: boolean
|
||||||
|
slideOrder?: SlideId[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UsePresenterModeReturn {
|
interface UsePresenterModeReturn {
|
||||||
@@ -57,8 +59,8 @@ interface SlideAudioPlan {
|
|||||||
segments: AudioSegment[]
|
segments: AudioSegment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSlideAudioPlan(slideIdx: number, lang: Language): SlideAudioPlan | null {
|
function buildSlideAudioPlan(slideIdx: number, lang: Language, activeSlideOrder: SlideId[]): SlideAudioPlan | null {
|
||||||
const slideId = SLIDE_ORDER[slideIdx]
|
const slideId = activeSlideOrder[slideIdx]
|
||||||
const script = PRESENTER_SCRIPT.find(s => s.slideId === slideId)
|
const script = PRESENTER_SCRIPT.find(s => s.slideId === slideId)
|
||||||
if (!script || script.paragraphs.length === 0) return null
|
if (!script || script.paragraphs.length === 0) return null
|
||||||
|
|
||||||
@@ -121,7 +123,9 @@ export function usePresenterMode({
|
|||||||
totalSlides,
|
totalSlides,
|
||||||
language,
|
language,
|
||||||
ttsEnabled: initialTtsEnabled = true,
|
ttsEnabled: initialTtsEnabled = true,
|
||||||
|
slideOrder: slideOrderProp,
|
||||||
}: UsePresenterModeConfig): UsePresenterModeReturn {
|
}: UsePresenterModeConfig): UsePresenterModeReturn {
|
||||||
|
const activeSlideOrder = slideOrderProp ?? SLIDE_ORDER
|
||||||
const [state, setState] = useState<PresenterState>('idle')
|
const [state, setState] = useState<PresenterState>('idle')
|
||||||
const [currentParagraph, setCurrentParagraph] = useState(0)
|
const [currentParagraph, setCurrentParagraph] = useState(0)
|
||||||
const [displayText, setDisplayText] = useState('')
|
const [displayText, setDisplayText] = useState('')
|
||||||
@@ -193,7 +197,7 @@ export function usePresenterMode({
|
|||||||
playSlideRef.current = async (slideIdx: number) => {
|
playSlideRef.current = async (slideIdx: number) => {
|
||||||
if (stateRef.current !== 'presenting') return
|
if (stateRef.current !== 'presenting') return
|
||||||
|
|
||||||
const plan = buildSlideAudioPlan(slideIdx, language)
|
const plan = buildSlideAudioPlan(slideIdx, language, activeSlideOrder)
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
// No script for this slide — skip to next
|
// No script for this slide — skip to next
|
||||||
if (slideIdx < totalSlides - 1) {
|
if (slideIdx < totalSlides - 1) {
|
||||||
@@ -216,7 +220,7 @@ export function usePresenterMode({
|
|||||||
|
|
||||||
// Pre-fetch next slide's audio in background
|
// Pre-fetch next slide's audio in background
|
||||||
if (slideIdx < totalSlides - 1) {
|
if (slideIdx < totalSlides - 1) {
|
||||||
const nextPlan = buildSlideAudioPlan(slideIdx + 1, language)
|
const nextPlan = buildSlideAudioPlan(slideIdx + 1, language, activeSlideOrder)
|
||||||
if (nextPlan) fetchAudio(nextPlan.fullText, language).catch(() => {})
|
if (nextPlan) fetchAudio(nextPlan.fullText, language).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +333,7 @@ export function usePresenterMode({
|
|||||||
setIsSpeaking(false)
|
setIsSpeaking(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [language, totalSlides, goToSlide, ttsAvailable, ttsEnabled])
|
}, [language, totalSlides, goToSlide, ttsAvailable, ttsEnabled, activeSlideOrder])
|
||||||
|
|
||||||
const start = useCallback(() => {
|
const start = useCallback(() => {
|
||||||
unlockAudio()
|
unlockAudio()
|
||||||
@@ -410,14 +414,18 @@ export function usePresenterMode({
|
|||||||
}
|
}
|
||||||
}, [unlockAudio, start, stop])
|
}, [unlockAudio, start, stop])
|
||||||
|
|
||||||
// Calculate overall progress
|
// Calculate overall progress against the active slide order's scripts
|
||||||
const progress = (() => {
|
const progress = (() => {
|
||||||
if (state === 'idle') return 0
|
if (state === 'idle') return 0
|
||||||
const totalScripts = PRESENTER_SCRIPT.length
|
const currentSlideId = activeSlideOrder[currentSlide]
|
||||||
const currentScriptIdx = PRESENTER_SCRIPT.findIndex(s => s.slideId === SLIDE_ORDER[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
|
if (currentScriptIdx < 0) return (currentSlide / totalSlides) * 100
|
||||||
|
|
||||||
const script = PRESENTER_SCRIPT[currentScriptIdx]
|
const script = activeScripts[currentScriptIdx]
|
||||||
const slideProgress = script.paragraphs.length > 0
|
const slideProgress = script.paragraphs.length > 0
|
||||||
? currentParagraph / script.paragraphs.length
|
? currentParagraph / script.paragraphs.length
|
||||||
: 0
|
: 0
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
|
import { SlideId } from '../types'
|
||||||
import { SLIDE_ORDER, TOTAL_SLIDES } from '../slide-order'
|
import { SLIDE_ORDER, TOTAL_SLIDES } from '../slide-order'
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
export { SLIDE_ORDER, TOTAL_SLIDES }
|
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 [currentIndex, setCurrentIndex] = useState(0)
|
||||||
const [direction, setDirection] = useState(0)
|
const [direction, setDirection] = useState(0)
|
||||||
const [visitedSlides, setVisitedSlides] = useState<Set<number>>(new Set([0]))
|
const [visitedSlides, setVisitedSlides] = useState<Set<number>>(new Set([0]))
|
||||||
const [showOverview, setShowOverview] = useState(false)
|
const [showOverview, setShowOverview] = useState(false)
|
||||||
|
|
||||||
const currentSlide = SLIDE_ORDER[currentIndex]
|
const currentSlide = slideOrder[currentIndex]
|
||||||
|
|
||||||
const goToSlide = useCallback((index: number) => {
|
const goToSlide = useCallback((index: number) => {
|
||||||
if (index < 0 || index >= TOTAL_SLIDES) return
|
if (index < 0 || index >= total) return
|
||||||
setDirection(index > currentIndex ? 1 : -1)
|
setDirection(index > currentIndex ? 1 : -1)
|
||||||
setCurrentIndex(index)
|
setCurrentIndex(index)
|
||||||
setVisitedSlides(prev => new Set([...prev, index]))
|
setVisitedSlides(prev => new Set([...prev, index]))
|
||||||
@@ -23,10 +25,10 @@ export function useSlideNavigation() {
|
|||||||
}, [currentIndex])
|
}, [currentIndex])
|
||||||
|
|
||||||
const nextSlide = useCallback(() => {
|
const nextSlide = useCallback(() => {
|
||||||
if (currentIndex < TOTAL_SLIDES - 1) {
|
if (currentIndex < total - 1) {
|
||||||
goToSlide(currentIndex + 1)
|
goToSlide(currentIndex + 1)
|
||||||
}
|
}
|
||||||
}, [currentIndex, goToSlide])
|
}, [currentIndex, goToSlide, total])
|
||||||
|
|
||||||
const prevSlide = useCallback(() => {
|
const prevSlide = useCallback(() => {
|
||||||
if (currentIndex > 0) {
|
if (currentIndex > 0) {
|
||||||
@@ -35,7 +37,7 @@ export function useSlideNavigation() {
|
|||||||
}, [currentIndex, goToSlide])
|
}, [currentIndex, goToSlide])
|
||||||
|
|
||||||
const goToFirst = useCallback(() => goToSlide(0), [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(() => {
|
const toggleOverview = useCallback(() => {
|
||||||
setShowOverview(prev => !prev)
|
setShowOverview(prev => !prev)
|
||||||
@@ -47,8 +49,8 @@ export function useSlideNavigation() {
|
|||||||
direction,
|
direction,
|
||||||
visitedSlides,
|
visitedSlides,
|
||||||
showOverview,
|
showOverview,
|
||||||
totalSlides: TOTAL_SLIDES,
|
totalSlides: total,
|
||||||
slideOrder: SLIDE_ORDER,
|
slideOrder,
|
||||||
goToSlide,
|
goToSlide,
|
||||||
nextSlide,
|
nextSlide,
|
||||||
prevSlide,
|
prevSlide,
|
||||||
@@ -57,6 +59,6 @@ export function useSlideNavigation() {
|
|||||||
toggleOverview,
|
toggleOverview,
|
||||||
setShowOverview,
|
setShowOverview,
|
||||||
isFirst: currentIndex === 0,
|
isFirst: currentIndex === 0,
|
||||||
isLast: currentIndex === TOTAL_SLIDES - 1,
|
isLast: currentIndex === total - 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { SlideId } from './types'
|
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[] = [
|
export const SLIDE_ORDER: SlideId[] = [
|
||||||
'intro-presenter',
|
'intro-presenter',
|
||||||
'executive-summary',
|
'executive-summary',
|
||||||
|
|||||||
@@ -114,6 +114,14 @@ export interface PitchProduct {
|
|||||||
operating_cost_eur: number
|
operating_cost_eur: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FpScenarioRef {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
is_default?: boolean
|
||||||
|
color?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface PitchData {
|
export interface PitchData {
|
||||||
company: PitchCompany
|
company: PitchCompany
|
||||||
team: PitchTeamMember[]
|
team: PitchTeamMember[]
|
||||||
@@ -125,6 +133,7 @@ export interface PitchData {
|
|||||||
metrics: PitchMetric[]
|
metrics: PitchMetric[]
|
||||||
funding: PitchFunding
|
funding: PitchFunding
|
||||||
products: PitchProduct[]
|
products: PitchProduct[]
|
||||||
|
fp_scenarios?: FpScenarioRef[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Financial Model Types
|
// Financial Model Types
|
||||||
|
|||||||
Reference in New Issue
Block a user