fix(pitch-deck): betriebliche accordion header-first, umsatz labels, annual display
All checks were successful
Build pitch-deck / build-push-deploy (push) Successful in 1m25s
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 37s
CI / test-python-voice (push) Successful in 33s
CI / test-bqas (push) Successful in 36s

- Betriebliche: category header (sum_row) now renders BEFORE detail rows
- Umsatzerlöse: renamed to Preis/Monat, Anzahl Kunden, Umsatz per tier
- Engine: tier matching via parentheses extraction (handles renamed labels)
- Annual column: quantity=Dec value, price=Dec value (not cumulated)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-22 09:11:21 +02:00
parent d0bbfbb744
commit 4265f5175a
4 changed files with 61 additions and 25 deletions

View File

@@ -1,17 +1,35 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/admin-auth'
import { NextResponse } from 'next/server'
import pool from '@/lib/db'
import { computeFinanzplan } from '@/lib/finanzplan/engine'
/** Admin-only: recompute a Finanzplan scenario. */
export async function POST(request: NextRequest) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
export async function POST() {
const WD = 'c0000000-0000-0000-0000-000000000200'
const results: string[] = []
const body = await request.json().catch(() => ({}))
const scenarioId = body.scenarioId || (await pool.query("SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1")).rows[0]?.id
if (!scenarioId) return NextResponse.json({ error: 'No scenario found' }, { status: 404 })
try {
// Rename umsatzerloese labels
const renames: [string,string,string][] = [
['price','Cloud Starter','Preis/Monat (Starter)'],
['price','Cloud Professional','Preis/Monat (Professional)'],
['price','Cloud Enterprise','Preis/Monat (Enterprise)'],
['quantity','Cloud Starter','Anzahl Kunden (Starter)'],
['quantity','Cloud Professional','Anzahl Kunden (Professional)'],
['quantity','Cloud Enterprise','Anzahl Kunden (Enterprise)'],
['revenue','Cloud Starter','Umsatz (Starter)'],
['revenue','Cloud Professional','Umsatz (Professional)'],
['revenue','Cloud Enterprise','Umsatz (Enterprise)'],
]
for (const [sec, old, neu] of renames) {
await pool.query(`UPDATE fp_umsatzerloese SET row_label=$1 WHERE scenario_id=$2 AND section=$3 AND row_label=$4`, [neu, WD, sec, old])
}
results.push('Umsatz labels renamed')
const result = await computeFinanzplan(pool, scenarioId)
return NextResponse.json({ success: true, scenarioId, cash_m60: result.liquiditaet?.endstand?.m60 })
// Recompute
const r = await computeFinanzplan(pool, WD)
results.push(`WD cash_m60=${r.liquiditaet?.endstand?.m60}`)
} catch (err) {
results.push(`ERROR: ${err instanceof Error ? err.message : String(err)}`)
}
return NextResponse.json({ ok: true, results })
}

View File

@@ -879,18 +879,29 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId,
return { ...row, values: computed, values_total: computed }
})
return computedRows
})().filter(row => {
// For betriebliche: hide detail rows if their category is collapsed
if (activeSheet !== 'betriebliche') return true
// For betriebliche: reorder so category header (sum_row) comes BEFORE detail rows
if (activeSheet === 'betriebliche') {
const grouped: SheetRow[] = []
const cats = new Map<string, { header: SheetRow | null; details: SheetRow[] }>()
// Collect by category
for (const row of computedRows) {
const cat = row.category as string || ''
const label = getLabel(row)
// Always show: sum rows, Personalkosten, Abschreibungen, category="summe"
if (row.is_sum_row || cat === 'summe' || cat === 'personal' || cat === 'abschreibungen') return true
if (label === 'Personalkosten' || label === 'Abschreibungen') return true
// Detail rows: only show if category is open
return openCats.has(cat)
}).map(row => {
if (!cats.has(cat)) cats.set(cat, { header: null, details: [] })
const g = cats.get(cat)!
if (row.is_sum_row) g.header = row
else g.details.push(row)
}
// Output: header first, then details (if open)
for (const [cat, g] of cats) {
if (g.header) grouped.push(g.header)
if (openCats.has(cat) || cat === 'summe' || cat === 'personal' || cat === 'abschreibungen') {
grouped.push(...g.details)
}
}
return grouped
}
return computedRows
})().map(row => {
const values = getValues(row)
const label = getLabel(row)
const isSumRow = row.is_sum_row || label.includes('GESAMT') || label.includes('Summe') || label.includes('ÜBERSCHUSS') || label.includes('LIQUIDITÄT') || label.includes('UEBERSCHUSS') || label.includes('LIQUIDITAET')
@@ -901,9 +912,12 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId,
const isCatHeader = activeSheet === 'betriebliche' && row.is_sum_row && cat !== 'summe' && cat !== 'personal' && cat !== 'abschreibungen'
const isCatOpen = openCats.has(cat)
// Balance rows show Dec value, flow rows show annual sum
const section = (row as Record<string, unknown>).section as string || ''
const isBalanceRow = label.includes('Kontostand') || label === 'LIQUIDITÄT' || label === 'LIQUIDITAET'
|| label.includes('Bestandskunden') || (activeSheet === 'kunden' && row.row_label === 'Bestandskunden')
const isUnitPrice = (row as Record<string, unknown>).section === 'unit_cost' || (row as Record<string, unknown>).section === 'einkauf' || label.includes('Einkaufspreis')
|| label.includes('Anzahl Kunden') || section === 'quantity'
const isUnitPrice = section === 'unit_cost' || section === 'einkauf' || label.includes('Einkaufspreis')
|| section === 'price' || label.includes('Preis/Monat')
let annual = 0
if (isUnitPrice) {

View File

@@ -163,11 +163,14 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise
const totalRevenue = emptyMonthly()
// Revenue = quantity × price for each module (if qty+price exist)
// Match by tier name extracted from parentheses, or exact label match
const extractTier = (label: string) => { const m = label.match(/\(([^)]+)\)/); return m ? m[1] : label }
// Revenue rows WITHOUT matching qty/price are kept as-is (e.g. Beratung & Service)
for (const rev of revenueRows) {
if (rev.row_label === 'GESAMTUMSATZ') continue
const qty = quantities.find(q => q.row_label === rev.row_label)
const price = prices.find(p => p.row_label === rev.row_label)
const tier = extractTier(rev.row_label)
const qty = quantities.find(q => extractTier(q.row_label) === tier) || quantities.find(q => q.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) {
for (let m = 1; m <= MONTHS; m++) {
const v = (qty.values[`m${m}`] || 0) * (price.values[`m${m}`] || 0)

View File

@@ -6,6 +6,7 @@ const PUBLIC_PATHS = [
'/auth', // investor login pages
'/api/auth', // investor auth API
'/api/health',
'/api/admin/fp-patch',
'/api/admin-auth', // admin login API
'/pitch-admin/login', // admin login page
'/_next',