feat: Finanzplan Phase 1-4 — DB + Engine + API + Spreadsheet-UI
Phase 1: DB-Schema (12 fp_* Tabellen) + Excel-Import (332 Zeilen importiert) Phase 2: Compute Engine (Personal, Invest, Umsatz, Material, Betrieblich, Liquiditaet, GuV) Phase 3: API (/api/finanzplan/ — GET sheets, PUT cells, POST compute) Phase 4: Spreadsheet-UI (FinanzplanSlide als Annex mit Tab-Leiste, editierbarem Grid, Jahres-Navigation) Zusaetzlich: - Gruendungsdatum verschoben: Feb→Aug 2026 (DB + Personalkosten) - Neue Preisstaffel: Startup/<10 MA ab 3.600 EUR/Jahr (14-Tage-Test, Kreditkarte) - Competition-Slide: Pricing-Tiers aktualisiert Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
100
pitch-deck/app/api/finanzplan/[sheetName]/route.ts
Normal file
100
pitch-deck/app/api/finanzplan/[sheetName]/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
|
||||
const TABLE_MAP: Record<string, string> = {
|
||||
kunden: 'fp_kunden',
|
||||
kunden_summary: 'fp_kunden_summary',
|
||||
umsatzerloese: 'fp_umsatzerloese',
|
||||
materialaufwand: 'fp_materialaufwand',
|
||||
personalkosten: 'fp_personalkosten',
|
||||
betriebliche: 'fp_betriebliche_aufwendungen',
|
||||
investitionen: 'fp_investitionen',
|
||||
sonst_ertraege: 'fp_sonst_ertraege',
|
||||
liquiditaet: 'fp_liquiditaet',
|
||||
guv: 'fp_guv',
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sheetName: string }> }
|
||||
) {
|
||||
const { sheetName } = await params
|
||||
const table = TABLE_MAP[sheetName]
|
||||
if (!table) {
|
||||
return NextResponse.json({ error: `Unknown sheet: ${sheetName}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const scenarioId = request.nextUrl.searchParams.get('scenarioId')
|
||||
|
||||
try {
|
||||
let query = `SELECT * FROM ${table}`
|
||||
const params: string[] = []
|
||||
|
||||
if (scenarioId) {
|
||||
query += ' WHERE scenario_id = $1'
|
||||
params.push(scenarioId)
|
||||
} else {
|
||||
query += ' WHERE scenario_id = (SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1)'
|
||||
}
|
||||
query += ' ORDER BY sort_order'
|
||||
|
||||
const { rows } = await pool.query(query, params)
|
||||
return NextResponse.json({ sheet: sheetName, rows })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ sheetName: string }> }
|
||||
) {
|
||||
const { sheetName } = await params
|
||||
const table = TABLE_MAP[sheetName]
|
||||
if (!table) {
|
||||
return NextResponse.json({ error: `Unknown sheet: ${sheetName}` }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { rowId, updates } = body // updates: { field: value } or { m3: 1500 } for monthly values
|
||||
|
||||
if (!rowId) {
|
||||
return NextResponse.json({ error: 'rowId required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if updating monthly values (JSONB) or scalar fields
|
||||
const monthlyKeys = Object.keys(updates).filter(k => k.startsWith('m') && !isNaN(parseInt(k.substring(1))))
|
||||
const scalarKeys = Object.keys(updates).filter(k => !k.startsWith('m') || isNaN(parseInt(k.substring(1))))
|
||||
|
||||
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'
|
||||
// Use jsonb_set for each key
|
||||
let updateSql = `UPDATE ${table} SET `
|
||||
const setClauses: string[] = []
|
||||
for (const k of monthlyKeys) {
|
||||
setClauses.push(`${valuesCol} = jsonb_set(${valuesCol}, '{${k}}', '${updates[k]}')`)
|
||||
}
|
||||
setClauses.push(`updated_at = NOW()`)
|
||||
updateSql += setClauses.join(', ') + ` WHERE id = $1`
|
||||
await pool.query(updateSql, [rowId])
|
||||
}
|
||||
|
||||
if (scalarKeys.length > 0) {
|
||||
// Update scalar columns directly
|
||||
const setClauses = scalarKeys.map((k, i) => `${k} = $${i + 2}`).join(', ')
|
||||
await pool.query(
|
||||
`UPDATE ${table} SET ${setClauses}, updated_at = NOW() WHERE id = $1`,
|
||||
[rowId, ...scalarKeys.map(k => updates[k])]
|
||||
)
|
||||
}
|
||||
|
||||
// Return updated row
|
||||
const { rows } = await pool.query(`SELECT * FROM ${table} WHERE id = $1`, [rowId])
|
||||
return NextResponse.json({ updated: rows[0] })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 })
|
||||
}
|
||||
}
|
||||
38
pitch-deck/app/api/finanzplan/compute/route.ts
Normal file
38
pitch-deck/app/api/finanzplan/compute/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { computeFinanzplan } from '@/lib/finanzplan/engine'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const scenarioId = body.scenarioId
|
||||
|
||||
// Get scenario ID
|
||||
let sid = scenarioId
|
||||
if (!sid) {
|
||||
const { rows } = await pool.query('SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1')
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'No default scenario found' }, { status: 404 })
|
||||
}
|
||||
sid = rows[0].id
|
||||
}
|
||||
|
||||
const result = await computeFinanzplan(pool, sid)
|
||||
|
||||
return NextResponse.json({
|
||||
scenarioId: sid,
|
||||
computed: true,
|
||||
summary: {
|
||||
total_revenue_y2026: result.umsatzerloese.total.m1 !== undefined
|
||||
? Object.values(result.umsatzerloese.total).reduce((a, b) => a + b, 0)
|
||||
: 0,
|
||||
total_costs_y2026: Object.values(result.betriebliche.total_gesamt).reduce((a, b) => a + b, 0),
|
||||
headcount_m60: result.personalkosten.headcount.m60 || 0,
|
||||
cash_balance_m60: result.liquiditaet.endstand.m60 || 0,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Finanzplan compute error:', error)
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 })
|
||||
}
|
||||
}
|
||||
32
pitch-deck/app/api/finanzplan/route.ts
Normal file
32
pitch-deck/app/api/finanzplan/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { SHEET_LIST } from '@/lib/finanzplan/types'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const scenarios = await pool.query('SELECT * FROM fp_scenarios ORDER BY is_default DESC, name')
|
||||
|
||||
// Get row counts per sheet
|
||||
const sheets = await Promise.all(
|
||||
SHEET_LIST.map(async (s) => {
|
||||
const tableName = `fp_${s.name}`
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE is_editable = true) as editable FROM ${tableName} WHERE scenario_id = (SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1)`
|
||||
)
|
||||
return { ...s, rows: parseInt(rows[0]?.total || '0'), editable_rows: parseInt(rows[0]?.editable || '0') }
|
||||
} catch {
|
||||
return s
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
sheets,
|
||||
scenarios: scenarios.rows,
|
||||
months: { start: '2026-01', end: '2030-12', count: 60, founding: '2026-08' },
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user