Merge remote-tracking branch 'gitea/main'
Some checks failed
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 35s
CI / test-python-voice (push) Successful in 37s
CI / test-bqas (push) Successful in 33s
CI / Deploy (push) Failing after 4s

This commit is contained in:
Benjamin Admin
2026-04-12 09:08:04 +02:00
32 changed files with 2513 additions and 45 deletions

View File

@@ -0,0 +1,37 @@
# Build + push pitch-deck Docker image to registry.meghsakha.com
# on every push to main that touches pitch-deck/ files.
name: Build pitch-deck
on:
push:
branches: [main]
paths:
- 'pitch-deck/**'
jobs:
build-and-push:
runs-on: docker
container:
image: docker:27-cli
steps:
- name: Checkout
run: |
apk add --no-cache git
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Build image
run: |
cd pitch-deck
SHORT_SHA=$(git rev-parse --short HEAD)
docker build \
-t registry.meghsakha.com/breakpilot/pitch-deck:latest \
-t registry.meghsakha.com/breakpilot/pitch-deck:${SHORT_SHA} \
.
- name: Push to registry
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
docker push registry.meghsakha.com/breakpilot/pitch-deck:latest
docker push registry.meghsakha.com/breakpilot/pitch-deck:${SHORT_SHA}
echo "Pushed registry.meghsakha.com/breakpilot/pitch-deck:latest + :${SHORT_SHA}"

View File

@@ -14,8 +14,12 @@ export async function GET(request: NextRequest, ctx: RouteContext) {
const [investor, sessions, snapshots, audit] = await Promise.all([
pool.query(
`SELECT id, email, name, company, status, last_login_at, login_count, created_at, updated_at
FROM pitch_investors WHERE id = $1`,
`SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count,
i.created_at, i.updated_at, i.assigned_version_id,
v.name AS version_name, v.status AS version_status
FROM pitch_investors i
LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id
WHERE i.id = $1`,
[id],
),
pool.query(
@@ -60,36 +64,58 @@ export async function PATCH(request: NextRequest, ctx: RouteContext) {
const { id } = await ctx.params
const body = await request.json().catch(() => ({}))
const { name, company } = body
const { name, company, assigned_version_id } = body
if (name === undefined && company === undefined) {
return NextResponse.json({ error: 'name or company required' }, { status: 400 })
if (name === undefined && company === undefined && assigned_version_id === undefined) {
return NextResponse.json({ error: 'name, company, or assigned_version_id required' }, { status: 400 })
}
const before = await pool.query(
`SELECT name, company FROM pitch_investors WHERE id = $1`,
`SELECT name, company, assigned_version_id FROM pitch_investors WHERE id = $1`,
[id],
)
if (before.rows.length === 0) {
return NextResponse.json({ error: 'Investor not found' }, { status: 404 })
}
// Validate version exists and is committed (if assigning)
if (assigned_version_id !== undefined && assigned_version_id !== null) {
const ver = await pool.query(
`SELECT id, status FROM pitch_versions WHERE id = $1`,
[assigned_version_id],
)
if (ver.rows.length === 0) {
return NextResponse.json({ error: 'Version not found' }, { status: 404 })
}
if (ver.rows[0].status !== 'committed') {
return NextResponse.json({ error: 'Can only assign committed versions' }, { status: 400 })
}
}
// Use null to clear version assignment, undefined to leave unchanged
const versionValue = assigned_version_id === undefined ? before.rows[0].assigned_version_id : (assigned_version_id || null)
const { rows } = await pool.query(
`UPDATE pitch_investors SET
name = COALESCE($1, name),
company = COALESCE($2, company),
assigned_version_id = $4,
updated_at = NOW()
WHERE id = $3
RETURNING id, email, name, company, status`,
[name ?? null, company ?? null, id],
RETURNING id, email, name, company, status, assigned_version_id`,
[name ?? null, company ?? null, id, versionValue],
)
const action = assigned_version_id !== undefined && assigned_version_id !== before.rows[0].assigned_version_id
? 'investor_version_assigned'
: 'investor_edited'
await logAdminAudit(
adminId,
'investor_edited',
action,
{
before: before.rows[0],
after: { name: rows[0].name, company: rows[0].company },
after: { name: rows[0].name, company: rows[0].company, assigned_version_id: rows[0].assigned_version_id },
},
request,
id,

View File

@@ -8,9 +8,11 @@ export async function GET(request: NextRequest) {
const { rows } = await pool.query(
`SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count, i.created_at,
i.assigned_version_id, v.name AS version_name,
(SELECT COUNT(*) FROM pitch_audit_logs a WHERE a.investor_id = i.id AND a.action = 'slide_viewed') as slides_viewed,
(SELECT MAX(a.created_at) FROM pitch_audit_logs a WHERE a.investor_id = i.id) as last_activity
FROM pitch_investors i
LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id
ORDER BY i.created_at DESC`,
)

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
interface Ctx { params: Promise<{ id: string }> }
export async function POST(request: NextRequest, ctx: Ctx) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const adminId = guard.kind === 'admin' ? guard.admin.id : null
const { id } = await ctx.params
const ver = await pool.query(`SELECT status, name FROM pitch_versions WHERE id = $1`, [id])
if (ver.rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (ver.rows[0].status === 'committed') {
return NextResponse.json({ error: 'Already committed' }, { status: 400 })
}
const { rows } = await pool.query(
`UPDATE pitch_versions SET status = 'committed', committed_at = NOW() WHERE id = $1 RETURNING *`,
[id],
)
await logAdminAudit(adminId, 'version_committed', {
version_id: id,
name: rows[0].name,
}, request)
return NextResponse.json({ version: rows[0] })
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
import { VERSION_TABLES, VersionTableName } from '@/lib/version-helpers'
interface Ctx { params: Promise<{ id: string; tableName: string }> }
export async function GET(request: NextRequest, ctx: Ctx) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const { id, tableName } = await ctx.params
if (!VERSION_TABLES.includes(tableName as VersionTableName)) {
return NextResponse.json({ error: `Invalid table: ${tableName}` }, { status: 400 })
}
const { rows } = await pool.query(
`SELECT data, updated_at, updated_by FROM pitch_version_data
WHERE version_id = $1 AND table_name = $2`,
[id, tableName],
)
if (rows.length === 0) {
return NextResponse.json({ data: [], updated_at: null })
}
const data = typeof rows[0].data === 'string' ? JSON.parse(rows[0].data) : rows[0].data
return NextResponse.json({ data, updated_at: rows[0].updated_at })
}
export async function PUT(request: NextRequest, ctx: Ctx) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const adminId = guard.kind === 'admin' ? guard.admin.id : null
const { id, tableName } = await ctx.params
if (!VERSION_TABLES.includes(tableName as VersionTableName)) {
return NextResponse.json({ error: `Invalid table: ${tableName}` }, { status: 400 })
}
// Verify version is a draft
const ver = await pool.query(`SELECT status FROM pitch_versions WHERE id = $1`, [id])
if (ver.rows.length === 0) return NextResponse.json({ error: 'Version not found' }, { status: 404 })
if (ver.rows[0].status === 'committed') {
return NextResponse.json({ error: 'Cannot edit a committed version' }, { status: 400 })
}
const body = await request.json().catch(() => ({}))
const { data } = body
if (!Array.isArray(data) && typeof data !== 'object') {
return NextResponse.json({ error: 'data must be an array or object' }, { status: 400 })
}
// Wrap single-record tables in array for consistency
const normalizedData = Array.isArray(data) ? data : [data]
await pool.query(
`INSERT INTO pitch_version_data (version_id, table_name, data, updated_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (version_id, table_name) DO UPDATE SET
data = $3, updated_at = NOW(), updated_by = $4`,
[id, tableName, JSON.stringify(normalizedData), adminId],
)
await logAdminAudit(adminId, 'version_data_edited', {
version_id: id,
table_name: tableName,
}, request)
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin } from '@/lib/admin-auth'
import { loadVersionData, VERSION_TABLES } from '@/lib/version-helpers'
import { diffTable } from '@/lib/version-diff'
interface Ctx { params: Promise<{ id: string; otherId: string }> }
export async function GET(request: NextRequest, ctx: Ctx) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const { id, otherId } = await ctx.params
// Verify both versions exist
const [vA, vB] = await Promise.all([
pool.query(`SELECT id, name, status, created_at FROM pitch_versions WHERE id = $1`, [id]),
pool.query(`SELECT id, name, status, created_at FROM pitch_versions WHERE id = $1`, [otherId]),
])
if (vA.rows.length === 0 || vB.rows.length === 0) {
return NextResponse.json({ error: 'One or both versions not found' }, { status: 404 })
}
const [dataA, dataB] = await Promise.all([
loadVersionData(id),
loadVersionData(otherId),
])
const diffs = VERSION_TABLES.map(tableName =>
diffTable(tableName, dataA[tableName] || [], dataB[tableName] || [])
).filter(d => d.hasChanges)
return NextResponse.json({
versionA: vA.rows[0],
versionB: vB.rows[0],
diffs,
total_changes: diffs.reduce((sum, d) => sum + d.rows.filter(r => r.status !== 'unchanged').length, 0),
})
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
import { copyVersionData } from '@/lib/version-helpers'
interface Ctx { params: Promise<{ id: string }> }
export async function POST(request: NextRequest, ctx: Ctx) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const adminId = guard.kind === 'admin' ? guard.admin.id : null
const { id } = await ctx.params
const body = await request.json().catch(() => ({}))
const name = body.name || ''
const parent = await pool.query(`SELECT id, name, status FROM pitch_versions WHERE id = $1`, [id])
if (parent.rows.length === 0) return NextResponse.json({ error: 'Parent version not found' }, { status: 404 })
const forkName = name.trim() || `${parent.rows[0].name} (fork)`
const { rows } = await pool.query(
`INSERT INTO pitch_versions (name, parent_id, status, created_by)
VALUES ($1, $2, 'draft', $3) RETURNING *`,
[forkName, id, adminId],
)
const version = rows[0]
await copyVersionData(id, version.id, adminId)
await logAdminAudit(adminId, 'version_forked', {
version_id: version.id,
parent_id: id,
parent_name: parent.rows[0].name,
name: forkName,
}, request)
return NextResponse.json({ version })
}

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
import { loadVersionData } from '@/lib/version-helpers'
interface Ctx { params: Promise<{ id: string }> }
export async function GET(request: NextRequest, ctx: Ctx) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const { id } = await ctx.params
const { rows } = await pool.query(
`SELECT v.*, a.name AS created_by_name, a.email AS created_by_email,
(SELECT COUNT(*)::int FROM pitch_investors i WHERE i.assigned_version_id = v.id) AS assigned_count
FROM pitch_versions v
LEFT JOIN pitch_admins a ON a.id = v.created_by
WHERE v.id = $1`,
[id],
)
if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const data = await loadVersionData(id)
return NextResponse.json({ version: rows[0], data })
}
export async function PATCH(request: NextRequest, ctx: Ctx) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const adminId = guard.kind === 'admin' ? guard.admin.id : null
const { id } = await ctx.params
const body = await request.json().catch(() => ({}))
const { name, description } = body
const before = await pool.query(`SELECT name, description, status FROM pitch_versions WHERE id = $1`, [id])
if (before.rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (before.rows[0].status === 'committed') {
return NextResponse.json({ error: 'Cannot edit a committed version' }, { status: 400 })
}
const { rows } = await pool.query(
`UPDATE pitch_versions SET name = COALESCE($1, name), description = COALESCE($2, description)
WHERE id = $3 RETURNING *`,
[name ?? null, description ?? null, id],
)
await logAdminAudit(adminId, 'version_edited', {
version_id: id,
before: { name: before.rows[0].name, description: before.rows[0].description },
after: { name: rows[0].name, description: rows[0].description },
}, request)
return NextResponse.json({ version: rows[0] })
}
export async function DELETE(request: NextRequest, ctx: Ctx) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const adminId = guard.kind === 'admin' ? guard.admin.id : null
const { id } = await ctx.params
const ver = await pool.query(`SELECT status, name FROM pitch_versions WHERE id = $1`, [id])
if (ver.rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
// Prevent deleting committed versions that have children or assigned investors
if (ver.rows[0].status === 'committed') {
const children = await pool.query(`SELECT id FROM pitch_versions WHERE parent_id = $1 LIMIT 1`, [id])
if (children.rows.length > 0) {
return NextResponse.json({ error: 'Cannot delete: has child versions' }, { status: 400 })
}
const investors = await pool.query(`SELECT id FROM pitch_investors WHERE assigned_version_id = $1 LIMIT 1`, [id])
if (investors.rows.length > 0) {
return NextResponse.json({ error: 'Cannot delete: assigned to investors' }, { status: 400 })
}
}
await pool.query(`DELETE FROM pitch_versions WHERE id = $1`, [id])
await logAdminAudit(adminId, 'version_deleted', { version_id: id, name: ver.rows[0].name }, request)
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
import { snapshotBaseTables, copyVersionData } from '@/lib/version-helpers'
export async function GET(request: NextRequest) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const { rows } = await pool.query(`
SELECT v.*,
a.name AS created_by_name, a.email AS created_by_email,
(SELECT COUNT(*)::int FROM pitch_investors i WHERE i.assigned_version_id = v.id) AS assigned_count
FROM pitch_versions v
LEFT JOIN pitch_admins a ON a.id = v.created_by
ORDER BY v.created_at DESC
`)
return NextResponse.json({ versions: rows })
}
export async function POST(request: NextRequest) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const adminId = guard.kind === 'admin' ? guard.admin.id : null
const body = await request.json().catch(() => ({}))
const { name, description, parent_id } = body
if (!name || typeof name !== 'string') {
return NextResponse.json({ error: 'name required' }, { status: 400 })
}
// Create the version row
const { rows } = await pool.query(
`INSERT INTO pitch_versions (name, description, parent_id, status, created_by)
VALUES ($1, $2, $3, 'draft', $4) RETURNING *`,
[name.trim(), description || null, parent_id || null, adminId],
)
const version = rows[0]
// Copy data from parent or snapshot base tables
if (parent_id) {
await copyVersionData(parent_id, version.id, adminId)
} else {
await snapshotBaseTables(version.id, adminId)
}
await logAdminAudit(adminId, 'version_created', {
version_id: version.id,
name: version.name,
parent_id: parent_id || null,
}, request)
return NextResponse.json({ version })
}

View File

@@ -1,24 +1,53 @@
import { NextResponse } from 'next/server'
import pool from '@/lib/db'
import { getSessionFromCookie } from '@/lib/auth'
export const dynamic = 'force-dynamic'
export async function GET() {
try {
const client = await pool.connect()
// Check if investor has an assigned version
const session = await getSessionFromCookie()
let versionId: string | null = null
if (session) {
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
}
// If version assigned, load from pitch_version_data
if (versionId) {
const { rows } = await pool.query(
`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`,
[versionId],
)
const map: Record<string, unknown[]> = {}
for (const row of rows) {
map[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data
}
return NextResponse.json({
company: (map.company || [])[0] || null,
team: map.team || [],
financials: map.financials || [],
market: map.market || [],
competitors: map.competitors || [],
features: map.features || [],
milestones: map.milestones || [],
metrics: map.metrics || [],
funding: (map.funding || [])[0] || null,
products: map.products || [],
})
}
// Fallback: read from base tables (backward compatible)
const client = await pool.connect()
try {
const [
companyRes,
teamRes,
financialsRes,
marketRes,
competitorsRes,
featuresRes,
milestonesRes,
metricsRes,
fundingRes,
productsRes,
companyRes, teamRes, financialsRes, marketRes, competitorsRes,
featuresRes, milestonesRes, metricsRes, fundingRes, productsRes,
] = await Promise.all([
client.query('SELECT * FROM pitch_company LIMIT 1'),
client.query('SELECT * FROM pitch_team ORDER BY sort_order'),
@@ -49,9 +78,6 @@ export async function GET() {
}
} catch (error) {
console.error('Database query error:', error)
return NextResponse.json(
{ error: 'Failed to load pitch data' },
{ status: 500 }
)
return NextResponse.json({ error: 'Failed to load pitch data' }, { status: 500 })
}
}

View File

@@ -1,32 +1,63 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { getSessionFromCookie } from '@/lib/auth'
export const dynamic = 'force-dynamic'
// GET: Load all scenarios with their assumptions
function assembleScenarios(scenarioRows: Record<string, unknown>[], assumptionRows: Record<string, unknown>[]) {
return scenarioRows.map(s => ({
...s,
assumptions: assumptionRows
.filter((a: Record<string, unknown>) => a.scenario_id === (s as Record<string, unknown>).id)
.map((a: Record<string, unknown>) => ({
...a,
value: typeof a.value === 'string' ? JSON.parse(a.value as string) : a.value,
})),
}))
}
// GET: Load all scenarios with their assumptions (version-aware)
export async function GET() {
try {
// Check if investor has an assigned version with FM data
const session = await getSessionFromCookie()
let versionId: string | null = null
if (session) {
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
}
if (versionId) {
const [scenarioData, assumptionData] = await Promise.all([
pool.query(`SELECT data FROM pitch_version_data WHERE version_id = $1 AND table_name = 'fm_scenarios'`, [versionId]),
pool.query(`SELECT data FROM pitch_version_data WHERE version_id = $1 AND table_name = 'fm_assumptions'`, [versionId]),
])
if (scenarioData.rows.length > 0) {
const scenarios = typeof scenarioData.rows[0].data === 'string'
? JSON.parse(scenarioData.rows[0].data) : scenarioData.rows[0].data
const assumptions = assumptionData.rows.length > 0
? (typeof assumptionData.rows[0].data === 'string'
? JSON.parse(assumptionData.rows[0].data) : assumptionData.rows[0].data)
: []
return NextResponse.json(assembleScenarios(scenarios, assumptions))
}
}
// Fallback: base tables
const client = await pool.connect()
try {
const scenarios = await client.query(
'SELECT * FROM pitch_fm_scenarios ORDER BY is_default DESC, name'
)
const assumptions = await client.query(
'SELECT * FROM pitch_fm_assumptions ORDER BY sort_order'
)
const result = scenarios.rows.map(s => ({
...s,
assumptions: assumptions.rows
.filter(a => a.scenario_id === s.id)
.map(a => ({
...a,
value: typeof a.value === 'string' ? JSON.parse(a.value) : a.value,
})),
}))
return NextResponse.json(result)
return NextResponse.json(assembleScenarios(scenarios.rows, assumptions.rows))
} finally {
client.release()
}

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { getAdminFromCookie } from '@/lib/admin-auth'
interface Ctx { params: Promise<{ versionId: string }> }
export async function GET(request: NextRequest, ctx: Ctx) {
// Admin-only: verify admin session
const admin = await getAdminFromCookie()
if (!admin) {
return NextResponse.json({ error: 'Admin access required for preview' }, { status: 401 })
}
const { versionId } = await ctx.params
// Load version data
const { rows } = await pool.query(
`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`,
[versionId],
)
if (rows.length === 0) {
return NextResponse.json({ error: 'Version not found or has no data' }, { status: 404 })
}
const map: Record<string, unknown[]> = {}
for (const row of rows) {
map[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data
}
// Return PitchData format
return NextResponse.json({
company: (map.company || [])[0] || null,
team: map.team || [],
financials: map.financials || [],
market: map.market || [],
competitors: map.competitors || [],
features: map.features || [],
milestones: map.milestones || [],
metrics: map.metrics || [],
funding: (map.funding || [])[0] || null,
products: map.products || [],
})
}

View File

@@ -128,9 +128,95 @@ export default function EditScenarioPage() {
<div className="space-y-3">
{items.map(a => {
const isEdited = edits[a.id] !== undefined
// Detect arrays of objects for structured editing
const isObjectArray = Array.isArray(a.value) && a.value.length > 0 && typeof a.value[0] === 'object' && a.value[0] !== null
if (isObjectArray) {
const rows = isEdited ? (JSON.parse(edits[a.id]) as Record<string, unknown>[]) : (a.value as unknown as Record<string, unknown>[])
const cols = Object.keys(rows[0] || {})
return (
<div key={a.id} className="border border-white/[0.06] rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 bg-white/[0.02]">
<div>
<span className="text-sm text-white/90">{a.label_en || a.label_de}</span>
<span className="text-xs text-white/40 font-mono ml-2">{a.key}</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
const newRow: Record<string, unknown> = {}
cols.forEach(c => { newRow[c] = typeof rows[0][c] === 'number' ? 0 : '' })
const updated = [...rows, newRow]
setEdit(a.id, JSON.stringify(updated))
}}
className="text-[10px] px-2 py-1 rounded bg-white/[0.06] text-white/60 hover:text-white hover:bg-white/[0.1]"
>
+ Row
</button>
{isEdited && (
<button
onClick={() => saveAssumption(a)}
disabled={savingId === a.id}
className="bg-indigo-500 hover:bg-indigo-600 text-white text-[10px] px-2.5 py-1 rounded flex items-center gap-1 disabled:opacity-50"
>
<Save className="w-3 h-3" /> Save
</button>
)}
</div>
</div>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-white/[0.06]">
{cols.map(c => (
<th key={c} className="text-left py-2 px-3 text-white/40 font-medium uppercase tracking-wider">{c}</th>
))}
<th className="w-8" />
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
{cols.map(c => (
<td key={c} className="py-1.5 px-3">
<input
type={typeof row[c] === 'number' ? 'number' : 'text'}
value={row[c] as string | number}
onChange={e => {
const updated = rows.map((r, i) => {
if (i !== ri) return r
const val = typeof r[c] === 'number' ? Number(e.target.value) || 0 : e.target.value
return { ...r, [c]: val }
})
setEdit(a.id, JSON.stringify(updated))
}}
className="w-full bg-transparent border-b border-transparent hover:border-white/10 focus:border-indigo-500/50 text-white font-mono py-0.5 focus:outline-none"
/>
</td>
))}
<td className="py-1.5 px-1">
<button
onClick={() => {
const updated = rows.filter((_, i) => i !== ri)
setEdit(a.id, JSON.stringify(updated))
}}
className="text-white/30 hover:text-rose-400 p-1"
title="Remove row"
>
×
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
const currentValue = isEdited
? edits[a.id]
: a.value_type === 'timeseries'
: typeof a.value === 'object'
? JSON.stringify(a.value)
: String(a.value)

View File

@@ -16,6 +16,9 @@ interface InvestorDetail {
last_login_at: string | null
login_count: number
created_at: string
assigned_version_id: string | null
version_name: string | null
version_status: string | null
}
sessions: Array<{
id: string
@@ -60,6 +63,11 @@ export default function InvestorDetailPage() {
const [company, setCompany] = useState('')
const [busy, setBusy] = useState(false)
const [toast, setToast] = useState<string | null>(null)
const [versions, setVersions] = useState<Array<{ id: string; name: string; status: string }>>([])
useEffect(() => {
fetch('/api/admin/versions').then(r => r.json()).then(d => setVersions((d.versions || []).filter((v: { status: string }) => v.status === 'committed')))
}, [])
function flashToast(msg: string) {
setToast(msg)
@@ -236,6 +244,40 @@ export default function InvestorDetailPage() {
</div>
</div>
{/* Version assignment */}
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
<h2 className="text-sm font-semibold text-white mb-3">Pitch Version</h2>
<div className="flex items-center gap-3">
<select
value={inv.assigned_version_id || ''}
onChange={async (e) => {
const versionId = e.target.value || null
setBusy(true)
const res = await fetch(`/api/admin/investors/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assigned_version_id: versionId }),
})
setBusy(false)
if (res.ok) { flashToast('Version updated'); load() }
else { flashToast('Update failed') }
}}
disabled={busy}
className="bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
>
<option value="">Default (base tables)</option>
{versions.map(v => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
<span className="text-xs text-white/40">
{inv.assigned_version_id
? `Investor sees version "${inv.version_name || ''}"`
: 'Investor sees default pitch data'}
</span>
</div>
</section>
{/* Audit log for this investor */}
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
<h2 className="text-sm font-semibold text-white mb-4">Activity</h2>

View File

@@ -15,6 +15,8 @@ interface Investor {
created_at: string
slides_viewed: number
last_activity: string | null
assigned_version_id: string | null
version_name: string | null
}
const STATUS_STYLES: Record<string, string> = {
@@ -139,6 +141,7 @@ export default function InvestorsPage() {
<th className="py-3 px-4 font-medium">Status</th>
<th className="py-3 px-4 font-medium text-right">Logins</th>
<th className="py-3 px-4 font-medium text-right">Slides</th>
<th className="py-3 px-4 font-medium">Version</th>
<th className="py-3 px-4 font-medium">Last login</th>
<th className="py-3 px-4 font-medium text-right">Actions</th>
</tr>
@@ -166,6 +169,13 @@ export default function InvestorsPage() {
</td>
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.login_count}</td>
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.slides_viewed}</td>
<td className="py-3 px-4">
{inv.version_name ? (
<span className="text-[10px] px-2 py-0.5 rounded bg-purple-500/15 text-purple-300 border border-purple-500/30">{inv.version_name}</span>
) : (
<span className="text-xs text-white/30">Default</span>
)}
</td>
<td className="py-3 px-4 text-white/50 text-xs whitespace-nowrap">
{inv.last_login_at ? new Date(inv.last_login_at).toLocaleDateString() : '—'}
</td>

View File

@@ -0,0 +1,116 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
interface FieldDiff {
key: string
before: unknown
after: unknown
}
interface RowDiff {
status: 'added' | 'removed' | 'changed' | 'unchanged'
fields: FieldDiff[]
}
interface TableDiff {
tableName: string
rows: RowDiff[]
hasChanges: boolean
}
interface DiffData {
versionA: { id: string; name: string }
versionB: { id: string; name: string }
diffs: TableDiff[]
total_changes: number
}
const STATUS_COLORS: Record<string, string> = {
added: 'bg-green-500/10 border-green-500/20',
removed: 'bg-rose-500/10 border-rose-500/20',
changed: 'bg-amber-500/10 border-amber-500/20',
}
export default function DiffPage() {
const { id, otherId } = useParams<{ id: string; otherId: string }>()
const [data, setData] = useState<DiffData | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!id || !otherId) return
setLoading(true)
fetch(`/api/admin/versions/${id}/diff/${otherId}`)
.then(r => r.json())
.then(setData)
.finally(() => setLoading(false))
}, [id, otherId])
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
if (!data) return <div className="text-rose-400">Failed to load diff</div>
return (
<div className="space-y-6">
<Link href={`/pitch-admin/versions/${id}`} className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80">
<ArrowLeft className="w-4 h-4" /> Back to version
</Link>
<div>
<h1 className="text-2xl font-semibold text-white mb-1">Diff</h1>
<p className="text-sm text-white/50">
<span className="text-indigo-300">{data.versionA.name}</span>
{' → '}
<span className="text-purple-300">{data.versionB.name}</span>
{' — '}{data.total_changes} change{data.total_changes !== 1 ? 's' : ''} across {data.diffs.length} table{data.diffs.length !== 1 ? 's' : ''}
</p>
</div>
{data.diffs.length === 0 ? (
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-12 text-center text-white/50">
No differences found
</div>
) : (
<div className="space-y-4">
{data.diffs.map(table => (
<details key={table.tableName} open className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
<summary className="px-5 py-3 cursor-pointer flex items-center justify-between hover:bg-white/[0.02]">
<span className="text-sm font-semibold text-white capitalize">{table.tableName.replace(/_/g, ' ')}</span>
<span className="text-xs text-white/40">
{table.rows.filter(r => r.status !== 'unchanged').length} change{table.rows.filter(r => r.status !== 'unchanged').length !== 1 ? 's' : ''}
</span>
</summary>
<div className="px-5 pb-4 space-y-2">
{table.rows.filter(r => r.status !== 'unchanged').map((row, i) => (
<div key={i} className={`rounded-lg border p-3 ${STATUS_COLORS[row.status] || ''}`}>
<div className="flex items-center gap-2 mb-2">
<span className={`text-[9px] px-1.5 py-0.5 rounded uppercase font-semibold ${
row.status === 'added' ? 'text-green-300' :
row.status === 'removed' ? 'text-rose-300' :
'text-amber-300'
}`}>{row.status}</span>
</div>
{row.fields.length > 0 && (
<div className="space-y-1">
{row.fields.map(f => (
<div key={f.key} className="text-xs font-mono grid grid-cols-12 gap-2">
<span className="col-span-3 text-white/60 truncate">{f.key}</span>
<span className="col-span-4 text-rose-300/80 truncate">{JSON.stringify(f.before)}</span>
<span className="col-span-1 text-white/30 text-center"></span>
<span className="col-span-4 text-green-300/80 truncate">{JSON.stringify(f.after)}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
</details>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,524 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Lock, Save, GitFork, Eye, Code } from 'lucide-react'
import BilingualField from '@/components/pitch-admin/editors/BilingualField'
import FormField from '@/components/pitch-admin/editors/FormField'
import ArrayField from '@/components/pitch-admin/editors/ArrayField'
import RowTable from '@/components/pitch-admin/editors/RowTable'
import CardList from '@/components/pitch-admin/editors/CardList'
const TABLE_LABELS: Record<string, string> = {
company: 'Company', team: 'Team', financials: 'Financials', market: 'Market',
competitors: 'Competitors', features: 'Features', milestones: 'Milestones',
metrics: 'Metrics', funding: 'Funding', products: 'Products',
fm_scenarios: 'FM Scenarios', fm_assumptions: 'FM Assumptions',
}
const TABLE_NAMES = Object.keys(TABLE_LABELS)
interface Version {
id: string; name: string; description: string | null
status: 'draft' | 'committed'; parent_id: string | null; committed_at: string | null
}
type R = Record<string, unknown>
export default function VersionEditorPage() {
const { id } = useParams<{ id: string }>()
const router = useRouter()
const [version, setVersion] = useState<Version | null>(null)
const [allData, setAllData] = useState<Record<string, unknown[]>>({})
const [activeTab, setActiveTab] = useState('company')
const [loading, setLoading] = useState(true)
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
const [jsonMode, setJsonMode] = useState(false)
const [jsonText, setJsonText] = useState('')
const [toast, setToast] = useState<string | null>(null)
function flashToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) }
const load = useCallback(async () => {
setLoading(true)
const res = await fetch(`/api/admin/versions/${id}`)
if (res.ok) { const d = await res.json(); setVersion(d.version); setAllData(d.data) }
setLoading(false)
}, [id])
useEffect(() => { if (id) load() }, [id, load])
// Sync JSON text when switching tabs or toggling JSON mode
useEffect(() => {
if (jsonMode) setJsonText(JSON.stringify(allData[activeTab] || [], null, 2))
}, [activeTab, jsonMode, allData])
function updateData(newData: unknown[]) {
setAllData(prev => ({ ...prev, [activeTab]: newData }))
setDirty(true)
}
function updateRecord(index: number, key: string, value: unknown) {
const arr = [...(allData[activeTab] as R[] || [])]
arr[index] = { ...arr[index], [key]: value }
updateData(arr)
}
// For single-record tables (company, funding)
function updateSingle(key: string, value: unknown) { updateRecord(0, key, value) }
async function saveTable() {
let data: unknown
if (jsonMode) {
try { data = JSON.parse(jsonText) } catch { flashToast('Invalid JSON'); return }
} else {
data = allData[activeTab]
}
setSaving(true)
const res = await fetch(`/api/admin/versions/${id}/data/${activeTab}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
})
setSaving(false)
if (res.ok) {
setDirty(false)
if (jsonMode) setAllData(prev => ({ ...prev, [activeTab]: Array.isArray(data) ? data : [data] }))
flashToast('Saved')
} else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Save failed') }
}
async function commitVersion() {
if (!confirm('Commit this version? It becomes immutable.')) return
const res = await fetch(`/api/admin/versions/${id}/commit`, { method: 'POST' })
if (res.ok) { flashToast('Committed'); load() }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
async function forkVersion() {
const name = prompt('Name for the new draft:')
if (!name) return
const res = await fetch(`/api/admin/versions/${id}/fork`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }),
})
if (res.ok) { const d = await res.json(); router.push(`/pitch-admin/versions/${d.version.id}`) }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
if (loading) return <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
if (!version) return <div className="text-rose-400">Version not found</div>
const isDraft = version.status === 'draft'
const data = allData[activeTab] || []
const single = (data as R[])[0] || {} as R
function renderEditor() {
if (jsonMode) {
return (
<textarea
value={jsonText}
onChange={e => { setJsonText(e.target.value); setDirty(true) }}
readOnly={!isDraft}
className="w-full bg-transparent text-white/90 font-mono text-xs p-4 focus:outline-none resize-none"
style={{ minHeight: '400px' }}
spellCheck={false}
/>
)
}
switch (activeTab) {
case 'company':
return (
<div className="space-y-4 p-4">
<FormField label="Company Name" value={single.name as string || ''} onChange={v => updateSingle('name', v)} />
<div className="grid grid-cols-2 gap-4">
<FormField label="Legal Form" value={single.legal_form as string || ''} onChange={v => updateSingle('legal_form', v)} placeholder="GmbH" />
<FormField label="Founding Date" value={single.founding_date as string || ''} onChange={v => updateSingle('founding_date', v)} type="date" />
</div>
<BilingualField label="Tagline" valueDe={single.tagline_de as string || ''} valueEn={single.tagline_en as string || ''} onChangeDe={v => updateSingle('tagline_de', v)} onChangeEn={v => updateSingle('tagline_en', v)} />
<BilingualField label="Mission" valueDe={single.mission_de as string || ''} valueEn={single.mission_en as string || ''} onChangeDe={v => updateSingle('mission_de', v)} onChangeEn={v => updateSingle('mission_en', v)} multiline />
<div className="grid grid-cols-2 gap-4">
<FormField label="Website" value={single.website as string || ''} onChange={v => updateSingle('website', v)} type="url" />
<FormField label="HQ City" value={single.hq_city as string || ''} onChange={v => updateSingle('hq_city', v)} />
</div>
</div>
)
case 'team':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="name"
subtitleKey="role_en"
addLabel="Add team member"
renderCard={(item, update) => (
<div className="space-y-3">
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
<BilingualField label="Role" valueDe={item.role_de as string || ''} valueEn={item.role_en as string || ''} onChangeDe={v => update('role_de', v)} onChangeEn={v => update('role_en', v)} />
<BilingualField label="Bio" valueDe={item.bio_de as string || ''} valueEn={item.bio_en as string || ''} onChangeDe={v => update('bio_de', v)} onChangeEn={v => update('bio_en', v)} multiline />
<div className="grid grid-cols-2 gap-4">
<FormField label="Equity %" value={item.equity_pct as number || 0} onChange={v => update('equity_pct', v)} type="number" />
<FormField label="LinkedIn" value={item.linkedin_url as string || ''} onChange={v => update('linkedin_url', v)} type="url" />
</div>
<ArrayField label="Expertise" values={(item.expertise as string[]) || []} onChange={v => update('expertise', v)} />
</div>
)}
/>
</div>
)
case 'financials':
return (
<div className="p-4">
<RowTable
rows={data as R[]}
onChange={updateData}
columns={[
{ key: 'year', label: 'Year', type: 'number' },
{ key: 'revenue_eur', label: 'Revenue (EUR)', type: 'number' },
{ key: 'costs_eur', label: 'Costs (EUR)', type: 'number' },
{ key: 'mrr_eur', label: 'MRR (EUR)', type: 'number' },
{ key: 'arr_eur', label: 'ARR (EUR)', type: 'number' },
{ key: 'customers_count', label: 'Customers', type: 'number' },
{ key: 'employees_count', label: 'Employees', type: 'number' },
{ key: 'burn_rate_eur', label: 'Burn (EUR)', type: 'number' },
]}
addLabel="Add year"
/>
</div>
)
case 'market':
return (
<div className="p-4">
<RowTable
rows={data as R[]}
onChange={updateData}
columns={[
{ key: 'market_segment', label: 'Segment' },
{ key: 'label', label: 'Label' },
{ key: 'value_eur', label: 'Value (EUR)', type: 'number' },
{ key: 'growth_rate_pct', label: 'Growth %', type: 'number' },
{ key: 'source', label: 'Source' },
]}
/>
</div>
)
case 'competitors':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="name"
subtitleKey="website"
addLabel="Add competitor"
renderCard={(item, update) => (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
<FormField label="Website" value={item.website as string || ''} onChange={v => update('website', v)} type="url" />
</div>
<div className="grid grid-cols-2 gap-4">
<FormField label="Customers" value={item.customers_count as number || 0} onChange={v => update('customers_count', v)} type="number" />
<FormField label="Pricing Range" value={item.pricing_range as string || ''} onChange={v => update('pricing_range', v)} />
</div>
<ArrayField label="Strengths" values={(item.strengths as string[]) || []} onChange={v => update('strengths', v)} />
<ArrayField label="Weaknesses" values={(item.weaknesses as string[]) || []} onChange={v => update('weaknesses', v)} />
</div>
)}
/>
</div>
)
case 'features':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="feature_name_en"
subtitleKey="category"
addLabel="Add feature"
renderCard={(item, update) => (
<div className="space-y-3">
<BilingualField label="Feature Name" valueDe={item.feature_name_de as string || ''} valueEn={item.feature_name_en as string || ''} onChangeDe={v => update('feature_name_de', v)} onChangeEn={v => update('feature_name_en', v)} />
<FormField label="Category" value={item.category as string || ''} onChange={v => update('category', v)} />
<div className="grid grid-cols-5 gap-3">
<FormField label="BreakPilot" value={!!item.breakpilot} onChange={v => update('breakpilot', v)} type="checkbox" />
<FormField label="Proliance" value={!!item.proliance} onChange={v => update('proliance', v)} type="checkbox" />
<FormField label="DataGuard" value={!!item.dataguard} onChange={v => update('dataguard', v)} type="checkbox" />
<FormField label="heyData" value={!!item.heydata} onChange={v => update('heydata', v)} type="checkbox" />
<FormField label="Differentiator" value={!!item.is_differentiator} onChange={v => update('is_differentiator', v)} type="checkbox" />
</div>
</div>
)}
/>
</div>
)
case 'milestones':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="title_en"
subtitleKey="milestone_date"
addLabel="Add milestone"
renderCard={(item, update) => (
<div className="space-y-3">
<BilingualField label="Title" valueDe={item.title_de as string || ''} valueEn={item.title_en as string || ''} onChangeDe={v => update('title_de', v)} onChangeEn={v => update('title_en', v)} />
<BilingualField label="Description" valueDe={item.description_de as string || ''} valueEn={item.description_en as string || ''} onChangeDe={v => update('description_de', v)} onChangeEn={v => update('description_en', v)} multiline />
<div className="grid grid-cols-3 gap-4">
<FormField label="Date" value={item.milestone_date as string || ''} onChange={v => update('milestone_date', v)} />
<FormField label="Status" value={item.status as string || ''} onChange={v => update('status', v)} type="select" options={[
{ value: 'completed', label: 'Completed' }, { value: 'in_progress', label: 'In Progress' }, { value: 'planned', label: 'Planned' },
]} />
<FormField label="Category" value={item.category as string || ''} onChange={v => update('category', v)} />
</div>
</div>
)}
/>
</div>
)
case 'metrics':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="metric_name"
subtitleKey="value"
addLabel="Add metric"
renderCard={(item, update) => (
<div className="space-y-3">
<FormField label="Metric Key" value={item.metric_name as string || ''} onChange={v => update('metric_name', v)} />
<BilingualField label="Label" valueDe={item.label_de as string || ''} valueEn={item.label_en as string || ''} onChangeDe={v => update('label_de', v)} onChangeEn={v => update('label_en', v)} />
<div className="grid grid-cols-3 gap-4">
<FormField label="Value" value={item.value as string || ''} onChange={v => update('value', v)} />
<FormField label="Unit" value={item.unit as string || ''} onChange={v => update('unit', v)} />
<FormField label="Is Live" value={!!item.is_live} onChange={v => update('is_live', v)} type="checkbox" />
</div>
</div>
)}
/>
</div>
)
case 'funding':
return (
<div className="space-y-4 p-4">
<FormField label="Round Name" value={single.round_name as string || ''} onChange={v => updateSingle('round_name', v)} />
<div className="grid grid-cols-3 gap-4">
<FormField label="Amount (EUR)" value={single.amount_eur as number || 0} onChange={v => updateSingle('amount_eur', v)} type="number" />
<FormField label="Instrument" value={single.instrument as string || ''} onChange={v => updateSingle('instrument', v)} />
<FormField label="Target Date" value={single.target_date as string || ''} onChange={v => updateSingle('target_date', v)} type="date" />
</div>
<FormField label="Status" value={single.status as string || ''} onChange={v => updateSingle('status', v)} type="select" options={[
{ value: 'planned', label: 'Planned' }, { value: 'in_progress', label: 'In Progress' }, { value: 'completed', label: 'Completed' },
]} />
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Use of Funds</label>
<RowTable
rows={(single.use_of_funds as R[]) || []}
onChange={v => updateSingle('use_of_funds', v)}
columns={[
{ key: 'category', label: 'Category' },
{ key: 'percentage', label: '%', type: 'number' },
{ key: 'label_de', label: 'Label DE' },
{ key: 'label_en', label: 'Label EN' },
]}
/>
</div>
</div>
)
case 'products':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="name"
subtitleKey="hardware"
addLabel="Add product"
renderCard={(item, update) => (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
<FormField label="Hardware" value={item.hardware as string || ''} onChange={v => update('hardware', v)} />
</div>
<div className="grid grid-cols-3 gap-4">
<FormField label="HW Cost (EUR)" value={item.hardware_cost_eur as number || 0} onChange={v => update('hardware_cost_eur', v)} type="number" />
<FormField label="Monthly Price (EUR)" value={item.monthly_price_eur as number || 0} onChange={v => update('monthly_price_eur', v)} type="number" />
<FormField label="Operating Cost (EUR)" value={item.operating_cost_eur as number || 0} onChange={v => update('operating_cost_eur', v)} type="number" />
</div>
<div className="grid grid-cols-2 gap-4">
<FormField label="LLM Model" value={item.llm_model as string || ''} onChange={v => update('llm_model', v)} />
<FormField label="LLM Size" value={item.llm_size as string || ''} onChange={v => update('llm_size', v)} />
</div>
<BilingualField label="LLM Capability" valueDe={item.llm_capability_de as string || ''} valueEn={item.llm_capability_en as string || ''} onChangeDe={v => update('llm_capability_de', v)} onChangeEn={v => update('llm_capability_en', v)} multiline />
<div className="grid grid-cols-2 gap-4">
<ArrayField label="Features (DE)" values={(item.features_de as string[]) || []} onChange={v => update('features_de', v)} />
<ArrayField label="Features (EN)" values={(item.features_en as string[]) || []} onChange={v => update('features_en', v)} />
</div>
<FormField label="Popular" value={!!item.is_popular} onChange={v => update('is_popular', v)} type="checkbox" />
</div>
)}
/>
</div>
)
case 'fm_scenarios':
return (
<div className="p-4">
<CardList
items={data as R[]}
onChange={updateData}
titleKey="name"
subtitleKey="description"
addLabel="Add scenario"
renderCard={(item, update) => (
<div className="space-y-3">
<FormField label="Name" value={item.name as string || ''} onChange={v => update('name', v)} />
<FormField label="Description" value={item.description as string || ''} onChange={v => update('description', v)} />
<div className="grid grid-cols-2 gap-4">
<FormField label="Color" value={item.color as string || '#6366f1'} onChange={v => update('color', v)} type="color" />
<FormField label="Default" value={!!item.is_default} onChange={v => update('is_default', v)} type="checkbox" />
</div>
</div>
)}
/>
</div>
)
case 'fm_assumptions':
// Reuse the inline table approach from the FM editor (already works well for this)
return (
<div className="p-4">
<RowTable
rows={data as R[]}
onChange={updateData}
columns={[
{ key: 'key', label: 'Key' },
{ key: 'label_de', label: 'Label DE' },
{ key: 'label_en', label: 'Label EN' },
{ key: 'category', label: 'Category' },
{ key: 'unit', label: 'Unit' },
]}
addLabel="Add assumption"
/>
<p className="text-[10px] text-white/30 mt-2">Note: values, min/max/step are best edited via "Edit as JSON" mode for complex types.</p>
</div>
)
default:
return <div className="p-4 text-white/40">No editor for this table</div>
}
}
return (
<div className="space-y-6">
<Link href="/pitch-admin/versions" className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80">
<ArrowLeft className="w-4 h-4" /> Back to versions
</Link>
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-2 mb-1">
<h1 className="text-2xl font-semibold text-white">{version.name}</h1>
<span className={`text-[9px] px-2 py-0.5 rounded-full border uppercase font-semibold ${
isDraft ? 'bg-amber-500/15 text-amber-300 border-amber-500/30' : 'bg-green-500/15 text-green-300 border-green-500/30'
}`}>{version.status}</span>
</div>
{version.description && <p className="text-sm text-white/50">{version.description}</p>}
</div>
<div className="flex items-center gap-2">
<Link
href={`/pitch-preview/${id}`}
target="_blank"
className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg flex items-center gap-2"
>
<Eye className="w-4 h-4" /> Preview
</Link>
{isDraft && (
<button onClick={commitVersion} className="bg-green-500/15 hover:bg-green-500/25 text-green-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2">
<Lock className="w-4 h-4" /> Commit
</button>
)}
<button onClick={forkVersion} className="bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2">
<GitFork className="w-4 h-4" /> Fork
</button>
{version.parent_id && (
<Link href={`/pitch-admin/versions/${id}/diff/${version.parent_id}`} className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg">
Diff
</Link>
)}
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 overflow-x-auto pb-1">
{TABLE_NAMES.map(t => (
<button
key={t}
onClick={() => { if (dirty && !confirm('Discard unsaved changes?')) return; setActiveTab(t); setDirty(false); setJsonMode(false) }}
className={`px-3 py-1.5 rounded-lg text-xs whitespace-nowrap transition-colors ${
activeTab === t
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
: 'bg-white/[0.04] text-white/40 border border-transparent hover:text-white/60'
}`}
>
{TABLE_LABELS[t]}
</button>
))}
</div>
{/* Editor */}
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-white">{TABLE_LABELS[activeTab]}</span>
{dirty && <span className="text-[9px] px-2 py-0.5 rounded bg-amber-500/20 text-amber-300">Unsaved</span>}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setJsonMode(!jsonMode)}
className={`text-[10px] px-2 py-1 rounded flex items-center gap-1 transition-colors ${
jsonMode ? 'bg-indigo-500/20 text-indigo-300' : 'bg-white/[0.04] text-white/40 hover:text-white/60'
}`}
>
<Code className="w-3 h-3" /> {jsonMode ? 'Form' : 'JSON'}
</button>
{isDraft && (
<button
onClick={saveTable}
disabled={saving || !dirty}
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-1.5 rounded-lg flex items-center gap-2 disabled:opacity-30"
>
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'}
</button>
)}
</div>
</div>
{renderEditor()}
</div>
{!isDraft && <p className="text-xs text-white/30 text-center">Committed read-only. Fork to edit.</p>}
{toast && (
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm z-50">
{toast}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,120 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
interface VersionOption {
id: string
name: string
status: string
}
export default function NewVersionPage() {
const router = useRouter()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [parentId, setParentId] = useState<string>('')
const [versions, setVersions] = useState<VersionOption[]>([])
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
fetch('/api/admin/versions')
.then(r => r.json())
.then(d => setVersions(d.versions || []))
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setSubmitting(true)
const res = await fetch('/api/admin/versions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
description: description || undefined,
parent_id: parentId || undefined,
}),
})
setSubmitting(false)
if (res.ok) {
const d = await res.json()
router.push(`/pitch-admin/versions/${d.version.id}`)
} else {
const d = await res.json().catch(() => ({}))
setError(d.error || 'Creation failed')
}
}
return (
<div className="max-w-xl">
<Link href="/pitch-admin/versions" className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80 mb-6">
<ArrowLeft className="w-4 h-4" /> Back to versions
</Link>
<h1 className="text-2xl font-semibold text-white mb-2">Create Version</h1>
<p className="text-sm text-white/50 mb-6">
A new draft will be created with a full copy of all pitch data.
Choose a parent to fork from, or leave empty to snapshot the current base tables.
</p>
<form onSubmit={handleSubmit} className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-6 space-y-4">
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
Name <span className="text-rose-400">*</span>
</label>
<input
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="e.g. Conservative Q4, Series A Ready"
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
/>
</div>
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
placeholder="Optional notes about this version"
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 resize-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Fork from</label>
<select
value={parentId}
onChange={e => setParentId(e.target.value)}
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
>
<option value="">Base tables (current pitch data)</option>
{versions.map(v => (
<option key={v.id} value={v.id}>{v.name} ({v.status})</option>
))}
</select>
</div>
{error && (
<div className="text-sm text-rose-300 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2">{error}</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Link href="/pitch-admin/versions" className="text-sm text-white/60 hover:text-white px-4 py-2">Cancel</Link>
<button
type="submit"
disabled={submitting}
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-5 py-2.5 rounded-lg disabled:opacity-50 shadow-lg shadow-indigo-500/20"
>
{submitting ? 'Creating…' : 'Create draft'}
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,198 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { GitBranch, Plus, Lock, Pencil, Trash2, GitFork, Users } from 'lucide-react'
interface Version {
id: string
name: string
description: string | null
parent_id: string | null
status: 'draft' | 'committed'
created_by_name: string | null
created_by_email: string | null
committed_at: string | null
created_at: string
assigned_count: number
}
export default function VersionsPage() {
const [versions, setVersions] = useState<Version[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null)
const [toast, setToast] = useState<string | null>(null)
function flashToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) }
async function load() {
setLoading(true)
const res = await fetch('/api/admin/versions')
if (res.ok) { const d = await res.json(); setVersions(d.versions) }
setLoading(false)
}
useEffect(() => { load() }, [])
async function commitVersion(id: string) {
if (!confirm('Commit this version? It becomes immutable and available for investor assignment.')) return
setBusy(id)
const res = await fetch(`/api/admin/versions/${id}/commit`, { method: 'POST' })
setBusy(null)
if (res.ok) { flashToast('Committed'); load() }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
async function forkVersion(id: string) {
const name = prompt('Name for the new draft:')
if (!name) return
setBusy(id)
const res = await fetch(`/api/admin/versions/${id}/fork`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
setBusy(null)
if (res.ok) { flashToast('Forked'); load() }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
async function deleteVersion(id: string, name: string) {
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return
setBusy(id)
const res = await fetch(`/api/admin/versions/${id}`, { method: 'DELETE' })
setBusy(null)
if (res.ok) { flashToast('Deleted'); load() }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">Pitch Versions</h1>
<p className="text-sm text-white/50 mt-1">
{versions.length} version{versions.length !== 1 ? 's' : ''} each is a complete snapshot of all pitch data
</p>
</div>
<Link
href="/pitch-admin/versions/new"
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-4 py-2 rounded-lg shadow-lg shadow-indigo-500/20 flex items-center gap-2"
>
<Plus className="w-4 h-4" /> New Version
</Link>
</div>
{loading ? (
<div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
) : versions.length === 0 ? (
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-12 text-center">
<GitBranch className="w-12 h-12 text-white/20 mx-auto mb-4" />
<p className="text-white/60 mb-4">No versions yet. Create your first version to snapshot the current pitch data.</p>
<Link
href="/pitch-admin/versions/new"
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-2 rounded-lg inline-flex items-center gap-2"
>
<Plus className="w-4 h-4" /> Create First Version
</Link>
</div>
) : (
<div className="space-y-3">
{versions.map(v => {
const parent = v.parent_id ? versions.find(p => p.id === v.parent_id) : null
return (
<div key={v.id} className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5 hover:border-white/[0.12] transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<Link href={`/pitch-admin/versions/${v.id}`} className="text-base font-semibold text-white hover:text-indigo-300">
{v.name}
</Link>
<span className={`text-[9px] px-2 py-0.5 rounded-full border uppercase font-semibold ${
v.status === 'committed'
? 'bg-green-500/15 text-green-300 border-green-500/30'
: 'bg-amber-500/15 text-amber-300 border-amber-500/30'
}`}>
{v.status}
</span>
{v.assigned_count > 0 && (
<span className="text-[9px] px-2 py-0.5 rounded-full bg-indigo-500/15 text-indigo-300 border border-indigo-500/30 flex items-center gap-1">
<Users className="w-3 h-3" /> {v.assigned_count}
</span>
)}
</div>
{v.description && <p className="text-sm text-white/50 mb-1">{v.description}</p>}
<div className="flex items-center gap-3 text-xs text-white/40">
<span>by {v.created_by_name || v.created_by_email || 'system'}</span>
<span>{new Date(v.created_at).toLocaleDateString()}</span>
{parent && (
<span className="flex items-center gap-1">
<GitBranch className="w-3 h-3" /> from {parent.name}
</span>
)}
{v.committed_at && <span>committed {new Date(v.committed_at).toLocaleDateString()}</span>}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Link
href={`/pitch-admin/versions/${v.id}`}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-white/[0.06] hover:text-white"
title="Edit"
>
<Pencil className="w-4 h-4" />
</Link>
{v.status === 'draft' && (
<button
onClick={() => commitVersion(v.id)}
disabled={busy === v.id}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-green-500/15 hover:text-green-300 disabled:opacity-30"
title="Commit"
>
<Lock className="w-4 h-4" />
</button>
)}
<button
onClick={() => forkVersion(v.id)}
disabled={busy === v.id}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-indigo-500/15 hover:text-indigo-300 disabled:opacity-30"
title="Fork"
>
<GitFork className="w-4 h-4" />
</button>
<button
onClick={() => deleteVersion(v.id, v.name)}
disabled={busy === v.id}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-rose-500/15 hover:text-rose-300 disabled:opacity-30"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Quick diff link if has parent */}
{v.parent_id && (
<div className="mt-3 pt-3 border-t border-white/[0.04]">
<Link
href={`/pitch-admin/versions/${v.id}/diff/${v.parent_id}`}
className="text-xs text-indigo-400 hover:text-indigo-300"
>
Compare with parent
</Link>
</div>
)}
</div>
)
})}
</div>
)}
{toast && (
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm z-50">
{toast}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,72 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useParams } from 'next/navigation'
import { Language, PitchData } from '@/lib/types'
import PitchDeck from '@/components/PitchDeck'
export default function PreviewPage() {
const { versionId } = useParams<{ versionId: string }>()
const [data, setData] = useState<PitchData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lang, setLang] = useState<Language>('de')
const toggleLanguage = useCallback(() => {
setLang(prev => prev === 'de' ? 'en' : 'de')
}, [])
useEffect(() => {
if (!versionId) return
setLoading(true)
fetch(`/api/preview-data/${versionId}`)
.then(async r => {
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'Failed to load')
return r.json()
})
.then(setData)
.catch(e => setError(e.message))
.finally(() => setLoading(false))
}, [versionId])
if (loading) {
return (
<div className="h-screen flex items-center justify-center bg-[#0a0a1a]">
<div className="text-center">
<div className="w-12 h-12 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-white/40 text-sm">Loading preview...</p>
</div>
</div>
)
}
if (error || !data) {
return (
<div className="h-screen flex items-center justify-center bg-[#0a0a1a]">
<div className="text-center max-w-md px-6">
<p className="text-rose-400 mb-2">Preview Error</p>
<p className="text-white/40 text-sm">{error || 'No data found for this version'}</p>
<p className="text-white/30 text-xs mt-4">Make sure you are logged in as an admin.</p>
</div>
</div>
)
}
// Render PitchDeck with no investor (no watermark, no audit) — admin preview only
// The banner at the top indicates this is a preview
return (
<div className="relative">
{/* Preview banner */}
<div className="fixed top-0 left-0 right-0 z-[100] bg-amber-500/90 text-black text-center py-1.5 text-xs font-semibold">
PREVIEW MODE This is how investors will see this version
</div>
<PitchDeck
lang={lang}
onToggleLanguage={toggleLanguage}
investor={null}
onLogout={() => {}}
previewData={data}
/>
</div>
)
}

View File

@@ -47,10 +47,14 @@ interface PitchDeckProps {
onToggleLanguage: () => void
investor: Investor | null
onLogout: () => void
previewData?: PitchData | null
}
export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout }: PitchDeckProps) {
const { data, loading, error } = usePitchData()
export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout, previewData }: PitchDeckProps) {
const fetched = usePitchData()
const data = previewData || fetched.data
const loading = previewData ? false : fetched.loading
const error = previewData ? null : fetched.error
const nav = useSlideNavigation()
const [fabOpen, setFabOpen] = useState(false)

View File

@@ -9,6 +9,7 @@ import {
FileText,
TrendingUp,
ShieldCheck,
GitBranch,
LogOut,
Menu,
X,
@@ -22,6 +23,7 @@ interface AdminShellProps {
const NAV = [
{ href: '/pitch-admin', label: 'Dashboard', icon: LayoutDashboard, exact: true },
{ href: '/pitch-admin/investors', label: 'Investors', icon: Users },
{ href: '/pitch-admin/versions', label: 'Versions', icon: GitBranch },
{ href: '/pitch-admin/audit', label: 'Audit Log', icon: FileText },
{ href: '/pitch-admin/financial-model', label: 'Financial Model', icon: TrendingUp },
{ href: '/pitch-admin/admins', label: 'Admins', icon: ShieldCheck },
@@ -43,7 +45,7 @@ export default function AdminShell({ admin, children }: AdminShellProps) {
}
return (
<div className="min-h-screen bg-[#0a0a1a] text-white flex">
<div className="h-screen bg-[#0a0a1a] text-white flex overflow-hidden">
{/* Sidebar */}
<aside
className={`fixed lg:static inset-y-0 left-0 z-40 w-64 bg-black/40 backdrop-blur-xl border-r border-white/[0.06]
@@ -111,7 +113,7 @@ export default function AdminShell({ admin, children }: AdminShellProps) {
)}
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 flex flex-col min-w-0 min-h-0">
<header className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
<button
onClick={() => setOpen(true)}

View File

@@ -0,0 +1,56 @@
'use client'
import { useState } from 'react'
import { X, Plus } from 'lucide-react'
interface ArrayFieldProps {
label: string
values: string[]
onChange: (v: string[]) => void
placeholder?: string
}
export default function ArrayField({ label, values, onChange, placeholder }: ArrayFieldProps) {
const [input, setInput] = useState('')
function add() {
const v = input.trim()
if (v && !values.includes(v)) {
onChange([...values, v])
setInput('')
}
}
function remove(idx: number) {
onChange(values.filter((_, i) => i !== idx))
}
return (
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{values.map((v, i) => (
<span key={i} className="inline-flex items-center gap-1 bg-indigo-500/15 text-indigo-300 text-xs px-2 py-1 rounded-lg border border-indigo-500/20">
{v}
<button onClick={() => remove(i)} className="hover:text-rose-300">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); add() } }}
placeholder={placeholder || 'Type and press Enter'}
className="flex-1 bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20"
/>
<button onClick={add} className="bg-white/[0.06] hover:bg-white/[0.1] text-white/60 p-1.5 rounded-lg">
<Plus className="w-4 h-4" />
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
interface BilingualFieldProps {
label: string
valueDe: string
valueEn: string
onChangeDe: (v: string) => void
onChangeEn: (v: string) => void
multiline?: boolean
placeholder?: string
}
export default function BilingualField({
label, valueDe, valueEn, onChangeDe, onChangeEn, multiline, placeholder,
}: BilingualFieldProps) {
const inputClass = 'w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20'
return (
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1.5 mb-1">
<span className="text-[10px] text-white/40 font-semibold">DE</span>
</div>
{multiline ? (
<textarea
value={valueDe || ''}
onChange={e => onChangeDe(e.target.value)}
rows={3}
placeholder={placeholder}
className={`${inputClass} resize-none`}
/>
) : (
<input
type="text"
value={valueDe || ''}
onChange={e => onChangeDe(e.target.value)}
placeholder={placeholder}
className={inputClass}
/>
)}
</div>
<div>
<div className="flex items-center gap-1.5 mb-1">
<span className="text-[10px] text-white/40 font-semibold">EN</span>
</div>
{multiline ? (
<textarea
value={valueEn || ''}
onChange={e => onChangeEn(e.target.value)}
rows={3}
placeholder={placeholder}
className={`${inputClass} resize-none`}
/>
) : (
<input
type="text"
value={valueEn || ''}
onChange={e => onChangeEn(e.target.value)}
placeholder={placeholder}
className={inputClass}
/>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,115 @@
'use client'
import { useState } from 'react'
import { ChevronDown, ChevronRight, Plus, Trash2, GripVertical } from 'lucide-react'
interface CardListProps {
items: Record<string, unknown>[]
onChange: (items: Record<string, unknown>[]) => void
titleKey: string
subtitleKey?: string
renderCard: (item: Record<string, unknown>, update: (key: string, value: unknown) => void) => React.ReactNode
newItemTemplate?: Record<string, unknown>
addLabel?: string
}
export default function CardList({
items, onChange, titleKey, subtitleKey, renderCard, newItemTemplate, addLabel,
}: CardListProps) {
const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
function updateItem(idx: number, key: string, value: unknown) {
onChange(items.map((item, i) => i === idx ? { ...item, [key]: value } : item))
}
function addItem() {
const newItem = newItemTemplate || (() => {
const template: Record<string, unknown> = {}
if (items.length > 0) {
Object.keys(items[0]).forEach(k => {
const sample = items[0][k]
template[k] = Array.isArray(sample) ? [] : typeof sample === 'number' ? 0 : typeof sample === 'boolean' ? false : ''
})
}
if ('sort_order' in template) template.sort_order = items.length
return template
})()
onChange([...items, newItem])
setExpandedIdx(items.length)
}
function removeItem(idx: number) {
if (!confirm('Remove this item?')) return
onChange(items.filter((_, i) => i !== idx))
if (expandedIdx === idx) setExpandedIdx(null)
}
function moveUp(idx: number) {
if (idx === 0) return
const copy = [...items]
;[copy[idx - 1], copy[idx]] = [copy[idx], copy[idx - 1]]
onChange(copy)
setExpandedIdx(idx - 1)
}
function moveDown(idx: number) {
if (idx >= items.length - 1) return
const copy = [...items]
;[copy[idx], copy[idx + 1]] = [copy[idx + 1], copy[idx]]
onChange(copy)
setExpandedIdx(idx + 1)
}
return (
<div className="space-y-2">
{items.map((item, idx) => {
const isExpanded = expandedIdx === idx
const title = String(item[titleKey] || `Item ${idx + 1}`)
const subtitle = subtitleKey ? String(item[subtitleKey] || '') : ''
return (
<div key={idx} className="border border-white/[0.06] rounded-xl overflow-hidden">
<button
onClick={() => setExpandedIdx(isExpanded ? null : idx)}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-white/[0.02] text-left"
>
<div className="flex items-center gap-1 text-white/30">
<button
onClick={e => { e.stopPropagation(); moveUp(idx) }}
className="hover:text-white/60 p-0.5"
title="Move up"
>
<GripVertical className="w-3 h-3" />
</button>
</div>
{isExpanded ? <ChevronDown className="w-4 h-4 text-white/40" /> : <ChevronRight className="w-4 h-4 text-white/40" />}
<div className="flex-1 min-w-0">
<span className="text-sm text-white/90 font-medium truncate block">{title}</span>
{subtitle && <span className="text-xs text-white/40 truncate block">{subtitle}</span>}
</div>
<span className="text-[9px] text-white/30 font-mono">#{idx + 1}</span>
<button
onClick={e => { e.stopPropagation(); removeItem(idx) }}
className="text-white/30 hover:text-rose-400 p-1"
title="Remove"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</button>
{isExpanded && (
<div className="px-4 pb-4 pt-1 border-t border-white/[0.04] space-y-4">
{renderCard(item, (key, value) => updateItem(idx, key, value))}
</div>
)}
</div>
)
})}
<button
onClick={addItem}
className="w-full flex items-center justify-center gap-2 py-2.5 text-xs text-white/50 hover:text-white border border-dashed border-white/[0.1] hover:border-white/[0.2] rounded-xl transition-colors"
>
<Plus className="w-3.5 h-3.5" /> {addLabel || 'Add item'}
</button>
</div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
interface FormFieldProps {
label: string
value: string | number | boolean
onChange: (v: string | number | boolean) => void
type?: 'text' | 'number' | 'date' | 'url' | 'checkbox' | 'select' | 'color'
placeholder?: string
options?: { value: string; label: string }[]
hint?: string
}
export default function FormField({
label, value, onChange, type = 'text', placeholder, options, hint,
}: FormFieldProps) {
const inputClass = 'w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 placeholder:text-white/20'
return (
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">{label}</label>
{type === 'checkbox' ? (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={!!value}
onChange={e => onChange(e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-black/30 text-indigo-500 focus:ring-indigo-500/40"
/>
<span className="text-sm text-white/70">{placeholder || label}</span>
</label>
) : type === 'select' && options ? (
<select
value={String(value)}
onChange={e => onChange(e.target.value)}
className={inputClass}
>
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
) : type === 'color' ? (
<div className="flex items-center gap-2">
<input
type="color"
value={String(value) || '#6366f1'}
onChange={e => onChange(e.target.value)}
className="w-10 h-10 rounded-lg border border-white/10 cursor-pointer bg-transparent"
/>
<input
type="text"
value={String(value)}
onChange={e => onChange(e.target.value)}
className={`${inputClass} flex-1`}
placeholder="#6366f1"
/>
</div>
) : (
<input
type={type}
value={value as string | number}
onChange={e => onChange(type === 'number' ? Number(e.target.value) || 0 : e.target.value)}
placeholder={placeholder}
className={inputClass}
/>
)}
{hint && <p className="text-[10px] text-white/30 mt-1">{hint}</p>}
</div>
)
}

View File

@@ -0,0 +1,92 @@
'use client'
import { Plus, Trash2 } from 'lucide-react'
interface RowTableProps {
rows: Record<string, unknown>[]
onChange: (rows: Record<string, unknown>[]) => void
columns?: { key: string; label: string; type?: 'text' | 'number' }[]
addLabel?: string
}
export default function RowTable({ rows, onChange, columns, addLabel }: RowTableProps) {
// Auto-detect columns from first row if not provided
const cols = columns || (rows.length > 0
? Object.keys(rows[0]).filter(k => k !== 'id' && k !== 'sort_order').map(k => ({
key: k,
label: k.replace(/_/g, ' '),
type: (typeof rows[0][k] === 'number' ? 'number' : 'text') as 'text' | 'number',
}))
: [])
function updateCell(rowIdx: number, key: string, value: string) {
const col = cols.find(c => c.key === key)
const parsedValue = col?.type === 'number' ? (Number(value) || 0) : value
onChange(rows.map((r, i) => i === rowIdx ? { ...r, [key]: parsedValue } : r))
}
function addRow() {
const newRow: Record<string, unknown> = {}
cols.forEach(c => { newRow[c.key] = c.type === 'number' ? 0 : '' })
// Carry over id-like fields
if (rows.length > 0 && 'id' in rows[0]) {
newRow.id = (rows.length + 1)
}
if (rows.length > 0 && 'sort_order' in rows[0]) {
newRow.sort_order = rows.length
}
onChange([...rows, newRow])
}
function removeRow(idx: number) {
onChange(rows.filter((_, i) => i !== idx))
}
if (cols.length === 0) return <div className="text-white/40 text-sm">No columns detected</div>
return (
<div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.08]">
{cols.map(c => (
<th key={c.key} className="text-left py-2 px-2 text-[10px] text-white/40 font-medium uppercase tracking-wider whitespace-nowrap">
{c.label}
</th>
))}
<th className="w-8" />
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
{cols.map(c => (
<td key={c.key} className="py-1 px-2">
<input
type={c.type || 'text'}
value={(row[c.key] as string | number) ?? ''}
onChange={e => updateCell(ri, c.key, e.target.value)}
className="w-full bg-transparent border-b border-transparent hover:border-white/10 focus:border-indigo-500/50 text-white font-mono text-xs py-1 focus:outline-none min-w-[60px]"
/>
</td>
))}
<td className="py-1 px-1">
<button onClick={() => removeRow(ri)} className="text-white/30 hover:text-rose-400 p-1" title="Remove">
<Trash2 className="w-3 h-3" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={addRow}
className="mt-2 text-xs text-white/50 hover:text-white flex items-center gap-1 px-2 py-1 rounded hover:bg-white/[0.04]"
>
<Plus className="w-3 h-3" /> {addLabel || 'Add row'}
</button>
</div>
)
}

View File

@@ -0,0 +1,102 @@
export interface FieldDiff {
key: string
before: unknown
after: unknown
}
export interface RowDiff {
status: 'added' | 'removed' | 'changed' | 'unchanged'
id?: string | number
fields: FieldDiff[]
before?: Record<string, unknown>
after?: Record<string, unknown>
}
export interface TableDiff {
tableName: string
rows: RowDiff[]
hasChanges: boolean
}
/**
* Diff two arrays of row objects. Matches rows by `id` field if present,
* otherwise by array position.
*/
export function diffTable(
tableName: string,
before: unknown[],
after: unknown[],
): TableDiff {
const beforeArr = (before || []) as Record<string, unknown>[]
const afterArr = (after || []) as Record<string, unknown>[]
const rows: RowDiff[] = []
// Build lookup by id if available
const hasIds = beforeArr.length > 0 && 'id' in (beforeArr[0] || {})
if (hasIds) {
const beforeMap = new Map(beforeArr.map(r => [String(r.id), r]))
const afterMap = new Map(afterArr.map(r => [String(r.id), r]))
const allIds = new Set([...beforeMap.keys(), ...afterMap.keys()])
for (const id of allIds) {
const b = beforeMap.get(id)
const a = afterMap.get(id)
if (!b && a) {
rows.push({ status: 'added', id: a.id as string, fields: [], after: a })
} else if (b && !a) {
rows.push({ status: 'removed', id: b.id as string, fields: [], before: b })
} else if (b && a) {
const fields = diffFields(b, a)
rows.push({
status: fields.length > 0 ? 'changed' : 'unchanged',
id: b.id as string,
fields,
before: b,
after: a,
})
}
}
} else {
// Positional comparison
const maxLen = Math.max(beforeArr.length, afterArr.length)
for (let i = 0; i < maxLen; i++) {
const b = beforeArr[i]
const a = afterArr[i]
if (!b && a) {
rows.push({ status: 'added', fields: [], after: a })
} else if (b && !a) {
rows.push({ status: 'removed', fields: [], before: b })
} else if (b && a) {
const fields = diffFields(b, a)
rows.push({
status: fields.length > 0 ? 'changed' : 'unchanged',
fields,
before: b,
after: a,
})
}
}
}
return {
tableName,
rows,
hasChanges: rows.some(r => r.status !== 'unchanged'),
}
}
function diffFields(before: Record<string, unknown>, after: Record<string, unknown>): FieldDiff[] {
const diffs: FieldDiff[] = []
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)])
for (const key of allKeys) {
const bVal = JSON.stringify(before[key] ?? null)
const aVal = JSON.stringify(after[key] ?? null)
if (bVal !== aVal) {
diffs.push({ key, before: before[key], after: after[key] })
}
}
return diffs
}

View File

@@ -0,0 +1,76 @@
import pool from './db'
/**
* The 12 data tables tracked per version.
* Each maps to a pitch_version_data.table_name value.
*/
export const VERSION_TABLES = [
'company', 'team', 'financials', 'market', 'competitors',
'features', 'milestones', 'metrics', 'funding', 'products',
'fm_scenarios', 'fm_assumptions',
] as const
export type VersionTableName = typeof VERSION_TABLES[number]
/** Maps version table names to the actual DB table + ORDER BY */
const TABLE_QUERIES: Record<VersionTableName, string> = {
company: 'SELECT * FROM pitch_company LIMIT 1',
team: 'SELECT * FROM pitch_team ORDER BY sort_order',
financials: 'SELECT * FROM pitch_financials ORDER BY year',
market: 'SELECT * FROM pitch_market ORDER BY id',
competitors: 'SELECT * FROM pitch_competitors ORDER BY id',
features: 'SELECT * FROM pitch_features ORDER BY sort_order',
milestones: 'SELECT * FROM pitch_milestones ORDER BY sort_order',
metrics: 'SELECT * FROM pitch_metrics ORDER BY id',
funding: 'SELECT * FROM pitch_funding LIMIT 1',
products: 'SELECT * FROM pitch_products ORDER BY sort_order',
fm_scenarios: 'SELECT * FROM pitch_fm_scenarios ORDER BY is_default DESC, name',
fm_assumptions: 'SELECT * FROM pitch_fm_assumptions ORDER BY sort_order',
}
/**
* Snapshot all base tables into pitch_version_data for a given version.
*/
export async function snapshotBaseTables(versionId: string, adminId: string | null): Promise<void> {
const client = await pool.connect()
try {
for (const tableName of VERSION_TABLES) {
const { rows } = await client.query(TABLE_QUERIES[tableName])
await client.query(
`INSERT INTO pitch_version_data (version_id, table_name, data, updated_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (version_id, table_name) DO UPDATE SET data = $3, updated_at = NOW(), updated_by = $4`,
[versionId, tableName, JSON.stringify(rows), adminId],
)
}
} finally {
client.release()
}
}
/**
* Copy all version data from one version to another.
*/
export async function copyVersionData(fromVersionId: string, toVersionId: string, adminId: string | null): Promise<void> {
await pool.query(
`INSERT INTO pitch_version_data (version_id, table_name, data, updated_by)
SELECT $1, table_name, data, $3
FROM pitch_version_data WHERE version_id = $2`,
[toVersionId, fromVersionId, adminId],
)
}
/**
* Load all version data as a map of table_name → JSONB rows.
*/
export async function loadVersionData(versionId: string): Promise<Record<string, unknown[]>> {
const { rows } = await pool.query(
`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`,
[versionId],
)
const result: Record<string, unknown[]> = {}
for (const row of rows) {
result[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data
}
return result
}

View File

@@ -16,7 +16,7 @@ const PUBLIC_PATHS = [
]
// Paths gated on the admin session cookie
const ADMIN_GATED_PREFIXES = ['/pitch-admin', '/api/admin']
const ADMIN_GATED_PREFIXES = ['/pitch-admin', '/api/admin', '/pitch-preview', '/api/preview-data']
function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some(p => pathname === p || pathname.startsWith(p + '/'))

View File

@@ -0,0 +1,191 @@
-- =========================================================
-- Pitch Deck: Core data tables + Financial Model
-- Run BEFORE 001_investor_auth.sql
-- =========================================================
-- Company info
CREATE TABLE IF NOT EXISTS pitch_company (
id SERIAL PRIMARY KEY,
name TEXT,
legal_form TEXT,
founding_date TEXT,
tagline_de TEXT,
tagline_en TEXT,
mission_de TEXT,
mission_en TEXT,
website TEXT,
hq_city TEXT
);
-- Team members
CREATE TABLE IF NOT EXISTS pitch_team (
id SERIAL PRIMARY KEY,
name TEXT,
role_de TEXT,
role_en TEXT,
bio_de TEXT,
bio_en TEXT,
equity_pct NUMERIC,
expertise TEXT[],
linkedin_url TEXT,
photo_url TEXT,
sort_order INT DEFAULT 0
);
-- Historical financials
CREATE TABLE IF NOT EXISTS pitch_financials (
id SERIAL PRIMARY KEY,
year INT,
revenue_eur BIGINT,
costs_eur BIGINT,
mrr_eur BIGINT,
burn_rate_eur BIGINT,
customers_count INT,
employees_count INT,
arr_eur BIGINT
);
-- Market segments (TAM/SAM/SOM)
CREATE TABLE IF NOT EXISTS pitch_market (
id SERIAL PRIMARY KEY,
market_segment TEXT,
label TEXT,
value_eur BIGINT,
growth_rate_pct NUMERIC,
source TEXT
);
-- Competitors
CREATE TABLE IF NOT EXISTS pitch_competitors (
id SERIAL PRIMARY KEY,
name TEXT,
customers_count INT,
pricing_range TEXT,
strengths TEXT[],
weaknesses TEXT[],
website TEXT
);
-- Feature comparison matrix
CREATE TABLE IF NOT EXISTS pitch_features (
id SERIAL PRIMARY KEY,
feature_name_de TEXT,
feature_name_en TEXT,
category TEXT,
breakpilot BOOLEAN,
proliance BOOLEAN,
dataguard BOOLEAN,
heydata BOOLEAN,
is_differentiator BOOLEAN,
sort_order INT DEFAULT 0
);
-- Milestones / timeline
CREATE TABLE IF NOT EXISTS pitch_milestones (
id SERIAL PRIMARY KEY,
milestone_date TEXT,
title_de TEXT,
title_en TEXT,
description_de TEXT,
description_en TEXT,
status TEXT,
category TEXT,
sort_order INT DEFAULT 0
);
-- Key metrics
CREATE TABLE IF NOT EXISTS pitch_metrics (
id SERIAL PRIMARY KEY,
metric_name TEXT,
label_de TEXT,
label_en TEXT,
value TEXT,
unit TEXT,
is_live BOOLEAN
);
-- Funding round
CREATE TABLE IF NOT EXISTS pitch_funding (
id SERIAL PRIMARY KEY,
round_name TEXT,
amount_eur BIGINT,
use_of_funds JSONB,
instrument TEXT,
target_date TEXT,
status TEXT
);
-- Products / tiers
CREATE TABLE IF NOT EXISTS pitch_products (
id SERIAL PRIMARY KEY,
name TEXT,
hardware TEXT,
hardware_cost_eur NUMERIC,
monthly_price_eur NUMERIC,
llm_model TEXT,
llm_size TEXT,
llm_capability_de TEXT,
llm_capability_en TEXT,
features_de TEXT[],
features_en TEXT[],
is_popular BOOLEAN,
operating_cost_eur NUMERIC,
sort_order INT DEFAULT 0
);
-- =========================================================
-- Financial Model
-- =========================================================
CREATE TABLE IF NOT EXISTS pitch_fm_scenarios (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT,
description TEXT,
is_default BOOLEAN DEFAULT false,
color TEXT DEFAULT '#6366f1',
sort_order INT DEFAULT 0
);
CREATE TABLE IF NOT EXISTS pitch_fm_assumptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_id UUID REFERENCES pitch_fm_scenarios(id) ON DELETE CASCADE,
key TEXT,
label_de TEXT,
label_en TEXT,
value JSONB,
value_type TEXT DEFAULT 'scalar',
unit TEXT,
min_value NUMERIC,
max_value NUMERIC,
step_size NUMERIC,
category TEXT,
sort_order INT DEFAULT 0
);
CREATE TABLE IF NOT EXISTS pitch_fm_results (
id SERIAL PRIMARY KEY,
scenario_id UUID REFERENCES pitch_fm_scenarios(id) ON DELETE CASCADE,
month INT,
year INT,
month_in_year INT,
new_customers INT,
churned_customers INT,
total_customers INT,
mrr_eur NUMERIC,
arr_eur NUMERIC,
revenue_eur NUMERIC,
cogs_eur NUMERIC,
personnel_eur NUMERIC,
infra_eur NUMERIC,
marketing_eur NUMERIC,
total_costs_eur NUMERIC,
employees_count INT,
gross_margin_pct NUMERIC,
burn_rate_eur NUMERIC,
runway_months NUMERIC,
cac_eur NUMERIC,
ltv_eur NUMERIC,
ltv_cac_ratio NUMERIC,
cash_balance_eur NUMERIC,
cumulative_revenue_eur NUMERIC
);

View File

@@ -0,0 +1,36 @@
-- =========================================================
-- Pitch Deck: Version Management (Git-Style History)
-- =========================================================
-- Version metadata: each version points to its parent (git-style DAG)
CREATE TABLE IF NOT EXISTS pitch_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
parent_id UUID REFERENCES pitch_versions(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'committed')),
created_by UUID REFERENCES pitch_admins(id) ON DELETE SET NULL,
committed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pitch_versions_parent ON pitch_versions(parent_id);
CREATE INDEX IF NOT EXISTS idx_pitch_versions_status ON pitch_versions(status);
-- Version content: one row per data table per version (fully materialized)
-- table_name values: company, team, financials, market, competitors, features,
-- milestones, metrics, funding, products, fm_scenarios, fm_assumptions
CREATE TABLE IF NOT EXISTS pitch_version_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
version_id UUID NOT NULL REFERENCES pitch_versions(id) ON DELETE CASCADE,
table_name TEXT NOT NULL,
data JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID REFERENCES pitch_admins(id) ON DELETE SET NULL,
UNIQUE(version_id, table_name)
);
CREATE INDEX IF NOT EXISTS idx_pitch_version_data_version ON pitch_version_data(version_id);
-- Per-investor version assignment (NULL = use base tables)
ALTER TABLE pitch_investors
ADD COLUMN IF NOT EXISTS assigned_version_id UUID REFERENCES pitch_versions(id) ON DELETE SET NULL;