Merge remote-tracking branch 'gitea/main'
Some checks failed
Build pitch-deck / build-push-deploy (push) Failing after 1m13s
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 49s
CI / test-python-voice (push) Successful in 38s
CI / test-bqas (push) Successful in 31s
Some checks failed
Build pitch-deck / build-push-deploy (push) Failing after 1m13s
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 49s
CI / test-python-voice (push) Successful in 38s
CI / test-bqas (push) Successful in 31s
# Conflicts: # pitch-deck/components/slides/MilestonesSlide.tsx # pitch-deck/lib/finanzplan/engine.ts
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
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 { SLIDE_ORDER } from '@/lib/slide-order'
|
import { SLIDE_ORDER } 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'
|
||||||
@@ -34,7 +35,8 @@ const SLIDE_DISPLAY_NAMES: Record<string, { de: string; en: string }> = {
|
|||||||
|
|
||||||
const slideCount = SLIDE_ORDER.length
|
const slideCount = SLIDE_ORDER.length
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `# Investor Agent — BreakPilot ComplAI
|
// Static prefix: Identität through Kernbotschaft #8 — #9 and VERSIONS-ISOLATION injected at runtime
|
||||||
|
const SYSTEM_PROMPT_PART1 = `# Investor Agent — BreakPilot ComplAI
|
||||||
|
|
||||||
## Identität
|
## Identität
|
||||||
Du bist der BreakPilot ComplAI Investor Relations Agent. Du beantwortest Fragen von
|
Du bist der BreakPilot ComplAI Investor Relations Agent. Du beantwortest Fragen von
|
||||||
@@ -55,8 +57,10 @@ Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen.
|
|||||||
5. EU-Infrastruktur: "BSI-zertifizierte Cloud in Deutschland oder Frankreich. 100% Datensouveränität. KEINE US-Anbieter. Isolierte Namespaces."
|
5. EU-Infrastruktur: "BSI-zertifizierte Cloud in Deutschland oder Frankreich. 100% Datensouveränität. KEINE US-Anbieter. Isolierte Namespaces."
|
||||||
6. Zielgruppen: "Maschinen- und Anlagenbauer, Automobilindustrie, Zulieferer und alle produzierenden Unternehmen."
|
6. Zielgruppen: "Maschinen- und Anlagenbauer, Automobilindustrie, Zulieferer und alle produzierenden Unternehmen."
|
||||||
7. Geschäftsmodell: "SaaS, mitarbeiterbasiertes Pricing. Drei Tiers: Starter (3.600 EUR/Jahr), Professional (15.000-40.000 EUR/Jahr), Enterprise (ab 50.000 EUR/Jahr). Plus Beratung & Service (10.000-30.000 EUR/Monat). Kunden sparen mehr als sie zahlen — ROI ab Tag 1."
|
7. Geschäftsmodell: "SaaS, mitarbeiterbasiertes Pricing. Drei Tiers: Starter (3.600 EUR/Jahr), Professional (15.000-40.000 EUR/Jahr), Enterprise (ab 50.000 EUR/Jahr). Plus Beratung & Service (10.000-30.000 EUR/Monat). Kunden sparen mehr als sie zahlen — ROI ab Tag 1."
|
||||||
8. Team: "Lean-Team: 2 Gründer + 7 Mitarbeiter bis 2030 (9 Personen gesamt). Erste Einstellung: IT-Recht/Datenschutzjurist (50%). Dann: Security Engineer, Vertrieb, Backend, Kundenbetreuer, Marketing, DevOps. Jede Einstellung an konkreten Umsatzmeilenstein gekoppelt."
|
8. Team: "Lean-Team: 2 Gründer + 7 Mitarbeiter bis 2030 (9 Personen gesamt). Erste Einstellung: IT-Recht/Datenschutzjurist (50%). Dann: Security Engineer, Vertrieb, Backend, Kundenbetreuer, Marketing, DevOps. Jede Einstellung an konkreten Umsatzmeilenstein gekoppelt."`
|
||||||
9. Finanzplan: "Gründung August 2026. Pre-Seed über Wandeldarlehen (200.000 EUR: 40.000 Investor + 160.000 L-Bank). ~195 Kunden und ~3,3 Mio. Umsatz bis 2030. 9 Mitarbeiter. Optionale 2. Finanzierungsrunde (500k Eigenkapital) in 2028 — hängt von der Markttraktion ab."
|
|
||||||
|
// Static middle: Kommunikationsstil — injected between #9 and VERSIONS-ISOLATION
|
||||||
|
const SYSTEM_PROMPT_PART2 = `
|
||||||
|
|
||||||
## Kommunikationsstil
|
## Kommunikationsstil
|
||||||
- Antworte IMMER wie ein Mensch in einem persönlichen Gespräch — ausformulierte Sätze, natürlicher Redefluss
|
- Antworte IMMER wie ein Mensch in einem persönlichen Gespräch — ausformulierte Sätze, natürlicher Redefluss
|
||||||
@@ -65,14 +69,10 @@ Du hast Zugriff auf alle Unternehmensdaten und zitierst immer konkrete Zahlen.
|
|||||||
- Nutze Übergangssätze wie "Der Grund dafür ist...", "Das haben wir bewusst so entschieden, weil...", "Besonders wichtig ist dabei..."
|
- Nutze Übergangssätze wie "Der Grund dafür ist...", "Das haben wir bewusst so entschieden, weil...", "Besonders wichtig ist dabei..."
|
||||||
- Zahlen und Fakten natürlich in den Text einbetten, nicht als Liste aufreihen
|
- Zahlen und Fakten natürlich in den Text einbetten, nicht als Liste aufreihen
|
||||||
- 3-5 Absätze pro Antwort, jeder Absatz ein eigenständiger Gedanke
|
- 3-5 Absätze pro Antwort, jeder Absatz ein eigenständiger Gedanke
|
||||||
- Der Text muss sich gut anhören wenn er vorgelesen wird (TTS-optimiert)
|
- Der Text muss sich gut anhören wenn er vorgelesen wird (TTS-optimiert)`
|
||||||
|
|
||||||
## VERSIONS-ISOLATION (ABSOLUT KRITISCH)
|
// Static suffix: everything after VERSIONS-ISOLATION
|
||||||
- Du kennst NUR die Wandeldarlehen-Version mit 200.000 EUR Finanzierung.
|
const SYSTEM_PROMPT_PART3 = `
|
||||||
- Es gibt KEINE andere Version. Es gibt KEINE 1-Mio-Version.
|
|
||||||
- Wenn nach anderen Versionen, anderen Investoren oder anderen Pitch Decks gefragt wird: "Dieses Pitch Deck wurde individuell für Sie erstellt. Es gibt nur diese Version."
|
|
||||||
- NIEMALS erwähnen: andere Finanzierungssummen, andere Bewertungen, andere Cap Tables.
|
|
||||||
- Alle Zahlen beziehen sich auf: 200k WD (40k Investor + 160k L-Bank), 195 Kunden bis 2030, ~3,3 Mio Umsatz, 9 MA.
|
|
||||||
|
|
||||||
## IP-Schutz-Layer (KRITISCH)
|
## IP-Schutz-Layer (KRITISCH)
|
||||||
NIEMALS offenbaren: Exakte Modellnamen, Frameworks, Code-Architektur, Datenbankschema, Sicherheitsdetails, Cloud-Provider.
|
NIEMALS offenbaren: Exakte Modellnamen, Frameworks, Code-Architektur, Datenbankschema, Sicherheitsdetails, Cloud-Provider.
|
||||||
@@ -106,7 +106,7 @@ EXAKTES FORMAT (keine Abweichung erlaubt):
|
|||||||
|
|
||||||
KONKRETES BEISPIEL einer vollständigen Antwort:
|
KONKRETES BEISPIEL einer vollständigen Antwort:
|
||||||
|
|
||||||
"Unser AI-First-Ansatz ermöglicht Skalierung ohne lineares Personalwachstum. Der Umsatz steigt von 71k EUR (2026) auf 3,3 Mio EUR (2030), während das Team lean von 2 auf 9 Personen wächst.
|
"Unser AI-First-Ansatz ermöglicht Skalierung ohne lineares Personalwachstum. Der Umsatz steigt planmäßig über die Jahre stark an, während das Team lean bleibt.
|
||||||
|
|
||||||
---
|
---
|
||||||
[Q] Wie sieht die Kostenstruktur im Detail aus?
|
[Q] Wie sieht die Kostenstruktur im Detail aus?
|
||||||
@@ -115,8 +115,125 @@ KONKRETES BEISPIEL einer vollständigen Antwort:
|
|||||||
|
|
||||||
WICHTIG: Vergiss NIEMALS die Folgefragen! Sie sind PFLICHT.`
|
WICHTIG: Vergiss NIEMALS die Folgefragen! Sie sind PFLICHT.`
|
||||||
|
|
||||||
async function loadPitchContext(): Promise<string> {
|
async function loadFpLiquiditaetSummary(scenarioName: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT l.row_label, l.values
|
||||||
|
FROM fp_liquiditaet l
|
||||||
|
JOIN fp_scenarios s ON s.id = l.scenario_id
|
||||||
|
WHERE s.name = $1
|
||||||
|
AND l.row_label IN ('LIQUIDITÄT', 'LIQUIDITAET', 'Summe ERTRÄGE', 'Summe EINZAHLUNGEN', 'Summe AUSZAHLUNGEN', 'ÜBERSCHUSS')
|
||||||
|
ORDER BY l.sort_order`,
|
||||||
|
[scenarioName]
|
||||||
|
)
|
||||||
|
if (rows.length === 0) {
|
||||||
|
if (scenarioName) console.warn('[chat] loadFpLiquiditaetSummary: no rows for scenario', JSON.stringify(scenarioName))
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const years = [2026, 2027, 2028, 2029, 2030]
|
||||||
|
const summary: Record<string, Record<number, number>> = {}
|
||||||
|
for (const row of rows) {
|
||||||
|
const label = row.row_label === 'LIQUIDITAET' ? 'LIQUIDITÄT' : row.row_label
|
||||||
|
summary[label] = {}
|
||||||
|
for (let yi = 0; yi < years.length; yi++) {
|
||||||
|
const start = yi * 12 + 1
|
||||||
|
const end = start + 11
|
||||||
|
// LIQUIDITÄT is a balance (end-of-period): use December value
|
||||||
|
if (label === 'LIQUIDITÄT') {
|
||||||
|
summary[label][years[yi]] = Math.round(row.values[`m${end}`] || 0)
|
||||||
|
} else {
|
||||||
|
let s = 0
|
||||||
|
for (let m = start; m <= end; m++) s += row.values[`m${m}`] || 0
|
||||||
|
summary[label][years[yi]] = Math.round(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [`### Finanzplan-Liquidität (Szenario: ${scenarioName})`]
|
||||||
|
for (const [label, yearVals] of Object.entries(summary)) {
|
||||||
|
const vals = years.map(y => `${y}: ${(yearVals[y] || 0).toLocaleString('de-DE')} EUR`).join(' | ')
|
||||||
|
lines.push(`${label}: ${vals}`)
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionMeta {
|
||||||
|
versionName: string
|
||||||
|
scenarioName: string
|
||||||
|
fundingAmount: number
|
||||||
|
fundingInstrument: string
|
||||||
|
customers2030: number
|
||||||
|
revenue2030: number
|
||||||
|
employees2030: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PitchContextResult {
|
||||||
|
contextString: string
|
||||||
|
meta: VersionMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_META: VersionMeta = {
|
||||||
|
versionName: '', scenarioName: '', fundingAmount: 0,
|
||||||
|
fundingInstrument: 'Wandeldarlehen', customers2030: 0, revenue2030: 0, employees2030: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMeta(
|
||||||
|
versionName: string,
|
||||||
|
fmScenarios: Array<{ name: string }> | undefined,
|
||||||
|
funding: Record<string, unknown> | null,
|
||||||
|
financials: Array<Record<string, unknown>>
|
||||||
|
): VersionMeta {
|
||||||
|
const fin2030 = financials.find(f => Number(f.year) === 2030) ?? {}
|
||||||
|
return {
|
||||||
|
versionName,
|
||||||
|
scenarioName: fmScenarios?.[0]?.name ?? versionName,
|
||||||
|
fundingAmount: Number(funding?.amount_eur ?? 0),
|
||||||
|
fundingInstrument: String(funding?.instrument ?? 'Wandeldarlehen'),
|
||||||
|
customers2030: Number(fin2030.customers_count ?? 0),
|
||||||
|
revenue2030: Number(fin2030.revenue_eur ?? 0),
|
||||||
|
employees2030: Number(fin2030.employees_count ?? 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPitchContext(versionId?: string | null): Promise<PitchContextResult> {
|
||||||
|
try {
|
||||||
|
// Version-specific data path
|
||||||
|
if (versionId) {
|
||||||
|
const [vDataRes, vNameRes] = await Promise.all([
|
||||||
|
pool.query(`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`, [versionId]),
|
||||||
|
pool.query(`SELECT name FROM pitch_versions WHERE id = $1`, [versionId]),
|
||||||
|
])
|
||||||
|
|
||||||
|
const map: Record<string, unknown> = {}
|
||||||
|
for (const r of vDataRes.rows) {
|
||||||
|
map[r.table_name] = typeof r.data === 'string' ? JSON.parse(r.data) : r.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = (map.company as unknown[])?.[0] ?? null
|
||||||
|
const team = (map.team as unknown[]) ?? []
|
||||||
|
const financials = (map.financials as Array<Record<string, unknown>>) ?? []
|
||||||
|
const market = (map.market as unknown[]) ?? []
|
||||||
|
const products = (map.products as unknown[]) ?? []
|
||||||
|
const funding = ((map.funding as unknown[])?.[0] as Record<string, unknown>) ?? null
|
||||||
|
const features = ((map.features as Array<Record<string, unknown>>) ?? [])
|
||||||
|
.filter(f => f.is_differentiator)
|
||||||
|
const fmScenarios = map.fm_scenarios as Array<{ name: string }> | undefined
|
||||||
|
|
||||||
|
const versionName = vNameRes.rows[0]?.name ?? ''
|
||||||
|
const meta = extractMeta(versionName, fmScenarios, funding, financials)
|
||||||
|
const fpSummary = meta.scenarioName ? await loadFpLiquiditaetSummary(meta.scenarioName) : ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
contextString: buildContextString(company, team, financials, market, products, funding, features, fpSummary),
|
||||||
|
meta,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: base tables
|
||||||
const client = await pool.connect()
|
const client = await pool.connect()
|
||||||
try {
|
try {
|
||||||
const [company, team, financials, market, products, funding, features] = await Promise.all([
|
const [company, team, financials, market, products, funding, features] = await Promise.all([
|
||||||
@@ -128,59 +245,117 @@ async function loadPitchContext(): Promise<string> {
|
|||||||
client.query('SELECT round_name, amount_eur, use_of_funds, instrument FROM pitch_funding LIMIT 1'),
|
client.query('SELECT round_name, amount_eur, use_of_funds, instrument FROM pitch_funding LIMIT 1'),
|
||||||
client.query('SELECT feature_name_de, breakpilot, proliance, dataguard, heydata, is_differentiator FROM pitch_features WHERE is_differentiator = true'),
|
client.query('SELECT feature_name_de, breakpilot, proliance, dataguard, heydata, is_differentiator FROM pitch_features WHERE is_differentiator = true'),
|
||||||
])
|
])
|
||||||
|
const meta = extractMeta('', undefined, funding.rows[0] ?? null, financials.rows)
|
||||||
return `
|
return {
|
||||||
## Unternehmensdaten (für präzise Antworten nutzen)
|
contextString: buildContextString(
|
||||||
|
company.rows[0], team.rows, financials.rows, market.rows,
|
||||||
### Firma
|
products.rows, funding.rows[0], features.rows, ''
|
||||||
${JSON.stringify(company.rows[0], null, 2)}
|
),
|
||||||
|
meta,
|
||||||
### Team
|
}
|
||||||
${JSON.stringify(team.rows, null, 2)}
|
|
||||||
|
|
||||||
### Finanzprognosen (5-Jahres-Plan)
|
|
||||||
${JSON.stringify(financials.rows, null, 2)}
|
|
||||||
|
|
||||||
### Markt (TAM/SAM/SOM)
|
|
||||||
${JSON.stringify(market.rows, null, 2)}
|
|
||||||
|
|
||||||
### Produkte
|
|
||||||
${JSON.stringify(products.rows, null, 2)}
|
|
||||||
|
|
||||||
### Finanzierung
|
|
||||||
${JSON.stringify(funding.rows[0], null, 2)}
|
|
||||||
|
|
||||||
### Differenzierende Features (nur bei ComplAI)
|
|
||||||
${JSON.stringify(features.rows, null, 2)}
|
|
||||||
`
|
|
||||||
} finally {
|
} finally {
|
||||||
client.release()
|
client.release()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Could not load pitch context from DB:', error)
|
console.warn('Could not load pitch context from DB:', error)
|
||||||
return ''
|
return { contextString: '', meta: DEFAULT_META }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildContextString(
|
||||||
|
company: unknown, team: unknown, financials: unknown, market: unknown,
|
||||||
|
products: unknown, funding: unknown, features: unknown, fpSummary: string
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
## Unternehmensdaten (für präzise Antworten nutzen)
|
||||||
|
|
||||||
|
### Firma
|
||||||
|
${JSON.stringify(company, null, 2)}
|
||||||
|
|
||||||
|
### Team
|
||||||
|
${JSON.stringify(team, null, 2)}
|
||||||
|
|
||||||
|
### Finanzprognosen (5-Jahres-Plan)
|
||||||
|
${JSON.stringify(financials, null, 2)}
|
||||||
|
|
||||||
|
### Markt (TAM/SAM/SOM)
|
||||||
|
${JSON.stringify(market, null, 2)}
|
||||||
|
|
||||||
|
### Produkte
|
||||||
|
${JSON.stringify(products, null, 2)}
|
||||||
|
|
||||||
|
### Finanzierung
|
||||||
|
${JSON.stringify(funding, null, 2)}
|
||||||
|
|
||||||
|
### Differenzierende Features (nur bei ComplAI)
|
||||||
|
${JSON.stringify(features, null, 2)}
|
||||||
|
${fpSummary ? '\n' + fpSummary : ''}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { message, history = [], lang = 'de', slideContext, faqContext } = body
|
const { message, history = [], lang: langParam = 'de', slideContext, faqContext } = body
|
||||||
|
const lang: 'de' | 'en' = langParam === 'en' ? 'en' : 'de'
|
||||||
|
|
||||||
if (!message || typeof message !== 'string') {
|
if (!message || typeof message !== 'string') {
|
||||||
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
|
return NextResponse.json({ error: 'Message is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const pitchContext = await loadPitchContext()
|
// Resolve investor's assigned version so the AI sees the correct scenario data
|
||||||
|
let versionId: string | null = null
|
||||||
|
try {
|
||||||
|
const session = await getSessionFromCookie()
|
||||||
|
if (session?.sub) {
|
||||||
|
const inv = await pool.query(
|
||||||
|
`SELECT assigned_version_id FROM pitch_investors WHERE id = $1`,
|
||||||
|
[session.sub]
|
||||||
|
)
|
||||||
|
versionId = inv.rows[0]?.assigned_version_id ?? null
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: fall back to base tables
|
||||||
|
}
|
||||||
|
|
||||||
let systemContent = SYSTEM_PROMPT
|
const { contextString, meta } = await loadPitchContext(versionId)
|
||||||
if (pitchContext) {
|
|
||||||
systemContent += '\n' + pitchContext
|
// Build dynamic VERSIONS-ISOLATION and Kernbotschaft #9 from actual version data
|
||||||
|
const fmt = (n: number) => n.toLocaleString('de-DE')
|
||||||
|
const revM = meta.revenue2030 > 0
|
||||||
|
? `~${(meta.revenue2030 / 1_000_000).toFixed(1).replace('.', ',')} Mio. EUR`
|
||||||
|
: 'laut Finanzplan'
|
||||||
|
const fundingStr = meta.fundingAmount > 0
|
||||||
|
? `${fmt(meta.fundingAmount)} EUR ${meta.fundingInstrument}`
|
||||||
|
: meta.fundingInstrument
|
||||||
|
const customersStr = meta.customers2030 > 0 ? `~${meta.customers2030} Kunden` : 'laut Finanzplan'
|
||||||
|
const employeesStr = meta.employees2030 > 0 ? `${meta.employees2030} Mitarbeiter` : 'laut Finanzplan'
|
||||||
|
|
||||||
|
const dynamicVersionIsolation = `## VERSIONS-ISOLATION (ABSOLUT KRITISCH)
|
||||||
|
- Es gibt NUR dieses eine Pitch Deck. Es wurde individuell für diesen Investor erstellt.
|
||||||
|
- Wenn gefragt wird ob es andere Versionen, andere Pitch Decks oder andere Konditionen gibt: "Dieses Pitch Deck wurde persönlich für Sie erstellt. Es gibt genau dieses."
|
||||||
|
- NIEMALS Begriffe wie "Version", "Szenario", "Variante" oder "diese Version" verwenden — das impliziert, es könnte andere geben.
|
||||||
|
- NIEMALS erwähnen: andere Finanzierungssummen, andere Bewertungen, andere Cap Tables, andere Szenarien.
|
||||||
|
- Alle Zahlen beziehen sich auf: ${fundingStr}, ${customersStr} bis 2030, ${revM} Umsatz, ${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
|
||||||
|
+ '\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
|
// 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') {
|
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.'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slide context for contextual awareness
|
// Slide context for contextual awareness
|
||||||
@@ -236,7 +411,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (!llmResponse.ok) {
|
if (!llmResponse.ok) {
|
||||||
const errorText = await llmResponse.text()
|
const errorText = await llmResponse.text()
|
||||||
console.error('LiteLLM error:', llmResponse.status, errorText)
|
console.error('LiteLLM error:', llmResponse.status, errorText.slice(0, 200))
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `LLM nicht erreichbar (Status ${llmResponse.status}).` },
|
{ error: `LLM nicht erreichbar (Status ${llmResponse.status}).` },
|
||||||
{ status: 502 }
|
{ status: 502 }
|
||||||
@@ -295,7 +470,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Stream read error:', error)
|
console.error('Stream read error:', (error as Error).message)
|
||||||
} finally {
|
} finally {
|
||||||
controller.close()
|
controller.close()
|
||||||
}
|
}
|
||||||
@@ -310,7 +485,7 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Investor agent chat error:', error)
|
console.error('Investor agent chat error:', (error as Error).message)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Verbindung zum LLM fehlgeschlagen.' },
|
{ error: 'Verbindung zum LLM fehlgeschlagen.' },
|
||||||
{ status: 503 }
|
{ status: 503 }
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export async function GET() {
|
|||||||
client.release()
|
client.release()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Database query error:', error)
|
console.error('Database query error:', (error as Error).message)
|
||||||
// Return minimal stub in dev so the pitch renders without a DB connection
|
// Return minimal stub in dev so the pitch renders without a DB connection
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import pool from '@/lib/db'
|
import pool from '@/lib/db'
|
||||||
|
import { validateAdminSecret } from '@/lib/auth'
|
||||||
|
|
||||||
// PUT: Update a single assumption and trigger recompute
|
// PUT: Update a single assumption — admin only
|
||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
|
if (!validateAdminSecret(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { scenarioId, key, value } = body
|
const { scenarioId, key, value } = body
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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, validateAdminSecret } from '@/lib/auth'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -67,8 +67,11 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: Create a new scenario
|
// POST: Create a new scenario — admin only
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!validateAdminSecret(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { name, description, color, copyFrom } = body
|
const { name, description, color, copyFrom } = body
|
||||||
|
|||||||
@@ -14,6 +14,30 @@ const TABLE_MAP: Record<string, string> = {
|
|||||||
guv: 'fp_guv',
|
guv: 'fp_guv',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whitelist of scalar columns that may be edited per table
|
||||||
|
const SCALAR_COLUMNS_WHITELIST: Record<string, string[]> = {
|
||||||
|
fp_personalkosten: ['row_label', 'start_date', 'end_date', 'position', 'sort_order'],
|
||||||
|
fp_investitionen: ['row_label', 'sort_order', 'position'],
|
||||||
|
fp_betriebliche_aufwendungen: ['row_label', 'sort_order'],
|
||||||
|
fp_umsatzerloese: ['row_label', 'sort_order'],
|
||||||
|
fp_materialaufwand: ['row_label', 'sort_order'],
|
||||||
|
fp_liquiditaet: ['row_label', 'sort_order'],
|
||||||
|
fp_kunden: ['row_label', 'sort_order'],
|
||||||
|
fp_kunden_summary: ['row_label', 'sort_order'],
|
||||||
|
fp_sonst_ertraege: ['row_label', 'sort_order'],
|
||||||
|
fp_guv: ['row_label', 'sort_order'],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid month key: m1 .. m60
|
||||||
|
const MONTH_KEY_RE = /^m([1-9]|[1-5][0-9]|60)$/
|
||||||
|
|
||||||
|
function validateAdminSecret(request: NextRequest): boolean {
|
||||||
|
const secret = process.env.PITCH_ADMIN_SECRET
|
||||||
|
if (!secret) return false
|
||||||
|
const auth = request.headers.get('authorization') ?? ''
|
||||||
|
return auth === `Bearer ${secret}`
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ sheetName: string }> }
|
{ params }: { params: Promise<{ sheetName: string }> }
|
||||||
@@ -24,7 +48,9 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: `Unknown sheet: ${sheetName}` }, { status: 400 })
|
return NextResponse.json({ error: `Unknown sheet: ${sheetName}` }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const scenarioId = request.nextUrl.searchParams.get('scenarioId')
|
// 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
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let query = `SELECT * FROM ${table}`
|
let query = `SELECT * FROM ${table}`
|
||||||
@@ -42,8 +68,8 @@ export async function GET(
|
|||||||
return NextResponse.json({ sheet: sheetName, rows }, {
|
return NextResponse.json({ sheet: sheetName, rows }, {
|
||||||
headers: { 'Cache-Control': 'no-store' },
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch {
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 })
|
return NextResponse.json({ error: 'Query failed' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +77,11 @@ export async function PUT(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ sheetName: string }> }
|
{ params }: { params: Promise<{ sheetName: string }> }
|
||||||
) {
|
) {
|
||||||
|
// C2: Admin-only — require PITCH_ADMIN_SECRET bearer token
|
||||||
|
if (!validateAdminSecret(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
const { sheetName } = await params
|
const { sheetName } = await params
|
||||||
const table = TABLE_MAP[sheetName]
|
const table = TABLE_MAP[sheetName]
|
||||||
if (!table) {
|
if (!table) {
|
||||||
@@ -59,33 +90,47 @@ export async function PUT(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { rowId, updates } = body // updates: { field: value } or { m3: 1500 } for monthly values
|
const { rowId, updates } = body
|
||||||
|
|
||||||
if (!rowId) {
|
if (!rowId || typeof rowId !== 'string') {
|
||||||
return NextResponse.json({ error: 'rowId required' }, { status: 400 })
|
return NextResponse.json({ error: 'rowId required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
|
||||||
|
return NextResponse.json({ error: 'updates object required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
// Check if updating monthly values (JSONB) or scalar fields
|
// C1: Separate and validate monthly vs scalar keys
|
||||||
const monthlyKeys = Object.keys(updates).filter(k => k.startsWith('m') && !isNaN(parseInt(k.substring(1))))
|
const monthlyKeys = Object.keys(updates).filter(k => MONTH_KEY_RE.test(k))
|
||||||
const scalarKeys = Object.keys(updates).filter(k => !k.startsWith('m') || isNaN(parseInt(k.substring(1))))
|
const scalarKeys = Object.keys(updates).filter(k => !MONTH_KEY_RE.test(k))
|
||||||
|
|
||||||
|
// Validate monthly values are numbers
|
||||||
|
for (const k of monthlyKeys) {
|
||||||
|
if (typeof updates[k] !== 'number' && isNaN(Number(updates[k]))) {
|
||||||
|
return NextResponse.json({ error: `Invalid value for ${k}` }, { status: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (monthlyKeys.length > 0) {
|
if (monthlyKeys.length > 0) {
|
||||||
// Update specific months in the values JSONB
|
|
||||||
const jsonbSet = monthlyKeys.map(k => `'${k}', '${updates[k]}'::jsonb`).join(', ')
|
|
||||||
const valuesCol = sheetName === 'personalkosten' ? 'values_brutto' : 'values'
|
const valuesCol = sheetName === 'personalkosten' ? 'values_brutto' : 'values'
|
||||||
// Use jsonb_set for each key
|
// Build sanitized JSON patch object — no interpolation of user data into SQL
|
||||||
let updateSql = `UPDATE ${table} SET `
|
const patch: Record<string, number> = {}
|
||||||
const setClauses: string[] = []
|
|
||||||
for (const k of monthlyKeys) {
|
for (const k of monthlyKeys) {
|
||||||
setClauses.push(`${valuesCol} = jsonb_set(${valuesCol}, '{${k}}', '${updates[k]}')`)
|
patch[k] = Number(updates[k])
|
||||||
}
|
}
|
||||||
setClauses.push(`updated_at = NOW()`)
|
await pool.query(
|
||||||
updateSql += setClauses.join(', ') + ` WHERE id = $1`
|
`UPDATE ${table} SET ${valuesCol} = ${valuesCol} || $1::jsonb, updated_at = NOW() WHERE id = $2`,
|
||||||
await pool.query(updateSql, [rowId])
|
[JSON.stringify(patch), rowId]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scalarKeys.length > 0) {
|
if (scalarKeys.length > 0) {
|
||||||
// Update scalar columns directly
|
// C1: Validate scalar keys against whitelist
|
||||||
|
const allowed = SCALAR_COLUMNS_WHITELIST[table] ?? []
|
||||||
|
for (const k of scalarKeys) {
|
||||||
|
if (!allowed.includes(k)) {
|
||||||
|
return NextResponse.json({ error: `Column '${k}' is not editable` }, { status: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
const setClauses = scalarKeys.map((k, i) => `${k} = $${i + 2}`).join(', ')
|
const setClauses = scalarKeys.map((k, i) => `${k} = $${i + 2}`).join(', ')
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE ${table} SET ${setClauses}, updated_at = NOW() WHERE id = $1`,
|
`UPDATE ${table} SET ${setClauses}, updated_at = NOW() WHERE id = $1`,
|
||||||
@@ -93,10 +138,9 @@ export async function PUT(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return updated row
|
|
||||||
const { rows } = await pool.query(`SELECT * FROM ${table} WHERE id = $1`, [rowId])
|
const { rows } = await pool.query(`SELECT * FROM ${table} WHERE id = $1`, [rowId])
|
||||||
return NextResponse.json({ updated: rows[0] })
|
return NextResponse.json({ updated: rows[0] })
|
||||||
} catch (error) {
|
} catch {
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 })
|
return NextResponse.json({ error: 'Update failed' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import pool from '@/lib/db'
|
import pool from '@/lib/db'
|
||||||
import { computeFinanzplan } from '@/lib/finanzplan/engine'
|
import { computeFinanzplan } from '@/lib/finanzplan/engine'
|
||||||
|
import { validateAdminSecret } from '@/lib/auth'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!validateAdminSecret(request)) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const body = await request.json().catch(() => ({}))
|
const body = await request.json().catch(() => ({}))
|
||||||
const scenarioId = body.scenarioId
|
const scenarioId = body.scenarioId
|
||||||
@@ -33,6 +37,6 @@ export async function POST(request: NextRequest) {
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Finanzplan compute error:', error)
|
console.error('Finanzplan compute error:', error)
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 })
|
return NextResponse.json({ error: 'Compute failed' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import pool from '@/lib/db'
|
import pool from '@/lib/db'
|
||||||
import { SHEET_LIST } from '@/lib/finanzplan/types'
|
import { SHEET_LIST } from '@/lib/finanzplan/types'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
|
// Only expose 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}`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const scenarios = await pool.query('SELECT * FROM fp_scenarios ORDER BY is_default DESC, name')
|
// 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
|
||||||
const sheets = await Promise.all(
|
const sheets = await Promise.all(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// MilestonesSlide data — extracted from MilestonesSlide.tsx
|
// MilestonesSlide data — extracted from MilestonesSlide.tsx
|
||||||
|
// Contains: Milestone/StatItem interfaces, MILESTONES array, STATS array, TODAY_POSITION
|
||||||
|
|
||||||
export const TODAY_POSITION = 0.56
|
export const TODAY_POSITION = 0.56
|
||||||
|
|
||||||
@@ -19,7 +20,8 @@ export interface StatItem { k: { de: string; en: string }; v: string; tint: stri
|
|||||||
|
|
||||||
export const MILESTONES: Milestone[] = [
|
export const MILESTONES: Milestone[] = [
|
||||||
{
|
{
|
||||||
id: 'ihk', when: 'Okt. 2025', tick: '10 \u00b7 25',
|
id: 'ihk',
|
||||||
|
when: 'Okt. 2025', tick: '10 \u00b7 25',
|
||||||
title: { de: 'Gr\u00fcnderzuschuss & IHK', en: 'Founder Grant & IHK' },
|
title: { de: 'Gr\u00fcnderzuschuss & IHK', en: 'Founder Grant & IHK' },
|
||||||
short: { de: 'Abstimmung mit Agentur f\u00fcr Arbeit und IHK Konstanz.', en: 'Coordination with Employment Agency and IHK Konstanz.' },
|
short: { de: 'Abstimmung mit Agentur f\u00fcr Arbeit und IHK Konstanz.', en: 'Coordination with Employment Agency and IHK Konstanz.' },
|
||||||
body: {
|
body: {
|
||||||
@@ -33,7 +35,8 @@ export const MILESTONES: Milestone[] = [
|
|||||||
tint: '#a78bfa', done: true,
|
tint: '#a78bfa', done: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'brand', when: '11. Nov. 2025', tick: '11 \u00b7 25',
|
id: 'brand',
|
||||||
|
when: '11. Nov. 2025', tick: '11 \u00b7 25',
|
||||||
title: { de: 'Markenanmeldung & Domains', en: 'Trademark Filing & Domains' },
|
title: { de: 'Markenanmeldung & Domains', en: 'Trademark Filing & Domains' },
|
||||||
short: { de: 'DPMA-Anmeldung BreakPilot + Domain-Portfolio.', en: 'DPMA filing BreakPilot + domain portfolio.' },
|
short: { de: 'DPMA-Anmeldung BreakPilot + Domain-Portfolio.', en: 'DPMA filing BreakPilot + domain portfolio.' },
|
||||||
body: {
|
body: {
|
||||||
@@ -47,7 +50,8 @@ export const MILESTONES: Milestone[] = [
|
|||||||
tint: '#a78bfa', done: true,
|
tint: '#a78bfa', done: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dev', when: 'Jan. 2026', tick: '01 \u00b7 26',
|
id: 'dev',
|
||||||
|
when: 'Jan. 2026', tick: '01 \u00b7 26',
|
||||||
title: { de: 'Plattform-Entwicklung gestartet', en: 'Platform Development Started' },
|
title: { de: 'Plattform-Entwicklung gestartet', en: 'Platform Development Started' },
|
||||||
short: { de: '500.000+ Lines of Code, vollst\u00e4ndige Architektur.', en: '500,000+ lines of code, full architecture.' },
|
short: { de: '500.000+ Lines of Code, vollst\u00e4ndige Architektur.', en: '500,000+ lines of code, full architecture.' },
|
||||||
body: {
|
body: {
|
||||||
@@ -61,7 +65,8 @@ export const MILESTONES: Milestone[] = [
|
|||||||
tint: '#c084fc', done: true,
|
tint: '#c084fc', done: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dpma', when: '27. M\u00e4r. 2026', tick: '03 \u00b7 26',
|
id: 'dpma',
|
||||||
|
when: '27. M\u00e4r. 2026', tick: '03 \u00b7 26',
|
||||||
title: { de: 'Markeneintragung DPMA', en: 'DPMA Trademark Registration' },
|
title: { de: 'Markeneintragung DPMA', en: 'DPMA Trademark Registration' },
|
||||||
short: { de: 'BreakPilot offiziell eingetragen.', en: 'BreakPilot officially registered.' },
|
short: { de: 'BreakPilot offiziell eingetragen.', en: 'BreakPilot officially registered.' },
|
||||||
body: {
|
body: {
|
||||||
@@ -75,7 +80,8 @@ export const MILESTONES: Milestone[] = [
|
|||||||
tint: '#c084fc', done: true,
|
tint: '#c084fc', done: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rag', when: 'Apr. 2026', tick: '04 \u00b7 26',
|
id: 'rag',
|
||||||
|
when: 'Apr. 2026', tick: '04 \u00b7 26',
|
||||||
title: { de: 'RAG mit 375+ Dokumenten', en: 'RAG with 375+ Documents' },
|
title: { de: 'RAG mit 375+ Dokumenten', en: 'RAG with 375+ Documents' },
|
||||||
short: { de: 'EU + DACH Regularien indexiert.', en: 'EU + DACH regulations indexed.' },
|
short: { de: 'EU + DACH Regularien indexiert.', en: 'EU + DACH regulations indexed.' },
|
||||||
body: {
|
body: {
|
||||||
@@ -89,7 +95,8 @@ export const MILESTONES: Milestone[] = [
|
|||||||
tint: '#c084fc', done: true,
|
tint: '#c084fc', done: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'euipo', when: '1. Mai 2026', tick: '05 \u00b7 26',
|
id: 'euipo',
|
||||||
|
when: '1. Mai 2026', tick: '05 \u00b7 26',
|
||||||
title: { de: 'Markenanmeldung EUIPO', en: 'EUIPO Trademark Filing' },
|
title: { de: 'Markenanmeldung EUIPO', en: 'EUIPO Trademark Filing' },
|
||||||
short: { de: 'EU-weiter Markenschutz beantragt.', en: 'EU-wide trademark protection filed.' },
|
short: { de: 'EU-weiter Markenschutz beantragt.', en: 'EU-wide trademark protection filed.' },
|
||||||
body: {
|
body: {
|
||||||
@@ -103,7 +110,8 @@ export const MILESTONES: Milestone[] = [
|
|||||||
tint: '#fbbf24', done: false, next: true,
|
tint: '#fbbf24', done: false, next: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gmbh', when: 'Aug. 2026', tick: '08 \u00b7 26',
|
id: 'gmbh',
|
||||||
|
when: 'Aug. 2026', tick: '08 \u00b7 26',
|
||||||
title: { de: 'GmbH-Gr\u00fcndung', en: 'GmbH Incorporation' },
|
title: { de: 'GmbH-Gr\u00fcndung', en: 'GmbH Incorporation' },
|
||||||
short: { de: 'Breakpilot COMPLAI GmbH gegr\u00fcndet.', en: 'Breakpilot COMPLAI GmbH incorporated.' },
|
short: { de: 'Breakpilot COMPLAI GmbH gegr\u00fcndet.', en: 'Breakpilot COMPLAI GmbH incorporated.' },
|
||||||
body: {
|
body: {
|
||||||
@@ -117,7 +125,8 @@ export const MILESTONES: Milestone[] = [
|
|||||||
tint: '#fbbf24', done: false,
|
tint: '#fbbf24', done: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'customers', when: 'Aug. 2026', tick: '08 \u00b7 26',
|
id: 'customers',
|
||||||
|
when: 'Aug. 2026', tick: '08 \u00b7 26',
|
||||||
title: { de: '2 zahlende Kunden', en: '2 Paying Customers' },
|
title: { de: '2 zahlende Kunden', en: '2 Paying Customers' },
|
||||||
short: { de: 'Erste Ums\u00e4tze ab Gr\u00fcndung.', en: 'First revenue from incorporation.' },
|
short: { de: 'Erste Ums\u00e4tze ab Gr\u00fcndung.', en: 'First revenue from incorporation.' },
|
||||||
body: {
|
body: {
|
||||||
@@ -131,7 +140,8 @@ export const MILESTONES: Milestone[] = [
|
|||||||
tint: '#fbbf24', done: false,
|
tint: '#fbbf24', done: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'beta', when: 'Q3 2026', tick: 'Q3 \u00b7 26',
|
id: 'beta',
|
||||||
|
when: 'Q3 2026', tick: 'Q3 \u00b7 26',
|
||||||
title: { de: '\u00d6ffentliches Beta', en: 'Public Beta' },
|
title: { de: '\u00d6ffentliches Beta', en: 'Public Beta' },
|
||||||
short: { de: 'Beta-Launch mit ersten zahlenden Kunden.', en: 'Beta launch with first paying customers.' },
|
short: { de: 'Beta-Launch mit ersten zahlenden Kunden.', en: 'Beta launch with first paying customers.' },
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { type Milestone, type StatItem, MILESTONES, TODAY_POSITION } from './MilestonesSlide.data'
|
import { type Milestone, type StatItem, MILESTONES, STATS, TODAY_POSITION } from './MilestonesSlide.data'
|
||||||
import { type Theme } from './MilestonesSlide.themes'
|
import { type Theme, MONO } from './MilestonesSlide.themes'
|
||||||
|
|
||||||
const MONO: React.CSSProperties = {
|
|
||||||
fontFamily: '"JetBrains Mono","SF Mono",ui-monospace,monospace',
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Star Field ────────────────────────────────────────────────────────────────
|
// ── Star Field ────────────────────────────────────────────────────────────────
|
||||||
export function StarField() {
|
export function StarField() {
|
||||||
@@ -150,7 +145,7 @@ function MilestoneNode({ m, onClick, active, t, de }: {
|
|||||||
const bgTopA = lit ? m.tint + t.cardTintTopH : m.tint + t.cardTintTop
|
const bgTopA = lit ? m.tint + t.cardTintTopH : m.tint + t.cardTintTop
|
||||||
const bgMidA = lit ? m.tint + t.cardTintMidH : m.tint + t.cardTintMid
|
const bgMidA = lit ? m.tint + t.cardTintMidH : m.tint + t.cardTintMid
|
||||||
const cardBg = `linear-gradient(180deg, ${bgTopA} 0%, ${bgMidA} 55%, ${t.cardBase}${lit ? t.cardBaseAH : t.cardBaseA})`
|
const cardBg = `linear-gradient(180deg, ${bgTopA} 0%, ${bgMidA} 55%, ${t.cardBase}${lit ? t.cardBaseAH : t.cardBaseA})`
|
||||||
const badge = m.done ? (de ? 'erledigt' : 'done') : (m.next ? (de ? 'als nächstes' : 'next') : (de ? 'geplant' : 'plan'))
|
const badge = m.done ? (de ? 'erledigt' : 'done') : (m.next ? (de ? 'als n\u00e4chstes' : 'next') : (de ? 'geplant' : 'plan'))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -175,7 +170,7 @@ function MilestoneNode({ m, onClick, active, t, de }: {
|
|||||||
transition: 'all .25s',
|
transition: 'all .25s',
|
||||||
transform: lit ? 'scale(1.15)' : 'scale(1)',
|
transform: lit ? 'scale(1.15)' : 'scale(1)',
|
||||||
}}>
|
}}>
|
||||||
{m.done ? '✓' : (m.next ? '◉' : '○')}
|
{m.done ? '\u2713' : (m.next ? '\u25c9' : '\u25cb')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* card */}
|
{/* card */}
|
||||||
@@ -224,7 +219,7 @@ function MilestoneNode({ m, onClick, active, t, de }: {
|
|||||||
opacity: lit ? 1 : 0.55,
|
opacity: lit ? 1 : 0.55,
|
||||||
transform: `translateX(${lit ? 0 : -4}px)`,
|
transform: `translateX(${lit ? 0 : -4}px)`,
|
||||||
transition: 'all .25s',
|
transition: 'all .25s',
|
||||||
}}>{de ? 'Details →' : 'Details →'}</span>
|
}}>{de ? 'Details \u2192' : 'Details \u2192'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -292,7 +287,7 @@ export function DetailModal({ item, onClose, t, de }: {
|
|||||||
const tint = item.tint
|
const tint = item.tint
|
||||||
const badge = item.done
|
const badge = item.done
|
||||||
? (de ? 'ABGESCHLOSSEN' : 'COMPLETED')
|
? (de ? 'ABGESCHLOSSEN' : 'COMPLETED')
|
||||||
: (item.next ? (de ? 'ALS NÄCHSTES' : 'NEXT UP') : (de ? 'GEPLANT' : 'PLANNED'))
|
: (item.next ? (de ? 'ALS N\u00c4CHSTES' : 'NEXT UP') : (de ? 'GEPLANT' : 'PLANNED'))
|
||||||
const badgeColor = item.done ? t.done : tint
|
const badgeColor = item.done ? t.done : tint
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -319,7 +314,7 @@ export function DetailModal({ item, onClose, t, de }: {
|
|||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
color: t.key === 'light' ? tint : '#fff', fontSize: 17, fontWeight: 700,
|
color: t.key === 'light' ? tint : '#fff', fontSize: 17, fontWeight: 700,
|
||||||
boxShadow: `0 0 20px ${tint}66`,
|
boxShadow: `0 0 20px ${tint}66`,
|
||||||
}}>{item.done ? '✓' : (item.next ? '◉' : '○')}</div>
|
}}>{item.done ? '\u2713' : (item.next ? '\u25c9' : '\u25cb')}</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -337,7 +332,7 @@ export function DetailModal({ item, onClose, t, de }: {
|
|||||||
<button onClick={onClose} style={{
|
<button onClick={onClose} style={{
|
||||||
background: 'transparent', border: `1px solid ${tint}66`, color: t.fg,
|
background: 'transparent', border: `1px solid ${tint}66`, color: t.fg,
|
||||||
width: 32, height: 32, borderRadius: 8, cursor: 'pointer', fontSize: 14,
|
width: 32, height: 32, borderRadius: 8, cursor: 'pointer', fontSize: 14,
|
||||||
}}>✕</button>
|
}}>{'\u2715'}</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 13, lineHeight: 1.6, color: t.fgSoft, marginBottom: 16 }}>
|
<div style={{ fontSize: 13, lineHeight: 1.6, color: t.fgSoft, marginBottom: 16 }}>
|
||||||
{de ? item.body.de : item.body.en}
|
{de ? item.body.de : item.body.en}
|
||||||
@@ -350,7 +345,7 @@ export function DetailModal({ item, onClose, t, de }: {
|
|||||||
background: t.bulletBg, border: `1px solid ${tint}44`,
|
background: t.bulletBg, border: `1px solid ${tint}44`,
|
||||||
}}>
|
}}>
|
||||||
<span style={{ color: item.done ? t.done : tint, fontSize: 12, marginTop: 1 }}>
|
<span style={{ color: item.done ? t.done : tint, fontSize: 12, marginTop: 1 }}>
|
||||||
{item.done ? '✓' : '▸'}
|
{item.done ? '\u2713' : '\u25b8'}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 12, lineHeight: 1.5, color: t.fgSoft }}>{b}</span>
|
<span style={{ fontSize: 12, lineHeight: 1.5, color: t.fgSoft }}>{b}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,3 +355,87 @@ export function DetailModal({ item, onClose, t, de }: {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inner slide (fixed 1280x600) ─────────────────────────────────────────────
|
||||||
|
export function MilestonesInner({ t, de, sel, setSel }: {
|
||||||
|
t: Theme; de: boolean
|
||||||
|
sel: Milestone | null
|
||||||
|
setSel: (m: Milestone | null) => void
|
||||||
|
}) {
|
||||||
|
const doneCnt = useMemo(() => MILESTONES.filter(m => m.done).length, [])
|
||||||
|
const total = MILESTONES.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', width: 1280, height: 600, overflow: 'hidden',
|
||||||
|
background: t.bg, color: t.fg,
|
||||||
|
fontFamily: '"Inter", system-ui, sans-serif', WebkitFontSmoothing: 'antialiased',
|
||||||
|
}}>
|
||||||
|
{/* Ambient glow */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: -120, left: '50%', transform: 'translateX(-50%)',
|
||||||
|
width: 800, height: 500, borderRadius: '50%',
|
||||||
|
background: t.ambient, filter: 'blur(50px)', pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{t.stars ? <StarField /> : <SoftGrid t={t} />}
|
||||||
|
|
||||||
|
{/* Progress indicator */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 36, right: 52, display: 'flex', alignItems: 'center', gap: 10, zIndex: 3,
|
||||||
|
}}>
|
||||||
|
<div style={{ ...MONO, fontSize: 10, letterSpacing: 2, color: t.fgMuted, textTransform: 'uppercase' as const, fontWeight: 700 }}>
|
||||||
|
{de ? 'Fortschritt' : 'Progress'}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
width: 120, height: 6, background: t.progressTrackBg, borderRadius: 3, overflow: 'hidden',
|
||||||
|
border: `1px solid ${t.progressTrackBorder}`,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: `${(doneCnt / total) * 100}%`, height: '100%',
|
||||||
|
background: `linear-gradient(90deg, ${t.done}, ${t.accent})`,
|
||||||
|
boxShadow: `0 0 12px ${t.done}99`,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div style={{ ...MONO, fontSize: 11, color: t.fg, fontWeight: 700 }}>
|
||||||
|
<span style={{ color: t.done }}>{doneCnt}</span>
|
||||||
|
<span style={{ color: t.fgWhisper }}> / {total}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tip */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 36, left: 52, ...MONO, fontSize: 10,
|
||||||
|
letterSpacing: 2, color: t.fgGhost, textTransform: 'uppercase' as const, fontWeight: 700,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, zIndex: 3,
|
||||||
|
}}>
|
||||||
|
<span>{de ? 'Tipp:' : 'Tip:'}</span>
|
||||||
|
<span style={{ color: t.accent70 }}>{de ? 'Klick auf einen Meilenstein' : 'Click any milestone'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div style={{ position: 'relative', marginTop: 68 }}>
|
||||||
|
<Timeline onSelect={setSel} selectedId={sel?.id ?? null} t={t} de={de} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: 40, right: 40, bottom: 36,
|
||||||
|
display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 14,
|
||||||
|
}}>
|
||||||
|
{STATS.map(s => <StatCard key={s.tint} item={s} t={t} de={de} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: 0, right: 0, bottom: 14, textAlign: 'center',
|
||||||
|
...MONO, fontSize: 9, letterSpacing: 3, color: t.accent40,
|
||||||
|
textTransform: 'uppercase' as const, fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
{de ? 'Stand heute \u00b7 live-Metriken aus der Plattform' : 'As of today \u00b7 live metrics from the platform'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DetailModal item={sel} onClose={() => setSel(null)} t={t} de={de} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,42 @@
|
|||||||
// MilestonesSlide themes — extracted from MilestonesSlide.tsx
|
// MilestonesSlide themes — extracted from MilestonesSlide.tsx
|
||||||
|
// Contains: THEMES, Theme type, useIsLight hook, MONO style, CSS_KF keyframes
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export const MONO: React.CSSProperties = {
|
||||||
|
fontFamily: '"JetBrains Mono","SF Mono",ui-monospace,monospace',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CSS_KF = `
|
||||||
|
@keyframes msFlow { 0%{stroke-dashoffset:0} 100%{stroke-dashoffset:-18} }
|
||||||
|
@keyframes msFadeIn { from{opacity:0} to{opacity:1} }
|
||||||
|
@keyframes msScaleIn { from{opacity:0;transform:scale(.94)} to{opacity:1;transform:scale(1)} }
|
||||||
|
@keyframes msHeadingDark {
|
||||||
|
0%,100%{text-shadow:0 0 22px rgba(167,139,250,.3)}
|
||||||
|
50% {text-shadow:0 0 40px rgba(167,139,250,.6)}
|
||||||
|
}
|
||||||
|
@keyframes msHeadingLight {
|
||||||
|
0%,100%{text-shadow:0 0 22px rgba(124,58,237,.15)}
|
||||||
|
50% {text-shadow:0 0 36px rgba(124,58,237,.30)}
|
||||||
|
}
|
||||||
|
@keyframes msPulse {
|
||||||
|
0%,100%{r:9;opacity:.4}
|
||||||
|
50% {r:14;opacity:.05}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export function useIsLight() {
|
||||||
|
const [isLight, setIsLight] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => setIsLight(document.documentElement.classList.contains('theme-light'))
|
||||||
|
check()
|
||||||
|
const obs = new MutationObserver(check)
|
||||||
|
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||||
|
return () => obs.disconnect()
|
||||||
|
}, [])
|
||||||
|
return isLight
|
||||||
|
}
|
||||||
|
|
||||||
export const THEMES = {
|
export const THEMES = {
|
||||||
dark: {
|
dark: {
|
||||||
@@ -103,4 +141,4 @@ export const THEMES = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Theme = typeof THEMES.dark
|
export type Theme = typeof THEMES.dark | typeof THEMES.light
|
||||||
|
|||||||
@@ -1,137 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { Language } from '@/lib/types'
|
import { Language } from '@/lib/types'
|
||||||
import GradientText from '../ui/GradientText'
|
import GradientText from '../ui/GradientText'
|
||||||
import FadeInView from '../ui/FadeInView'
|
import FadeInView from '../ui/FadeInView'
|
||||||
|
import { type Milestone } from './MilestonesSlide.data'
|
||||||
import { type Milestone, MILESTONES, STATS } from './MilestonesSlide.data'
|
import { THEMES, CSS_KF, useIsLight } from './MilestonesSlide.themes'
|
||||||
import { THEMES } from './MilestonesSlide.themes'
|
import { MilestonesInner } from './MilestonesSlide.parts'
|
||||||
import { StarField, SoftGrid, Timeline, StatCard, DetailModal } from './MilestonesSlide.parts'
|
|
||||||
|
|
||||||
interface MilestonesSlideProps { lang: Language }
|
interface MilestonesSlideProps { lang: Language }
|
||||||
|
|
||||||
const MONO: React.CSSProperties = {
|
|
||||||
fontFamily: '"JetBrains Mono","SF Mono",ui-monospace,monospace',
|
|
||||||
fontVariantNumeric: 'tabular-nums',
|
|
||||||
}
|
|
||||||
|
|
||||||
const CSS_KF = `
|
|
||||||
@keyframes msFlow { 0%{stroke-dashoffset:0} 100%{stroke-dashoffset:-18} }
|
|
||||||
@keyframes msFadeIn { from{opacity:0} to{opacity:1} }
|
|
||||||
@keyframes msScaleIn { from{opacity:0;transform:scale(.94)} to{opacity:1;transform:scale(1)} }
|
|
||||||
@keyframes msHeadingDark {
|
|
||||||
0%,100%{text-shadow:0 0 22px rgba(167,139,250,.3)}
|
|
||||||
50% {text-shadow:0 0 40px rgba(167,139,250,.6)}
|
|
||||||
}
|
|
||||||
@keyframes msHeadingLight {
|
|
||||||
0%,100%{text-shadow:0 0 22px rgba(124,58,237,.15)}
|
|
||||||
50% {text-shadow:0 0 36px rgba(124,58,237,.30)}
|
|
||||||
}
|
|
||||||
@keyframes msPulse {
|
|
||||||
0%,100%{r:9;opacity:.4}
|
|
||||||
50% {r:14;opacity:.05}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
// ── Light mode hook ───────────────────────────────────────────────────────────
|
|
||||||
function useIsLight() {
|
|
||||||
const [isLight, setIsLight] = useState(false)
|
|
||||||
useEffect(() => {
|
|
||||||
const check = () => setIsLight(document.documentElement.classList.contains('theme-light'))
|
|
||||||
check()
|
|
||||||
const obs = new MutationObserver(check)
|
|
||||||
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
|
||||||
return () => obs.disconnect()
|
|
||||||
}, [])
|
|
||||||
return isLight
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Inner slide (fixed 1280×680) ──────────────────────────────────────────────
|
|
||||||
function MilestonesInner({ t, de, sel, setSel }: {
|
|
||||||
t: typeof THEMES.dark; de: boolean
|
|
||||||
sel: Milestone | null
|
|
||||||
setSel: (m: Milestone | null) => void
|
|
||||||
}) {
|
|
||||||
const doneCnt = useMemo(() => MILESTONES.filter(m => m.done).length, [])
|
|
||||||
const total = MILESTONES.length
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
position: 'relative', width: 1280, height: 600, overflow: 'hidden',
|
|
||||||
background: t.bg, color: t.fg,
|
|
||||||
fontFamily: '"Inter", system-ui, sans-serif', WebkitFontSmoothing: 'antialiased',
|
|
||||||
}}>
|
|
||||||
{/* Ambient glow */}
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', top: -120, left: '50%', transform: 'translateX(-50%)',
|
|
||||||
width: 800, height: 500, borderRadius: '50%',
|
|
||||||
background: t.ambient, filter: 'blur(50px)', pointerEvents: 'none',
|
|
||||||
}} />
|
|
||||||
|
|
||||||
{t.stars ? <StarField /> : <SoftGrid t={t} />}
|
|
||||||
|
|
||||||
{/* Progress indicator */}
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', top: 36, right: 52, display: 'flex', alignItems: 'center', gap: 10, zIndex: 3,
|
|
||||||
}}>
|
|
||||||
<div style={{ ...MONO, fontSize: 10, letterSpacing: 2, color: t.fgMuted, textTransform: 'uppercase' as const, fontWeight: 700 }}>
|
|
||||||
{de ? 'Fortschritt' : 'Progress'}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
width: 120, height: 6, background: t.progressTrackBg, borderRadius: 3, overflow: 'hidden',
|
|
||||||
border: `1px solid ${t.progressTrackBorder}`,
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: `${(doneCnt / total) * 100}%`, height: '100%',
|
|
||||||
background: `linear-gradient(90deg, ${t.done}, ${t.accent})`,
|
|
||||||
boxShadow: `0 0 12px ${t.done}99`,
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
<div style={{ ...MONO, fontSize: 11, color: t.fg, fontWeight: 700 }}>
|
|
||||||
<span style={{ color: t.done }}>{doneCnt}</span>
|
|
||||||
<span style={{ color: t.fgWhisper }}> / {total}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tip */}
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', top: 36, left: 52, ...MONO, fontSize: 10,
|
|
||||||
letterSpacing: 2, color: t.fgGhost, textTransform: 'uppercase' as const, fontWeight: 700,
|
|
||||||
display: 'flex', alignItems: 'center', gap: 8, zIndex: 3,
|
|
||||||
}}>
|
|
||||||
<span>{de ? 'Tipp:' : 'Tip:'}</span>
|
|
||||||
<span style={{ color: t.accent70 }}>{de ? 'Klick auf einen Meilenstein' : 'Click any milestone'}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline */}
|
|
||||||
<div style={{ position: 'relative', marginTop: 68 }}>
|
|
||||||
<Timeline onSelect={setSel} selectedId={sel?.id ?? null} t={t} de={de} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', left: 40, right: 40, bottom: 36,
|
|
||||||
display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 14,
|
|
||||||
}}>
|
|
||||||
{STATS.map(s => <StatCard key={s.tint} item={s} t={t} de={de} />)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', left: 0, right: 0, bottom: 14, textAlign: 'center',
|
|
||||||
...MONO, fontSize: 9, letterSpacing: 3, color: t.accent40,
|
|
||||||
textTransform: 'uppercase' as const, fontWeight: 700,
|
|
||||||
}}>
|
|
||||||
{de ? 'Stand heute · live-Metriken aus der Plattform' : 'As of today · live metrics from the platform'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DetailModal item={sel} onClose={() => setSel(null)} t={t} de={de} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main slide ────────────────────────────────────────────────────────────────
|
|
||||||
const INNER_W = 1280
|
const INNER_W = 1280
|
||||||
const INNER_H = 600
|
const INNER_H = 600
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Pool, types } from 'pg'
|
|||||||
types.setTypeParser(types.builtins.NUMERIC, (val) => (val === null ? null : parseFloat(val)))
|
types.setTypeParser(types.builtins.NUMERIC, (val) => (val === null ? null : parseFloat(val)))
|
||||||
|
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
connectionString: process.env.DATABASE_URL || 'postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db',
|
connectionString: process.env.DATABASE_URL,
|
||||||
max: 20,
|
max: 20,
|
||||||
idleTimeoutMillis: 30000,
|
idleTimeoutMillis: 30000,
|
||||||
connectionTimeoutMillis: 10000,
|
connectionTimeoutMillis: 10000,
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
|
|||||||
const afaRow = betrieb.find((r: any) => r.row_label === 'Abschreibungen')
|
const afaRow = betrieb.find((r: any) => r.row_label === 'Abschreibungen')
|
||||||
const afa = afaRow?.values || emptyMonthly()
|
const afa = afaRow?.values || emptyMonthly()
|
||||||
|
|
||||||
// Liquidität endstand
|
// Liquidität endstand — match by row_type to handle both 'LIQUIDITÄT' and 'LIQUIDITAET' labels
|
||||||
const liqEndRow = liquid.find((r: any) => r.row_label === 'LIQUIDITAET')
|
const liqEndRow = liquid.find((r: any) => r.row_type === 'kontostand' && r.row_label?.includes('LIQUIDIT'))
|
||||||
const cashBalance = liqEndRow?.values || emptyMonthly()
|
const cashBalance = liqEndRow?.values || emptyMonthly()
|
||||||
|
|
||||||
// Headcount
|
// Headcount
|
||||||
@@ -120,7 +120,7 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
|
|||||||
const breakEvenMonth = results.findIndex(r => r.revenue_eur > r.total_costs_eur)
|
const breakEvenMonth = results.findIndex(r => r.revenue_eur > r.total_costs_eur)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scenario_id: sid,
|
scenario_id: sid as string,
|
||||||
results,
|
results,
|
||||||
summary: {
|
summary: {
|
||||||
final_arr: lastMonth?.arr_eur || 0,
|
final_arr: lastMonth?.arr_eur || 0,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* Betriebliche Aufwendungen — formula-based rows + category sums
|
* Betriebliche Aufwendungen — formula-based rows + category sums
|
||||||
*
|
*
|
||||||
* Computes formula-driven operating expenses (Fortbildung, Reisekosten, etc.),
|
* Computes formula-driven operating expenses (Fortbildung, Reisekosten, etc.),
|
||||||
* Gewerbesteuer, category sums, and Gesamtkosten.
|
* Gewerbesteuer, marketing, Berufsgenossenschaft, category sums, and Gesamtkosten.
|
||||||
|
* Also computes Cloud-Hosting formula in Materialaufwand.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Pool } from 'pg'
|
import { Pool } from 'pg'
|
||||||
@@ -13,6 +14,8 @@ import {
|
|||||||
} from './types'
|
} from './types'
|
||||||
import { sumRows } from './engine-sheets'
|
import { sumRows } from './engine-sheets'
|
||||||
|
|
||||||
|
type FPMaterialaufwand = import('./types').FPMaterialaufwand
|
||||||
|
|
||||||
export interface BetriebContext {
|
export interface BetriebContext {
|
||||||
totalBrutto: MonthlyValues
|
totalBrutto: MonthlyValues
|
||||||
totalPersonal: MonthlyValues
|
totalPersonal: MonthlyValues
|
||||||
@@ -23,6 +26,27 @@ export interface BetriebContext {
|
|||||||
totalBestandskunden: MonthlyValues
|
totalBestandskunden: MonthlyValues
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute Cloud-Hosting formula in Materialaufwand.
|
||||||
|
*/
|
||||||
|
export async function computeCloudHosting(
|
||||||
|
pool: Pool,
|
||||||
|
matRows: FPMaterialaufwand[],
|
||||||
|
totalBestandskunden: MonthlyValues,
|
||||||
|
): Promise<void> {
|
||||||
|
const cloudRow = matRows.find(r => r.row_label.includes('Cloud-Hosting'))
|
||||||
|
if (cloudRow) {
|
||||||
|
const computed = emptyMonthly()
|
||||||
|
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
|
||||||
|
const kunden = totalBestandskunden[`m${m}`] || 0
|
||||||
|
const extraKunden = Math.max(0, kunden - 10) // first 10 included in base
|
||||||
|
computed[`m${m}`] = Math.round(extraKunden * 100 + 1500)
|
||||||
|
}
|
||||||
|
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(computed), cloudRow.id])
|
||||||
|
cloudRow.values = computed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute all formula-based betriebliche aufwendungen rows and sums.
|
* Compute all formula-based betriebliche aufwendungen rows and sums.
|
||||||
* Writes computed values back to DB.
|
* Writes computed values back to DB.
|
||||||
@@ -41,6 +65,7 @@ export async function computeBetrieblicheAufwendungen(
|
|||||||
// Formula-based rows: derive from headcount (excl. founders) or customers
|
// Formula-based rows: derive from headcount (excl. founders) or customers
|
||||||
const formulaRows: { label: string; perUnit: number; source: MonthlyValues }[] = [
|
const formulaRows: { label: string; perUnit: number; source: MonthlyValues }[] = [
|
||||||
{ label: 'Fort-/Weiterbildungskosten (F)', perUnit: 300, source: hcWithoutFounders },
|
{ label: 'Fort-/Weiterbildungskosten (F)', perUnit: 300, source: hcWithoutFounders },
|
||||||
|
// KFZ costs are manual (from Jan 2028), not formula-based
|
||||||
{ label: 'Reisekosten (F)', perUnit: 75, source: ctx.headcount },
|
{ label: 'Reisekosten (F)', perUnit: 75, source: ctx.headcount },
|
||||||
{ label: 'Bewirtungskosten (F)', perUnit: 50, source: ctx.totalBestandskunden },
|
{ label: 'Bewirtungskosten (F)', perUnit: 50, source: ctx.totalBestandskunden },
|
||||||
{ label: 'Internet/Mobilfunk (F)', perUnit: 50, source: ctx.headcount },
|
{ label: 'Internet/Mobilfunk (F)', perUnit: 50, source: ctx.headcount },
|
||||||
@@ -58,7 +83,7 @@ export async function computeBetrieblicheAufwendungen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Berufsgenossenschaft (VBG IT/Büro): ~0.5% of total brutto payroll
|
// Berufsgenossenschaft (VBG IT/Buero): ~0.5% of total brutto payroll
|
||||||
const bgRow = betrieb.find(r => r.row_label.includes('Berufsgenossenschaft'))
|
const bgRow = betrieb.find(r => r.row_label.includes('Berufsgenossenschaft'))
|
||||||
if (bgRow) {
|
if (bgRow) {
|
||||||
const computed = emptyMonthly()
|
const computed = emptyMonthly()
|
||||||
@@ -95,6 +120,7 @@ export async function computeBetrieblicheAufwendungen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gewerbesteuer (F): 12.25% of monthly profit (only when positive)
|
// Gewerbesteuer (F): 12.25% of monthly profit (only when positive)
|
||||||
|
// Monthly profit = Revenue - Material - Personnel - AfA - other opex (excl. taxes)
|
||||||
const gewStRow = betrieb.find(r => r.row_label.includes('Gewerbesteuer'))
|
const gewStRow = betrieb.find(r => r.row_label.includes('Gewerbesteuer'))
|
||||||
if (gewStRow) {
|
if (gewStRow) {
|
||||||
const nonTaxOpex = betrieb.filter(r =>
|
const nonTaxOpex = betrieb.filter(r =>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* GuV (Gewinn- und Verlustrechnung) — annual P&L computation
|
* GuV (Gewinn- und Verlustrechnung) — annual P&L computation
|
||||||
*
|
*
|
||||||
* Computes annual sums, EBIT, taxes (Gewerbesteuer, Körperschaftsteuer)
|
* Computes annual sums, EBIT, taxes (Gewerbesteuer, Koerperschaftsteuer)
|
||||||
* with Verlustvortrag, and writes tax amounts back to Liquidität.
|
* with Verlustvortrag, and writes tax amounts back to Liquiditaet.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Pool } from 'pg'
|
import { Pool } from 'pg'
|
||||||
@@ -23,7 +23,7 @@ export interface GuvContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute GuV annual values, taxes, and write tax values to Liquidität.
|
* Compute GuV annual values, taxes, and write tax values to Liquiditaet.
|
||||||
* Returns EBIT annual values.
|
* Returns EBIT annual values.
|
||||||
*/
|
*/
|
||||||
export async function computeGuV(
|
export async function computeGuV(
|
||||||
@@ -85,7 +85,7 @@ export async function computeGuV(
|
|||||||
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ergebnisNachSteuern), scenarioId, 'Ergebnis nach Steuern'])
|
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ergebnisNachSteuern), scenarioId, 'Ergebnis nach Steuern'])
|
||||||
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ergebnisNachSteuern), scenarioId, 'Jahresüberschuss'])
|
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ergebnisNachSteuern), scenarioId, 'Jahresüberschuss'])
|
||||||
|
|
||||||
// Write taxes to Liquidität (monthly = 1/12 of annual amount)
|
// Write taxes to Liquiditaet (monthly = 1/12 of annual amount)
|
||||||
await writeTaxToLiquiditaet(pool, findLiq('Gewerbesteuer'), gewerbesteuer)
|
await writeTaxToLiquiditaet(pool, findLiq('Gewerbesteuer'), gewerbesteuer)
|
||||||
await writeTaxToLiquiditaet(pool, findLiq('Körperschaftsteuer'), koerperschaftsteuer)
|
await writeTaxToLiquiditaet(pool, findLiq('Körperschaftsteuer'), koerperschaftsteuer)
|
||||||
|
|
||||||
@@ -95,8 +95,8 @@ export async function computeGuV(
|
|||||||
// --- Tax helpers ---
|
// --- Tax helpers ---
|
||||||
|
|
||||||
// Stockach 78333: Hebesatz 350%
|
// Stockach 78333: Hebesatz 350%
|
||||||
// Gewerbesteuer = 3,5% × 3,5 = 12,25%
|
// Gewerbesteuer = 3,5% x 3,5 = 12,25%
|
||||||
// Körperschaftsteuer = 15% + 5,5% Soli = 15,825%
|
// Koerperschaftsteuer = 15% + 5,5% Soli = 15,825%
|
||||||
const GEWERBESTEUER_RATE = 0.035 * 3.5 // 12,25%
|
const GEWERBESTEUER_RATE = 0.035 * 3.5 // 12,25%
|
||||||
const KOERPERSCHAFTSTEUER_RATE = 0.15 * 1.055 // 15,825% (inkl. Soli)
|
const KOERPERSCHAFTSTEUER_RATE = 0.15 * 1.055 // 15,825% (inkl. Soli)
|
||||||
|
|
||||||
@@ -112,14 +112,16 @@ function computeTaxes(ebit: AnnualValues) {
|
|||||||
const gewinn = ebit[k] || 0
|
const gewinn = ebit[k] || 0
|
||||||
|
|
||||||
if (gewinn <= 0) {
|
if (gewinn <= 0) {
|
||||||
|
// Verlust: keine Steuern, Verlustvortrag aufbauen
|
||||||
verlustvortrag += Math.abs(gewinn)
|
verlustvortrag += Math.abs(gewinn)
|
||||||
gewerbesteuer[k] = 0
|
gewerbesteuer[k] = 0
|
||||||
koerperschaftsteuer[k] = 0
|
koerperschaftsteuer[k] = 0
|
||||||
steuernGesamt[k] = 0
|
steuernGesamt[k] = 0
|
||||||
ergebnisNachSteuern[k] = Math.round(gewinn)
|
ergebnisNachSteuern[k] = Math.round(gewinn)
|
||||||
} else {
|
} else {
|
||||||
|
// Gewinn: Verlustvortrag verrechnen
|
||||||
// Bis 1 Mio EUR: 100% verrechenbar
|
// Bis 1 Mio EUR: 100% verrechenbar
|
||||||
// Über 1 Mio EUR: nur 60% verrechenbar (Mindestbesteuerung)
|
// Ueber 1 Mio EUR: nur 60% verrechenbar (Mindestbesteuerung)
|
||||||
let verrechenbar = 0
|
let verrechenbar = 0
|
||||||
if (verlustvortrag > 0) {
|
if (verlustvortrag > 0) {
|
||||||
if (gewinn <= 1000000) {
|
if (gewinn <= 1000000) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Liquiditaet — rolling cash balance computation
|
* Liquiditaet — rolling cash balance computation
|
||||||
*
|
*
|
||||||
* Computes operative Einzahlungen/Auszahlungen sums,
|
* Computes Einzahlungen/Auszahlungen sums (dynamic row_type-based),
|
||||||
* Überschuss vor Investitionen/Entnahmen, and rolling Kontostand.
|
* Ueberschuss vor Investitionen/Entnahmen, and rolling Kontostand/Liquiditaet.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Pool } from 'pg'
|
import { Pool } from 'pg'
|
||||||
@@ -58,52 +58,57 @@ export async function computeLiquiditaet(
|
|||||||
liqInvest.values = ctx.totalInvest
|
liqInvest.values = ctx.totalInvest
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute sums and rolling balance
|
// Compute sums and rolling balance — dynamic row_type-based (handles any label conventions)
|
||||||
const sumEin = findLiq('Summe EINZAHLUNGEN')
|
await computeRollingBalance(pool, liquid)
|
||||||
const sumAus = findLiq('Summe AUSZAHLUNGEN')
|
|
||||||
const uebVorInv = findLiq('ÜBERSCHUSS VOR INVESTITIONEN')
|
|
||||||
const uebVorEnt = findLiq('ÜBERSCHUSS VOR ENTNAHMEN')
|
|
||||||
const ueberschuss = findLiq('ÜBERSCHUSS')
|
|
||||||
const kontostand = findLiq('Kontostand zu Beginn des Monats')
|
|
||||||
const liquiditaet = findLiq('LIQUIDITÄT')
|
|
||||||
|
|
||||||
// Dynamically categorize rows by row_type
|
return { endstand: liquid.find(r => r.row_type === 'kontostand' && r.row_label.includes('LIQUIDIT'))?.values || emptyMonthly() }
|
||||||
const einzahlungenOperativ = ['Umsatzerlöse', 'Sonst. betriebl. Erträge', 'Anzahlungen']
|
}
|
||||||
const finanzierungRows = liquid.filter(r =>
|
|
||||||
r.row_type === 'einzahlung' &&
|
|
||||||
!einzahlungenOperativ.includes(r.row_label) &&
|
|
||||||
!r.row_label.includes('Summe')
|
|
||||||
)
|
|
||||||
const auszahlungenOperativ = ['Materialaufwand', 'Personalkosten', 'Sonstige Kosten', 'Umsatzsteuer', 'Gewerbesteuer', 'Körperschaftsteuer']
|
|
||||||
const finanzAuszahlungRows = liquid.filter(r =>
|
|
||||||
r.row_type === 'auszahlung' &&
|
|
||||||
!auszahlungenOperativ.includes(r.row_label) &&
|
|
||||||
!r.row_label.includes('Summe')
|
|
||||||
)
|
|
||||||
|
|
||||||
// Summe EINZAHLUNGEN = nur operativ
|
/**
|
||||||
|
* Recompute Summe AUSZAHLUNGEN -> UEBERSCHUSS chain -> rolling balance.
|
||||||
|
* Called both in initial pass and after tax values are written from GuV.
|
||||||
|
*/
|
||||||
|
export async function computeRollingBalance(
|
||||||
|
pool: Pool,
|
||||||
|
liquid: FPLiquiditaet[],
|
||||||
|
): Promise<void> {
|
||||||
|
const findLiqMatch = (options: string[]) => liquid.find(r => options.includes(r.row_label))
|
||||||
|
|
||||||
|
const sumEin = findLiqMatch(['Summe ERTRÄGE', 'Summe EINZAHLUNGEN'])
|
||||||
|
const sumAus = findLiqMatch(['Summe AUSZAHLUNGEN'])
|
||||||
|
const uebVorInv = findLiqMatch(['ÜBERSCHUSS VOR INVESTITIONEN', 'UEBERSCHUSS VOR INVESTITIONEN'])
|
||||||
|
const uebVorEnt = findLiqMatch(['ÜBERSCHUSS VOR ENTNAHMEN', 'UEBERSCHUSS VOR ENTNAHMEN'])
|
||||||
|
const ueberschuss = findLiqMatch(['ÜBERSCHUSS', 'UEBERSCHUSS'])
|
||||||
|
const liqInvest = liquid.find(r => r.row_label === 'Investitionen')
|
||||||
|
// Kontostand: label varies per scenario (with/without parentheses)
|
||||||
|
const kontostand = liquid.find(r => r.row_type === 'kontostand' && !r.row_label.includes('LIQUIDIT'))
|
||||||
|
const liquiditaet = liquid.find(r => r.row_type === 'kontostand' && r.row_label.includes('LIQUIDIT'))
|
||||||
|
|
||||||
|
// Summe ERTRAEGE = ALL einzahlungen (dynamic — works regardless of how many rows exist)
|
||||||
if (sumEin) {
|
if (sumEin) {
|
||||||
const s = emptyMonthly()
|
const s = emptyMonthly()
|
||||||
for (const label of einzahlungenOperativ) {
|
for (const row of liquid) {
|
||||||
const row = findLiq(label)
|
if (row.row_type === 'einzahlung' && row.id !== sumEin.id) {
|
||||||
if (row) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
|
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), sumEin.id])
|
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), sumEin.id])
|
||||||
sumEin.values = s
|
sumEin.values = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summe AUSZAHLUNGEN = nur operativ
|
// Summe AUSZAHLUNGEN = ALL auszahlungen (dynamic)
|
||||||
if (sumAus) {
|
if (sumAus) {
|
||||||
const s = emptyMonthly()
|
const s = emptyMonthly()
|
||||||
for (const label of auszahlungenOperativ) {
|
for (const row of liquid) {
|
||||||
const row = findLiq(label)
|
if (row.row_type === 'auszahlung' && row.id !== sumAus.id) {
|
||||||
if (row) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
|
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), sumAus.id])
|
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), sumAus.id])
|
||||||
sumAus.values = s
|
sumAus.values = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// OPERATIVER ÜBERSCHUSS VOR INVESTITIONEN
|
// UEBERSCHUSS VOR INVESTITIONEN = Summe ERTRAEGE - Summe AUSZAHLUNGEN (total cashflow)
|
||||||
if (uebVorInv && sumEin && sumAus) {
|
if (uebVorInv && sumEin && sumAus) {
|
||||||
const s = emptyMonthly()
|
const s = emptyMonthly()
|
||||||
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((sumEin.values[`m${m}`] || 0) - (sumAus.values[`m${m}`] || 0))
|
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((sumEin.values[`m${m}`] || 0) - (sumAus.values[`m${m}`] || 0))
|
||||||
@@ -111,7 +116,7 @@ export async function computeLiquiditaet(
|
|||||||
uebVorInv.values = s
|
uebVorInv.values = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// ÜBERSCHUSS VOR ENTNAHMEN = Operativer Überschuss - Investitionen
|
// UEBERSCHUSS VOR ENTNAHMEN = UEBERSCHUSS VOR INVESTITIONEN - Investitionen
|
||||||
if (uebVorEnt && uebVorInv && liqInvest) {
|
if (uebVorEnt && uebVorInv && liqInvest) {
|
||||||
const s = emptyMonthly()
|
const s = emptyMonthly()
|
||||||
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((uebVorInv.values[`m${m}`] || 0) - (liqInvest.values[`m${m}`] || 0))
|
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((uebVorInv.values[`m${m}`] || 0) - (liqInvest.values[`m${m}`] || 0))
|
||||||
@@ -119,8 +124,8 @@ export async function computeLiquiditaet(
|
|||||||
uebVorEnt.values = s
|
uebVorEnt.values = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// ÜBERSCHUSS = Überschuss vor Entnahmen - Entnahmen
|
// UEBERSCHUSS = UEBERSCHUSS VOR ENTNAHMEN - Kapitalentnahmen
|
||||||
const entnahmen = findLiq('Kapitalentnahmen/Ausschüttungen')
|
const entnahmen = findLiqMatch(['Kapitalentnahmen/Ausschüttungen', 'Kapitalentnahmen/Ausschuettungen'])
|
||||||
if (ueberschuss && uebVorEnt && entnahmen) {
|
if (ueberschuss && uebVorEnt && entnahmen) {
|
||||||
const s = emptyMonthly()
|
const s = emptyMonthly()
|
||||||
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((uebVorEnt.values[`m${m}`] || 0) - (entnahmen.values[`m${m}`] || 0))
|
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((uebVorEnt.values[`m${m}`] || 0) - (entnahmen.values[`m${m}`] || 0))
|
||||||
@@ -128,27 +133,18 @@ export async function computeLiquiditaet(
|
|||||||
ueberschuss.values = s
|
ueberschuss.values = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rolling Kontostand: Vormonat + Operativer Überschuss + Finanzierung
|
// Rolling balance: LIQUIDITAET[m] = LIQUIDITAET[m-1] + UEBERSCHUSS[m]
|
||||||
|
// UEBERSCHUSS now includes ALL cash flows (operative + financing + repayments)
|
||||||
if (kontostand && liquiditaet && ueberschuss) {
|
if (kontostand && liquiditaet && ueberschuss) {
|
||||||
const finCF = emptyMonthly()
|
|
||||||
for (const row of finanzierungRows) {
|
|
||||||
for (let m = 1; m <= MONTHS; m++) finCF[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
|
|
||||||
}
|
|
||||||
for (const row of finanzAuszahlungRows) {
|
|
||||||
for (let m = 1; m <= MONTHS; m++) finCF[`m${m}`] -= Math.round(row.values[`m${m}`] || 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ks = emptyMonthly()
|
const ks = emptyMonthly()
|
||||||
const lq = emptyMonthly()
|
const lq = emptyMonthly()
|
||||||
for (let m = 1; m <= MONTHS; m++) {
|
for (let m = 1; m <= MONTHS; m++) {
|
||||||
ks[`m${m}`] = m === 1 ? 0 : Math.round(lq[`m${m - 1}`])
|
ks[`m${m}`] = m === 1 ? 0 : Math.round(lq[`m${m - 1}`])
|
||||||
lq[`m${m}`] = Math.round(ks[`m${m}`] + (ueberschuss.values[`m${m}`] || 0) + (finCF[`m${m}`] || 0))
|
lq[`m${m}`] = Math.round(ks[`m${m}`] + (ueberschuss.values[`m${m}`] || 0))
|
||||||
}
|
}
|
||||||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(ks), kontostand.id])
|
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(ks), kontostand.id])
|
||||||
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(lq), liquiditaet.id])
|
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(lq), liquiditaet.id])
|
||||||
kontostand.values = ks
|
kontostand.values = ks
|
||||||
liquiditaet.values = lq
|
liquiditaet.values = lq
|
||||||
}
|
}
|
||||||
|
|
||||||
return { endstand: liquiditaet?.values || emptyMonthly() }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MonthlyValues, MONTHS, FOUNDING_MONTH,
|
MonthlyValues, MONTHS,
|
||||||
emptyMonthly, dateToMonth, monthToDate,
|
emptyMonthly, dateToMonth, monthToDate,
|
||||||
FPPersonalkosten, FPInvestitionen,
|
FPPersonalkosten, FPInvestitionen,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|||||||
@@ -4,40 +4,38 @@
|
|||||||
* Dependency order:
|
* Dependency order:
|
||||||
* Personalkosten (independent inputs)
|
* Personalkosten (independent inputs)
|
||||||
* Investitionen (independent inputs)
|
* Investitionen (independent inputs)
|
||||||
* Kunden → Umsatzerlöse → Materialaufwand
|
* Kunden -> Umsatzerloese -> Materialaufwand
|
||||||
* Betriebliche Aufwendungen (needs Personal + Invest)
|
* Betriebliche Aufwendungen (needs Personal + Invest)
|
||||||
* Sonst. betr. Erträge (independent)
|
* Sonst. betr. Ertraege (independent)
|
||||||
* Liquidität (aggregates all above)
|
* Liquiditaet (aggregates all above)
|
||||||
* GuV (annual summary)
|
* GuV (annual summary)
|
||||||
*
|
*
|
||||||
* Split into modules:
|
* Each computation step is delegated to a companion module:
|
||||||
* engine-sheets.ts — pure calculators (no DB)
|
* engine-sheets.ts — pure computation (Personal, Invest, aggregation)
|
||||||
* engine-betrieb.ts — betriebliche aufwendungen
|
* engine-betrieb.ts — formula-based opex + category sums
|
||||||
* engine-liquiditaet.ts — liquidity / cash flow
|
* engine-liquiditaet.ts — rolling cash balance
|
||||||
* engine-guv.ts — GuV / P&L + taxes
|
* engine-guv.ts — annual P&L + taxes
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Pool } from 'pg'
|
import { Pool } from 'pg'
|
||||||
import {
|
import {
|
||||||
MonthlyValues, MONTHS, FOUNDING_MONTH,
|
MonthlyValues, MONTHS, FOUNDING_MONTH,
|
||||||
emptyMonthly,
|
emptyMonthly, FPBetrieblicheAufwendungen,
|
||||||
FPPersonalkosten, FPInvestitionen, FPBetrieblicheAufwendungen,
|
|
||||||
FPLiquiditaet, FPComputeResult,
|
FPLiquiditaet, FPComputeResult,
|
||||||
} from './types'
|
} from './types'
|
||||||
import {
|
import { computePersonalkosten, computeInvestitionen, sumField } from './engine-sheets'
|
||||||
computePersonalkosten, computeInvestitionen,
|
import { computeBetrieblicheAufwendungen, computeCloudHosting } from './engine-betrieb'
|
||||||
sumField, computeHeadcount,
|
import { computeLiquiditaet, computeRollingBalance } from './engine-liquiditaet'
|
||||||
} from './engine-sheets'
|
|
||||||
import { computeBetrieblicheAufwendungen } from './engine-betrieb'
|
|
||||||
import { computeLiquiditaet } from './engine-liquiditaet'
|
|
||||||
import { computeGuV } from './engine-guv'
|
import { computeGuV } from './engine-guv'
|
||||||
|
|
||||||
// Re-export sheet calculators for direct consumers
|
// Re-export pure calculators for direct use by tests / scripts
|
||||||
export { computePersonalkosten, computeInvestitionen } from './engine-sheets'
|
export { computePersonalkosten, computeInvestitionen } from './engine-sheets'
|
||||||
|
|
||||||
// Import types used inline
|
// Import types used only inside this file
|
||||||
type FPUmsatzerloese = import('./types').FPUmsatzerloese
|
type FPUmsatzerloese = import('./types').FPUmsatzerloese
|
||||||
type FPMaterialaufwand = import('./types').FPMaterialaufwand
|
type FPMaterialaufwand = import('./types').FPMaterialaufwand
|
||||||
|
type FPPersonalkosten = import('./types').FPPersonalkosten
|
||||||
|
type FPInvestitionen = import('./types').FPInvestitionen
|
||||||
|
|
||||||
// --- Main Engine ---
|
// --- Main Engine ---
|
||||||
|
|
||||||
@@ -61,8 +59,12 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise
|
|||||||
const totalBrutto = sumField(personal as any, 'values_brutto')
|
const totalBrutto = sumField(personal as any, 'values_brutto')
|
||||||
const totalSozial = sumField(personal as any, 'values_sozial')
|
const totalSozial = sumField(personal as any, 'values_sozial')
|
||||||
const totalPersonal = sumField(personal as any, 'values_total')
|
const totalPersonal = sumField(personal as any, 'values_total')
|
||||||
const headcount = computeHeadcount(personal)
|
const headcount = emptyMonthly()
|
||||||
|
for (let m = 1; m <= MONTHS; m++) {
|
||||||
|
headcount[`m${m}`] = personal.filter(p => (p.values_total[`m${m}`] || 0) > 0).length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write computed values back to DB
|
||||||
for (const p of personal) {
|
for (const p of personal) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'UPDATE fp_personalkosten SET values_brutto = $1, values_sozial = $2, values_total = $3 WHERE id = $4',
|
'UPDATE fp_personalkosten SET values_brutto = $1, values_sozial = $2, values_total = $3 WHERE id = $4',
|
||||||
@@ -82,84 +84,70 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Umsatzerlöse (quantity × price) + Materialaufwand
|
// 4. Umsatzerloese + Materialaufwand
|
||||||
const { totalRevenue, totalMaterial } = await computeRevenueAndMaterial(
|
const { totalRevenue, totalMaterial } = await computeRevenueAndMaterial(
|
||||||
pool, umsatzRows.rows as FPUmsatzerloese[], materialRows.rows as FPMaterialaufwand[]
|
pool, umsatzRows.rows as FPUmsatzerloese[], materialRows.rows as FPMaterialaufwand[],
|
||||||
)
|
)
|
||||||
|
|
||||||
// 5. Bestandskunden (for formula-based costs)
|
// 5. Bestandskunden (for formula-based costs)
|
||||||
const kundenRows = await pool.query(
|
const totalBestandskunden = await loadBestandskunden(pool, scenarioId)
|
||||||
"SELECT segment_name, row_label, values FROM fp_kunden WHERE scenario_id = $1 AND row_label LIKE 'Bestandskunden%' ORDER BY sort_order",
|
|
||||||
[scenarioId]
|
|
||||||
)
|
|
||||||
const totalBestandskunden = emptyMonthly()
|
|
||||||
for (const row of kundenRows.rows) {
|
|
||||||
const rl = (row as { row_label?: string }).row_label || ''
|
|
||||||
if (rl.includes('Bestandskunden') && !rl.includes('gesamt')) {
|
|
||||||
for (let m = 1; m <= MONTHS; m++) {
|
|
||||||
totalBestandskunden[`m${m}`] += row.values?.[`m${m}`] || 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cloud-Hosting in Materialaufwand
|
// 5b. Cloud-Hosting formula in Materialaufwand
|
||||||
const matRows = materialRows.rows as FPMaterialaufwand[]
|
await computeCloudHosting(pool, materialRows.rows as FPMaterialaufwand[], totalBestandskunden)
|
||||||
const cloudRow = matRows.find(r => r.row_label.includes('Cloud-Hosting'))
|
|
||||||
if (cloudRow) {
|
|
||||||
const computed = emptyMonthly()
|
|
||||||
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
|
|
||||||
const kunden = totalBestandskunden[`m${m}`] || 0
|
|
||||||
const extraKunden = Math.max(0, kunden - 10)
|
|
||||||
computed[`m${m}`] = Math.round(extraKunden * 100 + 1500)
|
|
||||||
}
|
|
||||||
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(computed), cloudRow.id])
|
|
||||||
cloudRow.values = computed
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Betriebliche Aufwendungen
|
// 6. Betriebliche Aufwendungen
|
||||||
const betrieb = betriebRows.rows as FPBetrieblicheAufwendungen[]
|
const betrieb = betriebRows.rows as FPBetrieblicheAufwendungen[]
|
||||||
const { totalSonstige, totalGesamt } = await computeBetrieblicheAufwendungen(pool, betrieb, {
|
const { totalSonstige } = await computeBetrieblicheAufwendungen(pool, betrieb, {
|
||||||
totalBrutto, totalPersonal, totalAfa, totalRevenue, totalMaterial,
|
totalBrutto, totalPersonal, totalAfa, totalRevenue, totalMaterial,
|
||||||
headcount, totalBestandskunden,
|
headcount, totalBestandskunden,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 7. Liquidität
|
// 7. Liquiditaet (first pass)
|
||||||
const liquid = liquidRows.rows as FPLiquiditaet[]
|
const liquid = liquidRows.rows as FPLiquiditaet[]
|
||||||
const { endstand } = await computeLiquiditaet(pool, liquid, {
|
await computeLiquiditaet(pool, liquid, {
|
||||||
totalRevenue, totalMaterial, totalPersonal, totalSonstige, totalInvest,
|
totalRevenue, totalMaterial, totalPersonal, totalSonstige, totalInvest,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 8. GuV
|
// 8. GuV — compute annual values + taxes (writes tax rows to Liquiditaet)
|
||||||
const guv = await computeGuV(pool, scenarioId, liquid, {
|
const guv = await computeGuV(pool, scenarioId, liquid, {
|
||||||
totalRevenue, totalMaterial, totalBrutto, totalSozial,
|
totalRevenue, totalMaterial, totalBrutto, totalSozial,
|
||||||
totalPersonal, totalAfa, totalSonstige,
|
totalPersonal, totalAfa, totalSonstige,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 9. Second pass: tax rows were written after rolling balance above.
|
||||||
|
// Recompute Summe AUSZAHLUNGEN -> UEBERSCHUSS chain -> rolling balance
|
||||||
|
// so taxes are included even on the first engine run.
|
||||||
|
await computeRollingBalance(pool, liquid)
|
||||||
|
|
||||||
|
// 10. Build result
|
||||||
|
const gesamtBetrieb = betrieb.find(r => r.row_label.includes('Gesamtkosten') || r.row_label.includes('SUMME Betriebliche'))
|
||||||
|
const liquiditaetRow = liquid.find(r => r.row_type === 'kontostand' && r.row_label.includes('LIQUIDIT'))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
personalkosten: { total_brutto: totalBrutto, total_sozial: totalSozial, total: totalPersonal, positions: personal, headcount },
|
personalkosten: { total_brutto: totalBrutto, total_sozial: totalSozial, total: totalPersonal, positions: personal, headcount },
|
||||||
investitionen: { total_invest: totalInvest, total_afa: totalAfa, items: invest },
|
investitionen: { total_invest: totalInvest, total_afa: totalAfa, items: invest },
|
||||||
umsatzerloese: { total: totalRevenue },
|
umsatzerloese: { total: totalRevenue },
|
||||||
materialaufwand: { total: totalMaterial },
|
materialaufwand: { total: totalMaterial },
|
||||||
betriebliche: { total_sonstige: totalSonstige, total_gesamt: totalGesamt },
|
betriebliche: { total_sonstige: totalSonstige, total_gesamt: gesamtBetrieb?.values || emptyMonthly() },
|
||||||
liquiditaet: { rows: liquid, endstand },
|
liquiditaet: { rows: liquid, endstand: liquiditaetRow?.values || emptyMonthly() },
|
||||||
guv,
|
guv,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Revenue & Material helpers (kept here to avoid circular deps) ---
|
// --- Revenue & Material helpers (kept in orchestrator — tightly coupled to DB writes) ---
|
||||||
|
|
||||||
async function computeRevenueAndMaterial(
|
async function computeRevenueAndMaterial(
|
||||||
pool: Pool,
|
pool: Pool,
|
||||||
umsatzAllRows: FPUmsatzerloese[],
|
umsatzAllRows: FPUmsatzerloese[],
|
||||||
materialAllRows: FPMaterialaufwand[],
|
materialAllRows: FPMaterialaufwand[],
|
||||||
): Promise<{ totalRevenue: MonthlyValues; totalMaterial: MonthlyValues }> {
|
): Promise<{ totalRevenue: MonthlyValues; totalMaterial: MonthlyValues }> {
|
||||||
|
// Umsatzerloese (quantity x price)
|
||||||
const prices = umsatzAllRows.filter(r => r.section === 'price')
|
const prices = umsatzAllRows.filter(r => r.section === 'price')
|
||||||
const quantities = umsatzAllRows.filter(r => r.section === 'quantity')
|
const quantities = umsatzAllRows.filter(r => r.section === 'quantity')
|
||||||
const revenueRows = umsatzAllRows.filter(r => r.section === 'revenue')
|
const revenueRows = umsatzAllRows.filter(r => r.section === 'revenue')
|
||||||
const totalRevenue = emptyMonthly()
|
const totalRevenue = emptyMonthly()
|
||||||
|
|
||||||
const extractTier = (label: string) => { const m = label.match(/\(([^)]+)\)/); return m ? m[1] : label }
|
const extractTier = (label: string) => { const m = label.match(/\(([^)]+)\)/); return m ? m[1] : label }
|
||||||
|
|
||||||
for (const rev of revenueRows) {
|
for (const rev of revenueRows) {
|
||||||
if (rev.row_label === 'GESAMTUMSATZ') continue
|
if (rev.row_label === 'GESAMTUMSATZ') continue
|
||||||
const tier = extractTier(rev.row_label)
|
const tier = extractTier(rev.row_label)
|
||||||
@@ -167,8 +155,7 @@ async function computeRevenueAndMaterial(
|
|||||||
const price = prices.find(p => extractTier(p.row_label) === tier) || prices.find(p => p.row_label === rev.row_label)
|
const price = prices.find(p => extractTier(p.row_label) === tier) || prices.find(p => p.row_label === rev.row_label)
|
||||||
if (qty && price) {
|
if (qty && price) {
|
||||||
for (let m = 1; m <= MONTHS; m++) {
|
for (let m = 1; m <= MONTHS; m++) {
|
||||||
const v = (qty.values[`m${m}`] || 0) * (price.values[`m${m}`] || 0)
|
rev.values[`m${m}`] = Math.round((qty.values[`m${m}`] || 0) * (price.values[`m${m}`] || 0))
|
||||||
rev.values[`m${m}`] = Math.round(v)
|
|
||||||
}
|
}
|
||||||
await pool.query('UPDATE fp_umsatzerloese SET values = $1 WHERE id = $2', [JSON.stringify(rev.values), rev.id])
|
await pool.query('UPDATE fp_umsatzerloese SET values = $1 WHERE id = $2', [JSON.stringify(rev.values), rev.id])
|
||||||
}
|
}
|
||||||
@@ -181,7 +168,7 @@ async function computeRevenueAndMaterial(
|
|||||||
await pool.query('UPDATE fp_umsatzerloese SET values = $1 WHERE id = $2', [JSON.stringify(totalRevenue), gesamtUmsatz.id])
|
await pool.query('UPDATE fp_umsatzerloese SET values = $1 WHERE id = $2', [JSON.stringify(totalRevenue), gesamtUmsatz.id])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Materialaufwand
|
// Materialaufwand (quantity x unit_cost)
|
||||||
const matCosts = materialAllRows.filter(r => r.section === 'cost')
|
const matCosts = materialAllRows.filter(r => r.section === 'cost')
|
||||||
const matUnitCosts = materialAllRows.filter(r => r.section === 'unit_cost')
|
const matUnitCosts = materialAllRows.filter(r => r.section === 'unit_cost')
|
||||||
const totalMaterial = emptyMonthly()
|
const totalMaterial = emptyMonthly()
|
||||||
@@ -192,8 +179,7 @@ async function computeRevenueAndMaterial(
|
|||||||
const qty = quantities.find(q => q.row_label === cost.row_label)
|
const qty = quantities.find(q => q.row_label === cost.row_label)
|
||||||
if (uc && qty) {
|
if (uc && qty) {
|
||||||
for (let m = 1; m <= MONTHS; m++) {
|
for (let m = 1; m <= MONTHS; m++) {
|
||||||
const v = (qty.values[`m${m}`] || 0) * (uc.values[`m${m}`] || 0)
|
cost.values[`m${m}`] = Math.round((qty.values[`m${m}`] || 0) * (uc.values[`m${m}`] || 0))
|
||||||
cost.values[`m${m}`] = Math.round(v)
|
|
||||||
}
|
}
|
||||||
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(cost.values), cost.id])
|
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(cost.values), cost.id])
|
||||||
}
|
}
|
||||||
@@ -208,3 +194,20 @@ async function computeRevenueAndMaterial(
|
|||||||
|
|
||||||
return { totalRevenue, totalMaterial }
|
return { totalRevenue, totalMaterial }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadBestandskunden(pool: Pool, scenarioId: string): Promise<MonthlyValues> {
|
||||||
|
const kundenRows = await pool.query(
|
||||||
|
"SELECT segment_name, row_label, values FROM fp_kunden WHERE scenario_id = $1 AND row_label LIKE 'Bestandskunden%' ORDER BY sort_order",
|
||||||
|
[scenarioId]
|
||||||
|
)
|
||||||
|
const totalBestandskunden = emptyMonthly()
|
||||||
|
for (const row of kundenRows.rows) {
|
||||||
|
const rl = (row as { row_label?: string }).row_label || ''
|
||||||
|
if (rl.includes('Bestandskunden') && !rl.includes('gesamt')) {
|
||||||
|
for (let m = 1; m <= MONTHS; m++) {
|
||||||
|
totalBestandskunden[`m${m}`] += row.values?.[`m${m}`] || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totalBestandskunden
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ const PUBLIC_PATHS = [
|
|||||||
'/auth', // investor login pages
|
'/auth', // investor login pages
|
||||||
'/api/auth', // investor auth API
|
'/api/auth', // investor auth API
|
||||||
'/api/health',
|
'/api/health',
|
||||||
'/api/admin/fp-patch',
|
|
||||||
'/api/admin-auth', // admin login API
|
'/api/admin-auth', // admin login API
|
||||||
'/pitch-admin/login', // admin login page
|
'/pitch-admin/login', // admin login page
|
||||||
'/_next',
|
'/_next',
|
||||||
@@ -47,10 +46,14 @@ export async function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
// ----- Admin-gated routes -----
|
// ----- Admin-gated routes -----
|
||||||
if (isAdminGatedPath(pathname)) {
|
if (isAdminGatedPath(pathname)) {
|
||||||
// Allow legacy bearer-secret CLI access on /api/admin/* (the API routes themselves
|
// Allow bearer-secret CLI access on /api/admin/* — validate the token here,
|
||||||
// also check this and log as actor='cli'). The bearer header is opaque to the JWT
|
// not just in the route handler, to avoid any unprotected route slipping through.
|
||||||
// path, so we just let it through here and let the route handler enforce.
|
|
||||||
if (pathname.startsWith('/api/admin') && request.headers.get('authorization')?.startsWith('Bearer ')) {
|
if (pathname.startsWith('/api/admin') && request.headers.get('authorization')?.startsWith('Bearer ')) {
|
||||||
|
const bearerToken = request.headers.get('authorization')!.slice(7)
|
||||||
|
const adminSecret = process.env.PITCH_ADMIN_SECRET
|
||||||
|
if (!adminSecret || bearerToken !== adminSecret) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
return NextResponse.next()
|
return NextResponse.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ const nextConfig = {
|
|||||||
NEXT_PUBLIC_GIT_SHA: process.env.GIT_SHA || 'dev',
|
NEXT_PUBLIC_GIT_SHA: process.env.GIT_SHA || 'dev',
|
||||||
},
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
typescript: {
|
|
||||||
ignoreBuildErrors: true,
|
|
||||||
},
|
|
||||||
serverExternalPackages: ['nodemailer'],
|
serverExternalPackages: ['nodemailer'],
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [{ "name": "next" }],
|
"plugins": [{ "name": "next" }],
|
||||||
"paths": { "@/*": ["./*"] },
|
"paths": { "@/*": ["./*"] },
|
||||||
"target": "ES2017"
|
"target": "ES2018"
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|||||||
Reference in New Issue
Block a user