[split-required] [guardrail-change] Enforce 500 LOC budget across all services

Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook)
and split all 44 files exceeding 500 LOC into domain-focused modules:

- consent-service (Go): models, handlers, services, database splits
- backend-core (Python): security_api, rbac_api, pdf_service, auth splits
- admin-core (TypeScript): 5 page.tsx + sidebar extractions
- pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits
- voice-service (Python): enhanced_task_orchestrator split

Result: 0 violations, 36 exempted (pipeline, tests, pure-data files).
Go build verified clean. No behavior changes — pure structural splits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-27 00:09:30 +02:00
parent 5ef039a6bc
commit 92c86ec6ba
162 changed files with 23853 additions and 23034 deletions

View File

@@ -0,0 +1,382 @@
'use client'
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'
type R = Record<string, unknown>
interface TabEditorProps {
activeTab: string
data: unknown[]
single: R
jsonMode: boolean
jsonText: string
isDraft: boolean
onJsonTextChange: (text: string) => void
onDirty: () => void
updateData: (newData: unknown[]) => void
updateRecord: (index: number, key: string, value: unknown) => void
updateSingle: (key: string, value: unknown) => void
}
export default function TabEditor({
activeTab,
data,
single,
jsonMode,
jsonText,
isDraft,
onJsonTextChange,
onDirty,
updateData,
updateRecord,
updateSingle,
}: TabEditorProps) {
if (jsonMode) {
return (
<textarea
value={jsonText}
onChange={e => { onJsonTextChange(e.target.value); onDirty() }}
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 <CompanyEditor single={single} updateSingle={updateSingle} />
case 'team':
return <TeamEditor data={data as R[]} updateData={updateData} />
case 'financials':
return <FinancialsEditor data={data as R[]} updateData={updateData} />
case 'market':
return <MarketEditor data={data as R[]} updateData={updateData} />
case 'competitors':
return <CompetitorsEditor data={data as R[]} updateData={updateData} />
case 'features':
return <FeaturesEditor data={data as R[]} updateData={updateData} />
case 'milestones':
return <MilestonesEditor data={data as R[]} updateData={updateData} />
case 'metrics':
return <MetricsEditor data={data as R[]} updateData={updateData} />
case 'funding':
return <FundingEditor single={single} updateSingle={updateSingle} />
case 'products':
return <ProductsEditor data={data as R[]} updateData={updateData} />
case 'fm_scenarios':
return <FmScenariosEditor data={data as R[]} updateData={updateData} />
case 'fm_assumptions':
return <FmAssumptionsEditor data={data as R[]} updateData={updateData} />
default:
return <div className="p-4 text-white/40">No editor for this table</div>
}
}
/* --- Individual tab editors --- */
function CompanyEditor({ single, updateSingle }: { single: R; updateSingle: (k: string, v: unknown) => void }) {
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>
)
}
function TeamEditor({ data, updateData }: { data: R[]; updateData: (d: unknown[]) => void }) {
return (
<div className="p-4">
<CardList
items={data}
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>
)
}
function FinancialsEditor({ data, updateData }: { data: R[]; updateData: (d: unknown[]) => void }) {
return (
<div className="p-4">
<RowTable
rows={data}
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>
)
}
function MarketEditor({ data, updateData }: { data: R[]; updateData: (d: unknown[]) => void }) {
return (
<div className="p-4">
<RowTable
rows={data}
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>
)
}
function CompetitorsEditor({ data, updateData }: { data: R[]; updateData: (d: unknown[]) => void }) {
return (
<div className="p-4">
<CardList
items={data}
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>
)
}
function FeaturesEditor({ data, updateData }: { data: R[]; updateData: (d: unknown[]) => void }) {
return (
<div className="p-4">
<CardList
items={data}
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>
)
}
function MilestonesEditor({ data, updateData }: { data: R[]; updateData: (d: unknown[]) => void }) {
return (
<div className="p-4">
<CardList
items={data}
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>
)
}
function MetricsEditor({ data, updateData }: { data: R[]; updateData: (d: unknown[]) => void }) {
return (
<div className="p-4">
<CardList
items={data}
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>
)
}
function FundingEditor({ single, updateSingle }: { single: R; updateSingle: (k: string, v: unknown) => void }) {
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>
)
}
function ProductsEditor({ data, updateData }: { data: R[]; updateData: (d: unknown[]) => void }) {
return (
<div className="p-4">
<CardList
items={data}
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>
)
}
function FmScenariosEditor({ data, updateData }: { data: R[]; updateData: (d: unknown[]) => void }) {
return (
<div className="p-4">
<CardList
items={data}
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>
)
}
function FmAssumptionsEditor({ data, updateData }: { data: R[]; updateData: (d: unknown[]) => void }) {
return (
<div className="p-4">
<RowTable
rows={data}
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 &quot;Edit as JSON&quot; mode for complex types.</p>
</div>
)
}

View File

@@ -4,11 +4,7 @@ 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'
import TabEditor from './_components/TabEditors'
const TABLE_LABELS: Record<string, string> = {
company: 'Company', team: 'Team', financials: 'Financials', market: 'Market',
@@ -65,7 +61,6 @@ export default function VersionEditorPage() {
updateData(arr)
}
// For single-record tables (company, funding)
function updateSingle(key: string, value: unknown) { updateRecord(0, key, value) }
async function saveTable() {
@@ -114,316 +109,6 @@ export default function VersionEditorPage() {
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">
@@ -467,17 +152,17 @@ export default function VersionEditorPage() {
{/* Tabs */}
<div className="flex gap-1 overflow-x-auto pb-1">
{TABLE_NAMES.map(t => (
{TABLE_NAMES.map(tab => (
<button
key={t}
onClick={() => { if (dirty && !confirm('Discard unsaved changes?')) return; setActiveTab(t); setDirty(false); setJsonMode(false) }}
key={tab}
onClick={() => { if (dirty && !confirm('Discard unsaved changes?')) return; setActiveTab(tab); setDirty(false); setJsonMode(false) }}
className={`px-3 py-1.5 rounded-lg text-xs whitespace-nowrap transition-colors ${
activeTab === t
activeTab === tab
? '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]}
{TABLE_LABELS[tab]}
</button>
))}
</div>
@@ -504,15 +189,27 @@ export default function VersionEditorPage() {
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'}
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving...' : 'Save'}
</button>
)}
</div>
</div>
{renderEditor()}
<TabEditor
activeTab={activeTab}
data={data}
single={single}
jsonMode={jsonMode}
jsonText={jsonText}
isDraft={isDraft}
onJsonTextChange={setJsonText}
onDirty={() => setDirty(true)}
updateData={updateData}
updateRecord={updateRecord}
updateSingle={updateSingle}
/>
</div>
{!isDraft && <p className="text-xs text-white/30 text-center">Committed read-only. Fork to edit.</p>}
{!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">

View File

@@ -0,0 +1,112 @@
import { Language, SlideId } from '@/lib/types'
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
import { PresenterState } from '@/lib/presenter/types'
export interface ChatFABProps {
lang: Language
currentSlide: SlideId
currentIndex: number
visitedSlides: Set<number>
onGoToSlide: (index: number) => void
presenterState?: PresenterState
onPresenterInterrupt?: () => void
}
export interface ParsedMessage {
text: string
followUps: string[]
gotos: { index: number; label: string }[]
}
export function parseAgentResponse(content: string, lang: Language): ParsedMessage {
const followUps: string[] = []
const gotos: { index: number; label: string }[] = []
// Split on the follow-up separator — flexible: "---", "- - -", "___", or multiple dashes
const parts = content.split(/\n\s*[-_]{3,}\s*\n/)
let text = parts[0]
// Parse follow-up questions from second part
if (parts.length > 1) {
const qSection = parts.slice(1).join('\n')
// Match [Q], **[Q]**, or numbered/bulleted question patterns
const qMatches = qSection.matchAll(/(?:\[Q\]|\*\*\[Q\]\*\*)\s*(.+?)(?:\n|$)/g)
for (const m of qMatches) {
const q = m[1].trim().replace(/^\*\*|\*\*$/g, '')
if (q.length > 5) followUps.push(q)
}
// Fallback: if no [Q] markers found, look for numbered or bulleted questions in the section
if (followUps.length === 0) {
const lineMatches = qSection.matchAll(/(?:^|\n)\s*(?:\d+[\.\)]\s*|[-•]\s*)(.+?\?)\s*$/gm)
for (const m of lineMatches) {
const q = m[1].trim()
if (q.length > 5 && followUps.length < 3) followUps.push(q)
}
}
}
// Also look for [Q] questions anywhere in the text (sometimes model puts them without ---)
if (followUps.length === 0) {
const inlineMatches = content.matchAll(/\[Q\]\s*(.+?)(?:\n|$)/g)
const inlineQs: string[] = []
for (const m of inlineMatches) {
inlineQs.push(m[1].trim())
}
if (inlineQs.length >= 2) {
followUps.push(...inlineQs)
// Remove [Q] lines from main text
text = text.replace(/\n?\s*\[Q\]\s*.+?(?:\n|$)/g, '\n').trim()
}
}
// Parse GOTO markers — support both [GOTO:N] (numeric) and [GOTO:slide-id] (string)
const gotoRegex = /\[GOTO:([\w-]+)\]/g
let gotoMatch
while ((gotoMatch = gotoRegex.exec(text)) !== null) {
const target = gotoMatch[1]
let slideIndex: number
// Try numeric index first
const numericIndex = parseInt(target)
if (!isNaN(numericIndex) && numericIndex >= 0 && numericIndex < SLIDE_ORDER.length) {
slideIndex = numericIndex
} else {
// Try slide ID lookup
slideIndex = SLIDE_ORDER.indexOf(target as SlideId)
}
if (slideIndex >= 0) {
gotos.push({
index: slideIndex,
label: lang === 'de' ? `Zu Slide ${slideIndex + 1} springen` : `Jump to slide ${slideIndex + 1}`,
})
}
}
// Remove GOTO markers from visible text
text = text.replace(/\s*\[GOTO:[\w-]+\]/g, '')
// Clean up trailing reminder instruction that might leak through
text = text.replace(/\n*\(Erinnerung:.*?\)\s*$/s, '').trim()
return { text: text.trim(), followUps, gotos }
}
/**
* Clean text for TTS: remove markdown formatting, keep plain speech
*/
export function cleanTextForTts(text: string): string {
return text
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\[GOTO:[\w-]+\]/g, '')
.replace(/\[Q\]\s*/g, '')
.replace(/---/g, '')
.trim()
}
/**
* Detect language heuristically for TTS
*/
export function detectTtsLanguage(text: string, fallback: Language): string {
return /[äöüÄÖÜß]|(?:^|\s)(?:das|die|der|und|ist|wir|ein|für|mit|auf|von|den|des)\s/i.test(text) ? 'de' : fallback
}

View File

@@ -3,101 +3,17 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X, Send, Bot, User, Sparkles, Maximize2, Minimize2, ArrowRight, Volume2, VolumeX } from 'lucide-react'
import { ChatMessage, Language, SlideId } from '@/lib/types'
import { ChatMessage } from '@/lib/types'
import { t } from '@/lib/i18n'
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
import { PresenterState } from '@/lib/presenter/types'
import { matchFAQMultiple, buildFAQContext } from '@/lib/presenter/faq-matcher'
interface ChatFABProps {
lang: Language
currentSlide: SlideId
currentIndex: number
visitedSlides: Set<number>
onGoToSlide: (index: number) => void
presenterState?: PresenterState
onPresenterInterrupt?: () => void
}
interface ParsedMessage {
text: string
followUps: string[]
gotos: { index: number; label: string }[]
}
function parseAgentResponse(content: string, lang: Language): ParsedMessage {
const followUps: string[] = []
const gotos: { index: number; label: string }[] = []
// Split on the follow-up separator — flexible: "---", "- - -", "___", or multiple dashes
const parts = content.split(/\n\s*[-_]{3,}\s*\n/)
let text = parts[0]
// Parse follow-up questions from second part
if (parts.length > 1) {
const qSection = parts.slice(1).join('\n')
// Match [Q], **[Q]**, or numbered/bulleted question patterns
const qMatches = qSection.matchAll(/(?:\[Q\]|\*\*\[Q\]\*\*)\s*(.+?)(?:\n|$)/g)
for (const m of qMatches) {
const q = m[1].trim().replace(/^\*\*|\*\*$/g, '')
if (q.length > 5) followUps.push(q)
}
// Fallback: if no [Q] markers found, look for numbered or bulleted questions in the section
if (followUps.length === 0) {
const lineMatches = qSection.matchAll(/(?:^|\n)\s*(?:\d+[\.\)]\s*|[-•]\s*)(.+?\?)\s*$/gm)
for (const m of lineMatches) {
const q = m[1].trim()
if (q.length > 5 && followUps.length < 3) followUps.push(q)
}
}
}
// Also look for [Q] questions anywhere in the text (sometimes model puts them without ---)
if (followUps.length === 0) {
const inlineMatches = content.matchAll(/\[Q\]\s*(.+?)(?:\n|$)/g)
const inlineQs: string[] = []
for (const m of inlineMatches) {
inlineQs.push(m[1].trim())
}
if (inlineQs.length >= 2) {
followUps.push(...inlineQs)
// Remove [Q] lines from main text
text = text.replace(/\n?\s*\[Q\]\s*.+?(?:\n|$)/g, '\n').trim()
}
}
// Parse GOTO markers — support both [GOTO:N] (numeric) and [GOTO:slide-id] (string)
const gotoRegex = /\[GOTO:([\w-]+)\]/g
let gotoMatch
while ((gotoMatch = gotoRegex.exec(text)) !== null) {
const target = gotoMatch[1]
let slideIndex: number
// Try numeric index first
const numericIndex = parseInt(target)
if (!isNaN(numericIndex) && numericIndex >= 0 && numericIndex < SLIDE_ORDER.length) {
slideIndex = numericIndex
} else {
// Try slide ID lookup
slideIndex = SLIDE_ORDER.indexOf(target as SlideId)
}
if (slideIndex >= 0) {
gotos.push({
index: slideIndex,
label: lang === 'de' ? `Zu Slide ${slideIndex + 1} springen` : `Jump to slide ${slideIndex + 1}`,
})
}
}
// Remove GOTO markers from visible text
text = text.replace(/\s*\[GOTO:[\w-]+\]/g, '')
// Clean up trailing reminder instruction that might leak through
text = text.replace(/\n*\(Erinnerung:.*?\)\s*$/s, '').trim()
return { text: text.trim(), followUps, gotos }
}
import {
ChatFABProps,
ParsedMessage,
parseAgentResponse,
cleanTextForTts,
detectTtsLanguage,
} from './ChatFAB.helpers'
export default function ChatFAB({
lang,
@@ -140,21 +56,14 @@ export default function ChatFAB({
if (!chatTtsEnabled) return
cancelChatAudio()
// Clean text for TTS: remove markdown formatting, keep plain speech
const cleanText = text
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\[GOTO:[\w-]+\]/g, '')
.replace(/\[Q\]\s*/g, '')
.replace(/---/g, '')
.trim()
const cleanText = cleanTextForTts(text)
if (!cleanText) return
const controller = new AbortController()
ttsAbortRef.current = controller
try {
const textLang = /[äöüÄÖÜß]|(?:^|\s)(?:das|die|der|und|ist|wir|ein|für|mit|auf|von|den|des)\s/i.test(cleanText) ? 'de' : lang
const textLang = detectTtsLanguage(cleanText, lang)
const res = await fetch('/api/presenter/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -169,14 +78,8 @@ export default function ChatFAB({
chatAudioRef.current = audio
setIsChatSpeaking(true)
audio.onended = () => {
setIsChatSpeaking(false)
chatAudioRef.current = null
}
audio.onerror = () => {
setIsChatSpeaking(false)
chatAudioRef.current = null
}
audio.onended = () => { setIsChatSpeaking(false); chatAudioRef.current = null }
audio.onerror = () => { setIsChatSpeaking(false); chatAudioRef.current = null }
await audio.play()
} catch {
@@ -196,8 +99,8 @@ export default function ChatFAB({
// Parse the latest assistant message when streaming ends
const lastAssistantIndex = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'assistant') return i
for (let idx = messages.length - 1; idx >= 0; idx--) {
if (messages[idx].role === 'assistant') return idx
}
return -1
}, [messages])
@@ -207,8 +110,6 @@ export default function ChatFAB({
const msg = messages[lastAssistantIndex]
const parsed = parseAgentResponse(msg.content, lang)
setParsedResponses(prev => new Map(prev).set(lastAssistantIndex, parsed))
// Speak the response via TTS
speakResponse(parsed.text)
}
}, [isStreaming, lastAssistantIndex, messages, parsedResponses, lang])
@@ -217,7 +118,6 @@ export default function ChatFAB({
const message = text || input.trim()
if (!message || isStreaming) return
// Interrupt presenter if it's running
if (presenterState === 'presenting' && onPresenterInterrupt) {
onPresenterInterrupt()
}
@@ -228,7 +128,6 @@ export default function ChatFAB({
setIsStreaming(true)
setIsWaiting(true)
// Find relevant FAQ entries as context for the LLM
const faqMatches = matchFAQMultiple(message, lang, 3)
const faqContext = buildFAQContext(faqMatches, lang)
@@ -240,17 +139,13 @@ export default function ChatFAB({
history: messages.slice(-10),
lang,
slideContext: {
currentSlide,
currentIndex,
currentSlide, currentIndex,
visitedSlides: Array.from(visitedSlides),
totalSlides: SLIDE_ORDER.length,
},
}
// Send FAQ context to LLM (not direct streaming — LLM interprets and combines)
if (faqContext) {
requestBody.faqContext = faqContext
}
if (faqContext) requestBody.faqContext = faqContext
const res = await fetch('/api/chat', {
method: 'POST',
@@ -291,8 +186,7 @@ export default function ChatFAB({
if (topMatch?.goto_slide) {
const gotoIdx = SLIDE_ORDER.indexOf(topMatch.goto_slide)
if (gotoIdx >= 0) {
const suffix = `\n\n[GOTO:${topMatch.goto_slide}]`
content += suffix
content += `\n\n[GOTO:${topMatch.goto_slide}]`
setMessages(prev => {
const updated = [...prev]
updated[updated.length - 1] = { role: 'assistant', content }
@@ -319,10 +213,7 @@ export default function ChatFAB({
}
function stopGeneration() {
if (abortRef.current) {
abortRef.current.abort()
setIsStreaming(false)
}
if (abortRef.current) { abortRef.current.abort(); setIsStreaming(false) }
}
const suggestions = i.aiqa.suggestions.slice(0, 3)
@@ -338,7 +229,6 @@ export default function ChatFAB({
<span className="inline-block w-1.5 h-3.5 bg-indigo-400 animate-pulse ml-0.5" />
)}
{/* GOTO Buttons */}
{parsed && parsed.gotos.length > 0 && (
<div className="mt-2 space-y-1">
{parsed.gotos.map((g, gi) => (
@@ -357,7 +247,6 @@ export default function ChatFAB({
</div>
)}
{/* Follow-Up Suggestions */}
{parsed && parsed.followUps.length > 0 && !isStreaming && (
<div className="mt-3 space-y-1.5 border-t border-white/10 pt-2">
{parsed.followUps.map((q, qi) => (
@@ -380,7 +269,7 @@ export default function ChatFAB({
return (
<>
{/* FAB Button — sits to the left of NavigationFAB */}
{/* FAB Button */}
<AnimatePresence>
{!isOpen && (
<motion.button
@@ -402,7 +291,6 @@ export default function ChatFAB({
<circle cx="12" cy="10" r="1" fill="currentColor" />
<circle cx="15" cy="10" r="1" fill="currentColor" />
</svg>
{/* Presenter active indicator */}
{presenterState !== 'idle' && (
<span className="absolute -top-1 -right-1 w-3 h-3 rounded-full bg-green-400 border-2 border-black animate-pulse" />
)}
@@ -445,12 +333,7 @@ export default function ChatFAB({
</div>
<div className="flex items-center gap-1">
<button
onClick={() => {
setChatTtsEnabled(prev => {
if (prev) cancelChatAudio()
return !prev
})
}}
onClick={() => { setChatTtsEnabled(prev => { if (prev) cancelChatAudio(); return !prev }) }}
className={`w-7 h-7 rounded-full flex items-center justify-center transition-colors ${
isChatSpeaking ? 'bg-indigo-500/30' : 'bg-white/10 hover:bg-white/20'
}`}
@@ -471,10 +354,7 @@ export default function ChatFAB({
{isExpanded ? <Minimize2 className="w-3.5 h-3.5 text-white/60" /> : <Maximize2 className="w-3.5 h-3.5 text-white/60" />}
</button>
<button
onClick={() => {
cancelChatAudio()
setIsOpen(false)
}}
onClick={() => { cancelChatAudio(); setIsOpen(false) }}
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center hover:bg-white/20 transition-colors"
>
<X className="w-4 h-4 text-white/60" />
@@ -521,12 +401,12 @@ export default function ChatFAB({
<Bot className="w-3.5 h-3.5 text-indigo-400" />
</div>
<div className="bg-white/[0.06] rounded-2xl px-3.5 py-3 flex items-center gap-1">
{[0, 1, 2].map(i => (
{[0, 1, 2].map(dotIdx => (
<motion.span
key={i}
key={dotIdx}
className="block w-1.5 h-1.5 rounded-full bg-indigo-400/70"
animate={{ opacity: [0.3, 1, 0.3], y: [0, -3, 0] }}
transition={{ duration: 0.7, repeat: Infinity, delay: i * 0.15 }}
transition={{ duration: 0.7, repeat: Infinity, delay: dotIdx * 0.15 }}
/>
))}
</div>
@@ -535,24 +415,15 @@ export default function ChatFAB({
</AnimatePresence>
{messages.map((msg, idx) => (
<div
key={idx}
className={`flex gap-2.5 ${msg.role === 'user' ? 'justify-end' : ''}`}
>
<div key={idx} className={`flex gap-2.5 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-7 h-7 rounded-full bg-indigo-500/20 flex items-center justify-center shrink-0 mt-0.5">
<Bot className="w-3.5 h-3.5 text-indigo-400" />
</div>
)}
<div
className={`
max-w-[85%] rounded-2xl px-3.5 py-2.5 text-xs leading-relaxed
${msg.role === 'user'
? 'bg-indigo-500/20 text-white'
: 'bg-white/[0.06] text-white/80'
}
`}
>
<div className={`max-w-[85%] rounded-2xl px-3.5 py-2.5 text-xs leading-relaxed ${
msg.role === 'user' ? 'bg-indigo-500/20 text-white' : 'bg-white/[0.06] text-white/80'
}`}>
{msg.role === 'assistant' ? renderMessageContent(msg, idx) : (
<div className="whitespace-pre-wrap">{msg.content}</div>
)}

View File

@@ -0,0 +1,118 @@
// ArchitectureSlide data — extracted from ArchitectureSlide.tsx
import {
Brain, Shield, ScanLine, Zap, Cpu, Layers, Wrench,
} from 'lucide-react'
export type NodeId = 'certifai' | 'complai' | 'scanner' | 'litellm' | 'llm' | 'embeddings' | 'tools'
export interface NodeDef {
id: NodeId
icon: React.ElementType
title: string
subtitle: string
color: string
tech: string[]
services: { name: string; desc: string }[]
primary?: boolean
tier: 'product' | 'proxy' | 'inference'
}
export function getNodes(de: boolean): NodeDef[] {
return [
{
id: 'certifai', icon: Brain,
title: 'CERTifAI',
subtitle: de ? 'GenAI Mandantenportal' : 'GenAI Tenant Portal',
color: '#c084fc', tier: 'product',
tech: ['Rust', 'Dioxus', 'MongoDB', 'Keycloak', 'SearXNG', 'LangGraph'],
services: [
{ name: 'LiteLLM Dashboard', desc: de ? 'Modellverwaltung & Kostentracking' : 'Model mgmt & cost tracking' },
{ name: 'LibreChat + SSO', desc: de ? 'Mandanten-Chat mit Keycloak' : 'Tenant chat with Keycloak' },
{ name: 'LangGraph Agents', desc: de ? 'Agent-Orchestrierung' : 'Agent orchestration' },
{ name: 'MCP Hub', desc: de ? 'Tool-Integration f\u00fcr KI-Clients' : 'Tool integration for AI clients' },
],
},
{
id: 'complai', icon: Shield,
title: 'COMPLAI',
subtitle: de ? 'Compliance & Audit' : 'Compliance & Audit',
color: '#818cf8', tier: 'product',
tech: ['Next.js 15', 'FastAPI', 'Go/Gin', 'PostgreSQL', 'Qdrant', 'Valkey'],
services: [
{ name: de ? 'DSGVO / AI Act / NIS2' : 'GDPR / AI Act / NIS2', desc: de ? '70k+ auditierbare Controls' : '70k+ auditable controls' },
{ name: 'RAG Pipeline', desc: de ? '75+ Rechtsquellen, semantische Suche' : '75+ legal sources, semantic search' },
{ name: 'Control Pipeline', desc: de ? 'Gesetzestextanalyse via LLM' : 'Legal text analysis via LLM' },
{ name: 'MCP Client', desc: de ? 'Echtzeit-Findings vom Scanner' : 'Real-time findings from Scanner' },
],
},
{
id: 'scanner', icon: ScanLine,
title: 'Compliance Scanner',
subtitle: de ? 'Code-Sicherheit' : 'Code Security',
color: '#34d399', tier: 'product',
tech: ['Rust', 'Axum', 'MongoDB', 'Semgrep', 'Gitleaks', 'Syft'],
services: [
{ name: 'SAST / SBOM / CVE', desc: de ? 'Vollautomatische Pipeline' : 'Fully automated pipeline' },
{ name: de ? 'KI-Triage' : 'AI Triage', desc: de ? 'LLM filtert False Positives' : 'LLM filters false positives' },
{ name: de ? 'KI-Pentest' : 'AI Pentest', desc: de ? 'Autonome Angriffsketten' : 'Autonomous attack chains' },
{ name: 'MCP Server', desc: de ? 'Live-Findings f\u00fcr COMPLAI' : 'Live findings for COMPLAI' },
],
},
{
id: 'litellm', icon: Zap,
title: 'LiteLLM Proxy',
subtitle: de ? 'KI-Gateway & Guardrails' : 'AI Gateway & Guardrails',
color: '#fbbf24', tier: 'proxy', primary: true,
tech: ['OpenAI-kompatible API', 'Bearer Auth', 'Rate Limiting', 'PII-Filter', 'Spend Tracking'],
services: [
{ name: de ? 'Token-Budget' : 'Token Budget', desc: de ? 'Pro-Mandant Kontingente & Abrechnung' : 'Per-tenant quotas & billing' },
{ name: 'PII Guardrails', desc: de ? 'Datenschutz-Filter f\u00fcr alle Anfragen' : 'Privacy filter on all requests' },
{ name: de ? 'Web-Suche (anonym)' : 'Web Search (anon)', desc: de ? 'SearXNG-Proxy, kein US-Anbieter' : 'SearXNG proxy, no US providers' },
{ name: de ? 'Namespace-Isolierung' : 'Namespace Isolation', desc: de ? 'Mandantentrennung per API-Key' : 'Tenant isolation per API key' },
{ name: de ? 'Failover-Routing' : 'Failover Routing', desc: de ? 'Automatisches Fallback' : 'Automatic fallback between models' },
],
},
{
id: 'llm', icon: Cpu,
title: de ? 'LLM Inferenz' : 'LLM Inference',
subtitle: de ? 'Lokale Sprachmodelle' : 'Local Language Models',
color: '#60a5fa', tier: 'inference',
tech: ['Qwen3-32B', 'Qwen3-Coder-30B', 'DeepSeek-R1-8B', 'Ollama'],
services: [
{ name: de ? 'Vollst\u00e4ndig lokal' : 'Fully local', desc: de ? 'Daten verlassen nie den Server' : 'Data never leaves the server' },
{ name: de ? 'Air-Gap f\u00e4hig' : 'Air-Gap Capable', desc: de ? 'Kein Internet erforderlich' : 'No internet required' },
{ name: de ? 'GPU-optimiert' : 'GPU-optimized', desc: de ? 'Dedizierte Inferenz-Hardware' : 'Dedicated inference hardware' },
],
},
{
id: 'embeddings', icon: Layers,
title: 'Embeddings',
subtitle: de ? 'Semantische Suche' : 'Semantic Search',
color: '#a78bfa', tier: 'inference',
tech: ['bge-m3', 'Qdrant Vector DB', 'Sentence-Transformers'],
services: [
{ name: 'RAG Pipeline', desc: de ? '75+ Rechtsquellen indexiert' : '75+ legal sources indexed' },
{ name: de ? 'Semantische Suche' : 'Semantic Search', desc: de ? 'Multi-linguale Einbettungen' : 'Multi-lingual embeddings' },
{ name: de ? 'Lokal' : 'Fully local', desc: de ? 'Keine externen APIs' : 'No external APIs' },
],
},
{
id: 'tools', icon: Wrench,
title: de ? 'KI-Tools' : 'AI Tools',
subtitle: de ? 'Web-Suche & MCP' : 'Web Search & MCP',
color: '#2dd4bf', tier: 'inference',
tech: ['SearXNG', 'MCP Protocol', 'Semgrep API', 'Gitleaks API'],
services: [
{ name: 'SearXNG', desc: de ? 'Anonymisierte EU-Websuche' : 'Anonymized EU web search' },
{ name: 'MCP Tools', desc: de ? 'Auditdokumente & Code-Findings' : 'Audit docs & code findings' },
{ name: de ? 'Kein US-Anbieter' : 'No US providers', desc: de ? '100% DSGVO-konform' : '100% GDPR-compliant' },
],
},
]
}
export const LAYERS: { id: string; nodeIds: NodeId[]; tint: string; depth: number }[] = [
{ id: 'product', nodeIds: ['certifai', 'complai', 'scanner'], tint: '#a78bfa', depth: 24 },
{ id: 'proxy', nodeIds: ['litellm'], tint: '#fbbf24', depth: 12 },
{ id: 'inference', nodeIds: ['llm', 'embeddings', 'tools'], tint: '#8b5cf6', depth: 0 },
]

View File

@@ -0,0 +1,370 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { type NodeId, type NodeDef } from './ArchitectureSlide.data'
// ── CSS keyframes (injected via <style> in main component) ───────────────────
export const CSS_KF = `
@keyframes v4FlowDown { from { stroke-dashoffset: 0 } to { stroke-dashoffset: -18px } }
@keyframes v4Pulse { 0%,100% { opacity:1;transform:scale(1) } 50% { opacity:.4;transform:scale(1.4) } }
@keyframes v4Caret { 0%,50% { opacity:1 } 51%,100% { opacity:0 } }
@keyframes v4DotFall {
0% { transform: translateY(-5px); opacity: 0; }
12% { opacity: 1; }
88% { opacity: 1; }
100% { transform: translateY(38px); opacity: 0; }
}
`
export const MONO: React.CSSProperties = {
fontFamily: '"JetBrains Mono","SF Mono",ui-monospace,monospace',
fontVariantNumeric: 'tabular-nums',
}
// ── Theme detection ───────────────────────────────────────────────────────────
export function useIsLight() {
const [isLight, setIsLight] = useState(false)
useEffect(() => {
const check = () => setIsLight(document.documentElement.classList.contains('theme-light'))
check()
const obs = new MutationObserver(check)
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => obs.disconnect()
}, [])
return isLight
}
// ── Ticker primitives ─────────────────────────────────────────────────────────
function useTicker(fn: () => void, min = 140, max = 360, skipChance = 0.1) {
const ref = useRef(fn)
ref.current = fn
useEffect(() => {
let tid: ReturnType<typeof setTimeout>
const loop = () => {
if (Math.random() > skipChance) ref.current()
tid = setTimeout(loop, min + Math.random() * (max - min))
}
loop()
return () => clearTimeout(tid)
}, [min, max, skipChance])
}
function TickerShell({ color, children, isLight }: { color: string; children: React.ReactNode; isLight: boolean }) {
return (
<div style={{
...MONO,
marginTop: 7, padding: '5px 9px',
background: isLight ? '#f1f5f9' : 'rgba(0,0,0,.38)',
border: `1px solid ${color}${isLight ? '55' : '55'}`, borderRadius: 6,
fontSize: 10, color: isLight ? '#475569' : 'rgba(236,233,247,.88)',
display: 'flex', alignItems: 'center', gap: 6,
whiteSpace: 'nowrap', overflow: 'hidden', height: 22,
}}>{children}</div>
)
}
function Caret({ color }: { color: string }) {
return (
<span style={{
display: 'inline-block', width: 5, height: 9, marginLeft: -2,
background: color, animation: 'v4Caret 1s step-end infinite',
}} />
)
}
// ── Per-node tickers ──────────────────────────────────────────────────────────
function TickCertifAI({ color, isLight }: { color: string; isLight: boolean }) {
const [n, setN] = useState(8421)
const [hash, setHash] = useState('9f3a…e10b')
const pool = 'abcdef0123456789'
const r = (k: number) => Array.from({ length: k }, () => pool[Math.floor(Math.random() * pool.length)]).join('')
useTicker(() => { setN(v => v + 1); setHash(`${r(4)}${r(4)}`) }, 1000, 2000, 0.1)
return (
<TickerShell color={color} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color, opacity: .85 }}>sig</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{n.toLocaleString()}</span>
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.55)' }}>{hash}</span>
</TickerShell>
)
}
function TickComplAI({ color, isLight }: { color: string; isLight: boolean }) {
const [evals, setEvals] = useState(1284)
const [rate, setRate] = useState(99.2)
useTicker(() => {
setEvals(v => v + 1 + Math.floor(Math.random() * 3))
setRate(r => Math.max(97, Math.min(99.9, r + (Math.random() - 0.5) * 0.4)))
}, 200, 500, 0.1)
return (
<TickerShell color={color} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color, opacity: .85 }}>eval</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{evals.toLocaleString()}</span>
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.45)' }}>pass</span>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}>{rate.toFixed(1)}%</span>
</TickerShell>
)
}
function TickScanner({ color, isLight }: { color: string; isLight: boolean }) {
const lines = [
{ k: 'PASS', c: '#16a34a', cd: '#4ade80', t: 'CWE-79 xss check' },
{ k: 'WARN', c: '#d97706', cd: '#fbbf24', t: 'drift: model v2.1→2.2' },
{ k: 'PASS', c: '#16a34a', cd: '#4ade80', t: 'bias: demographic parity' },
{ k: 'FAIL', c: '#dc2626', cd: '#f87171', t: 'license: GPL-3 detected' },
{ k: 'PASS', c: '#16a34a', cd: '#4ade80', t: 'prompt-inject: 214 vectors' },
{ k: 'SCAN', c: '#7c3aed', cd: '#a78bfa', t: 'artifact model-card.json' },
]
const [i, setI] = useState(0)
useTicker(() => setI(x => (x + 1) % lines.length), 700, 1200, 0.05)
const l = lines[i]
return (
<TickerShell color={color} isLight={isLight}>
<span style={{ color: isLight ? l.c : l.cd, fontWeight: 600, minWidth: 30 }}>{l.k}</span>
<span style={{ color: isLight ? '#334155' : 'rgba(236,233,247,.85)', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>{l.t}</span>
</TickerShell>
)
}
function TickLiteLLM({ color, isLight }: { color: string; isLight: boolean }) {
const [rps, setRps] = useState(428)
const [p50, setP50] = useState(84)
useTicker(() => {
setRps(v => Math.max(200, Math.min(800, v + (Math.random() - 0.5) * 60)))
setP50(v => Math.max(40, Math.min(160, v + (Math.random() - 0.5) * 20)))
}, 250, 500, 0.05)
return (
<TickerShell color={color} isLight={isLight}>
<span style={{ color: '#d97706' }}></span>
<span style={{ color, opacity: .9 }}>req/s</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{Math.round(rps)}</span>
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.4)' }}>·</span>
<span style={{ color, opacity: .9 }}>p50</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{Math.round(p50)}ms</span>
<Caret color={color} />
</TickerShell>
)
}
function TickLLM({ color, isLight }: { color: string; isLight: boolean }) {
const [tokens, setTokens] = useState(14832)
const [stream, setStream] = useState('t_a91f')
const pool = 'abcdef0123456789'
useTicker(() => {
setTokens(v => v + 1 + Math.floor(Math.random() * 5))
setStream('t_' + Array.from({ length: 4 }, () => pool[Math.floor(Math.random() * pool.length)]).join(''))
}, 120, 340, 0.15)
return (
<TickerShell color={color} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color, opacity: .85 }}>tok</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{tokens.toLocaleString()}</span>
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.35)' }}></span>
<span style={{ color }}>{stream}</span>
<Caret color={color} />
</TickerShell>
)
}
function TickEmbeddings({ color, isLight }: { color: string; isLight: boolean }) {
const [vecs, setVecs] = useState(284112)
useTicker(() => setVecs(v => v + 1 + Math.floor(Math.random() * 8)), 180, 420, 0.1)
return (
<TickerShell color={color} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color, opacity: .85 }}>idx</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{vecs.toLocaleString()}</span>
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.4)' }}>· 1024d</span>
<Caret color={color} />
</TickerShell>
)
}
function TickTools({ color, isLight }: { color: string; isLight: boolean }) {
const ops = [
'search("BSI C5 controls")', 'fetch eur-lex.europa.eu',
'grep -r "DSGVO"', 'read docs/policy.md',
'mcp.call(filesystem)', 'search("vLLM 0.6 release")',
]
const [i, setI] = useState(0)
useTicker(() => setI(x => (x + 1) % ops.length), 900, 1600, 0.05)
return (
<TickerShell color={color} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color, opacity: .85 }}>call</span>
<span style={{ color: isLight ? '#334155' : '#f5f3fc', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>{ops[i]}</span>
</TickerShell>
)
}
export const NODE_TICKER: Record<NodeId, React.ComponentType<{ color: string; isLight: boolean }>> = {
certifai: TickCertifAI,
complai: TickComplAI,
scanner: TickScanner,
litellm: TickLiteLLM,
llm: TickLLM,
embeddings: TickEmbeddings,
tools: TickTools,
}
// ── Animated connector ────────────────────────────────────────────────────────
export function LayerConnector({ tint }: { tint: string }) {
const tracks = [
{ x: '32%', primary: false },
{ x: '50%', primary: true },
{ x: '68%', primary: false },
]
return (
<div style={{ position: 'relative', height: 34, width: '100%', maxWidth: 960, margin: '0 auto' }}>
{tracks.map(({ x, primary }, ti) => {
const color = primary ? '#fbbf24' : tint
const dots = primary ? 4 : 3
const dur = primary ? 1.6 : 2.4
return (
<div key={ti} style={{ position: 'absolute', left: x, top: 0, bottom: 0, transform: 'translateX(-50%)' }}>
<div style={{
position: 'absolute', left: -0.75, top: 0, bottom: 0, width: 1.5,
background: `linear-gradient(180deg, ${color}00, ${color}55 40%, ${color}55 60%, ${color}00)`,
}} />
{Array.from({ length: dots }, (_, j) => (
<div key={j} style={{
position: 'absolute', top: 0, left: -3, width: 6, height: 6, borderRadius: '50%',
background: color, boxShadow: `0 0 7px ${color}`,
animation: `v4DotFall ${dur}s ${-(j / dots) * dur}s linear infinite`,
}} />
))}
</div>
)
})}
</div>
)
}
// ── Node card ─────────────────────────────────────────────────────────────────
export function NodeCard({ node, selected, onClick, isLight }: {
node: NodeDef; selected: boolean; onClick: () => void; isLight: boolean
}) {
const [hover, setHover] = useState(false)
const active = hover || selected
const c = node.color
const Ticker = NODE_TICKER[node.id]
const Icon = node.icon
return (
<button
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
flex: 1,
background: active
? `linear-gradient(180deg, ${c}${isLight ? '20' : '33'}, ${c}${isLight ? '0a' : '12'})`
: isLight
? 'linear-gradient(180deg, #ffffff, #f8fafc)'
: 'linear-gradient(180deg, rgba(255,255,255,.055), rgba(255,255,255,.015))',
border: `1px solid ${active ? c : isLight ? 'rgba(0,0,0,.1)' : 'rgba(255,255,255,.14)'}`,
borderRadius: 12, padding: '12px 14px',
cursor: 'pointer', textAlign: 'left',
color: isLight ? '#1a1a2e' : '#ece9f7', fontFamily: 'inherit',
display: 'flex', flexDirection: 'column',
transition: 'all .2s ease',
transform: active ? 'translateY(-1px)' : 'none',
boxShadow: active
? `0 8px 26px ${c}44, 0 0 0 4px ${c}14`
: isLight ? '0 1px 4px rgba(0,0,0,.06)' : '0 1px 0 rgba(255,255,255,.04)',
minWidth: 0, position: 'relative',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
background: `linear-gradient(135deg, ${c}3a, ${c}10)`,
border: `1px solid ${c}66`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: c,
boxShadow: node.primary ? `inset 0 0 14px ${c}40` : 'none',
}}>
<Icon style={{ width: 18, height: 18 }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 600,
color: isLight ? '#1a1a2e' : '#f7f5fc',
letterSpacing: -0.1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{node.title}</div>
<div style={{
fontSize: 10.5,
color: isLight ? '#64748b' : 'rgba(236,233,247,.65)',
marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{node.subtitle}</div>
</div>
</div>
<Ticker color={c} isLight={isLight} />
{node.primary && (
<div style={{
position: 'absolute', top: -1, right: -1,
width: 6, height: 6, borderRadius: '50%',
background: '#fbbf24', boxShadow: '0 0 8px #fbbf24',
animation: 'v4Pulse 1.6s ease-in-out infinite',
}} />
)}
</button>
)
}
// ── 3D slab ───────────────────────────────────────────────────────────────────
export function LayerSlab({ label, sublabel, nodes, tint, depth, selectedId, onSelect, isLight }: {
label: string; sublabel: string; nodes: NodeDef[]
tint: string; depth: number
selectedId: NodeId | null; onSelect: (id: NodeId) => void
isLight: boolean
}) {
const isProxy = nodes.length === 1 && !!nodes[0].primary
return (
<div style={{
position: 'relative', margin: '0 auto',
padding: '14px 20px 18px', width: '100%', maxWidth: 960,
background: isLight
? `linear-gradient(180deg, ${tint}18 0%, ${tint}08 60%, rgba(248,250,252,.98) 100%)`
: `linear-gradient(180deg, ${tint}26 0%, ${tint}12 60%, rgba(14,8,28,.85) 100%)`,
border: `1px solid ${tint}${isLight ? '44' : '66'}`,
borderRadius: 16,
boxShadow: isLight
? `0 -2px 16px ${tint}18, 0 8px 30px rgba(0,0,0,.05), inset 0 1px 0 ${tint}44`
: `0 -6px 30px ${tint}22, 0 24px 60px rgba(0,0,0,.6), inset 0 1px 0 ${tint}55, inset 0 -1px 0 rgba(0,0,0,.4)`,
transform: `perspective(2000px) rotateX(12deg) translateZ(${depth}px)`,
}}>
<div style={{
position: 'absolute', top: 0, left: 20, right: 20, height: 1,
background: `linear-gradient(90deg, transparent, ${tint}${isLight ? 'aa' : 'cc'}, transparent)`,
pointerEvents: 'none',
}} />
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{
fontSize: 9.5, letterSpacing: 2, textTransform: 'uppercase' as const, fontWeight: 600,
color: tint,
background: isLight ? `${tint}18` : `${tint}20`,
padding: '3px 9px', borderRadius: 99,
border: `1px solid ${tint}${isLight ? '44' : '50'}`, whiteSpace: 'nowrap',
}}>{label}</div>
<div style={{
fontSize: 11,
color: isLight ? '#64748b' : 'rgba(236,233,247,.55)',
whiteSpace: 'nowrap',
}}>{sublabel}</div>
</div>
<div style={{ display: 'flex', gap: 10, justifyContent: isProxy ? 'center' : undefined }}>
{isProxy ? (
<div style={{ width: '42%', minWidth: 260, display: 'flex' }}>
<NodeCard node={nodes[0]} selected={selectedId === nodes[0].id} onClick={() => onSelect(nodes[0].id)} isLight={isLight} />
</div>
) : (
nodes.map(n => (
<NodeCard key={n.id} node={n} selected={selectedId === n.id} onClick={() => onSelect(n.id)} isLight={isLight} />
))
)}
</div>
</div>
)
}

View File

@@ -1,496 +1,16 @@
'use client'
import { useState, useEffect, useRef, Fragment } from 'react'
import { useState, Fragment } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Language } from '@/lib/types'
import { t } from '@/lib/i18n'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import {
Brain, Shield, ScanLine, Zap, Cpu,
Layers, Wrench, X, Users, Lock,
Server, BadgeCheck,
} from 'lucide-react'
import { X, Users, Lock, Server, BadgeCheck } from 'lucide-react'
import { type NodeId, type NodeDef, getNodes, LAYERS } from './ArchitectureSlide.data'
import { CSS_KF, MONO, useIsLight, LayerConnector, LayerSlab } from './ArchitectureSlide.parts'
interface ArchitectureSlideProps { lang: Language }
type NodeId = 'certifai' | 'complai' | 'scanner' | 'litellm' | 'llm' | 'embeddings' | 'tools'
interface NodeDef {
id: NodeId
icon: React.ElementType
title: string
subtitle: string
color: string
tech: string[]
services: { name: string; desc: string }[]
primary?: boolean
tier: 'product' | 'proxy' | 'inference'
}
function getNodes(de: boolean): NodeDef[] {
return [
{
id: 'certifai', icon: Brain,
title: 'CERTifAI',
subtitle: de ? 'GenAI Mandantenportal' : 'GenAI Tenant Portal',
color: '#c084fc', tier: 'product',
tech: ['Rust', 'Dioxus', 'MongoDB', 'Keycloak', 'SearXNG', 'LangGraph'],
services: [
{ name: 'LiteLLM Dashboard', desc: de ? 'Modellverwaltung & Kostentracking' : 'Model mgmt & cost tracking' },
{ name: 'LibreChat + SSO', desc: de ? 'Mandanten-Chat mit Keycloak' : 'Tenant chat with Keycloak' },
{ name: 'LangGraph Agents', desc: de ? 'Agent-Orchestrierung' : 'Agent orchestration' },
{ name: 'MCP Hub', desc: de ? 'Tool-Integration für KI-Clients' : 'Tool integration for AI clients' },
],
},
{
id: 'complai', icon: Shield,
title: 'COMPLAI',
subtitle: de ? 'Compliance & Audit' : 'Compliance & Audit',
color: '#818cf8', tier: 'product',
tech: ['Next.js 15', 'FastAPI', 'Go/Gin', 'PostgreSQL', 'Qdrant', 'Valkey'],
services: [
{ name: de ? 'DSGVO / AI Act / NIS2' : 'GDPR / AI Act / NIS2', desc: de ? '70k+ auditierbare Controls' : '70k+ auditable controls' },
{ name: 'RAG Pipeline', desc: de ? '75+ Rechtsquellen, semantische Suche' : '75+ legal sources, semantic search' },
{ name: 'Control Pipeline', desc: de ? 'Gesetzestextanalyse via LLM' : 'Legal text analysis via LLM' },
{ name: 'MCP Client', desc: de ? 'Echtzeit-Findings vom Scanner' : 'Real-time findings from Scanner' },
],
},
{
id: 'scanner', icon: ScanLine,
title: 'Compliance Scanner',
subtitle: de ? 'Code-Sicherheit' : 'Code Security',
color: '#34d399', tier: 'product',
tech: ['Rust', 'Axum', 'MongoDB', 'Semgrep', 'Gitleaks', 'Syft'],
services: [
{ name: 'SAST / SBOM / CVE', desc: de ? 'Vollautomatische Pipeline' : 'Fully automated pipeline' },
{ name: de ? 'KI-Triage' : 'AI Triage', desc: de ? 'LLM filtert False Positives' : 'LLM filters false positives' },
{ name: de ? 'KI-Pentest' : 'AI Pentest', desc: de ? 'Autonome Angriffsketten' : 'Autonomous attack chains' },
{ name: 'MCP Server', desc: de ? 'Live-Findings für COMPLAI' : 'Live findings for COMPLAI' },
],
},
{
id: 'litellm', icon: Zap,
title: 'LiteLLM Proxy',
subtitle: de ? 'KI-Gateway & Guardrails' : 'AI Gateway & Guardrails',
color: '#fbbf24', tier: 'proxy', primary: true,
tech: ['OpenAI-kompatible API', 'Bearer Auth', 'Rate Limiting', 'PII-Filter', 'Spend Tracking'],
services: [
{ name: de ? 'Token-Budget' : 'Token Budget', desc: de ? 'Pro-Mandant Kontingente & Abrechnung' : 'Per-tenant quotas & billing' },
{ name: 'PII Guardrails', desc: de ? 'Datenschutz-Filter für alle Anfragen' : 'Privacy filter on all requests' },
{ name: de ? 'Web-Suche (anonym)' : 'Web Search (anon)', desc: de ? 'SearXNG-Proxy, kein US-Anbieter' : 'SearXNG proxy, no US providers' },
{ name: de ? 'Namespace-Isolierung' : 'Namespace Isolation', desc: de ? 'Mandantentrennung per API-Key' : 'Tenant isolation per API key' },
{ name: de ? 'Failover-Routing' : 'Failover Routing', desc: de ? 'Automatisches Fallback' : 'Automatic fallback between models' },
],
},
{
id: 'llm', icon: Cpu,
title: de ? 'LLM Inferenz' : 'LLM Inference',
subtitle: de ? 'Lokale Sprachmodelle' : 'Local Language Models',
color: '#60a5fa', tier: 'inference',
tech: ['Qwen3-32B', 'Qwen3-Coder-30B', 'DeepSeek-R1-8B', 'Ollama'],
services: [
{ name: de ? 'Vollständig lokal' : 'Fully local', desc: de ? 'Daten verlassen nie den Server' : 'Data never leaves the server' },
{ name: de ? 'Air-Gap fähig' : 'Air-Gap Capable', desc: de ? 'Kein Internet erforderlich' : 'No internet required' },
{ name: de ? 'GPU-optimiert' : 'GPU-optimized', desc: de ? 'Dedizierte Inferenz-Hardware' : 'Dedicated inference hardware' },
],
},
{
id: 'embeddings', icon: Layers,
title: 'Embeddings',
subtitle: de ? 'Semantische Suche' : 'Semantic Search',
color: '#a78bfa', tier: 'inference',
tech: ['bge-m3', 'Qdrant Vector DB', 'Sentence-Transformers'],
services: [
{ name: 'RAG Pipeline', desc: de ? '75+ Rechtsquellen indexiert' : '75+ legal sources indexed' },
{ name: de ? 'Semantische Suche' : 'Semantic Search', desc: de ? 'Multi-linguale Einbettungen' : 'Multi-lingual embeddings' },
{ name: de ? 'Lokal' : 'Fully local', desc: de ? 'Keine externen APIs' : 'No external APIs' },
],
},
{
id: 'tools', icon: Wrench,
title: de ? 'KI-Tools' : 'AI Tools',
subtitle: de ? 'Web-Suche & MCP' : 'Web Search & MCP',
color: '#2dd4bf', tier: 'inference',
tech: ['SearXNG', 'MCP Protocol', 'Semgrep API', 'Gitleaks API'],
services: [
{ name: 'SearXNG', desc: de ? 'Anonymisierte EU-Websuche' : 'Anonymized EU web search' },
{ name: 'MCP Tools', desc: de ? 'Auditdokumente & Code-Findings' : 'Audit docs & code findings' },
{ name: de ? 'Kein US-Anbieter' : 'No US providers', desc: de ? '100% DSGVO-konform' : '100% GDPR-compliant' },
],
},
]
}
const LAYERS: { id: string; nodeIds: NodeId[]; tint: string; depth: number }[] = [
{ id: 'product', nodeIds: ['certifai', 'complai', 'scanner'], tint: '#a78bfa', depth: 24 },
{ id: 'proxy', nodeIds: ['litellm'], tint: '#fbbf24', depth: 12 },
{ id: 'inference', nodeIds: ['llm', 'embeddings', 'tools'], tint: '#8b5cf6', depth: 0 },
]
const CSS_KF = `
@keyframes v4FlowDown { from { stroke-dashoffset: 0 } to { stroke-dashoffset: -18px } }
@keyframes v4Pulse { 0%,100% { opacity:1;transform:scale(1) } 50% { opacity:.4;transform:scale(1.4) } }
@keyframes v4Caret { 0%,50% { opacity:1 } 51%,100% { opacity:0 } }
@keyframes v4DotFall {
0% { transform: translateY(-5px); opacity: 0; }
12% { opacity: 1; }
88% { opacity: 1; }
100% { transform: translateY(38px); opacity: 0; }
}
`
const MONO: React.CSSProperties = {
fontFamily: '"JetBrains Mono","SF Mono",ui-monospace,monospace',
fontVariantNumeric: 'tabular-nums',
}
// ── Theme detection ───────────────────────────────────────────────────────────
function useIsLight() {
const [isLight, setIsLight] = useState(false)
useEffect(() => {
const check = () => setIsLight(document.documentElement.classList.contains('theme-light'))
check()
const obs = new MutationObserver(check)
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => obs.disconnect()
}, [])
return isLight
}
// ── Ticker primitives ─────────────────────────────────────────────────────────
function useTicker(fn: () => void, min = 140, max = 360, skipChance = 0.1) {
const ref = useRef(fn)
ref.current = fn
useEffect(() => {
let tid: ReturnType<typeof setTimeout>
const loop = () => {
if (Math.random() > skipChance) ref.current()
tid = setTimeout(loop, min + Math.random() * (max - min))
}
loop()
return () => clearTimeout(tid)
}, [min, max, skipChance])
}
function TickerShell({ color, children, isLight }: { color: string; children: React.ReactNode; isLight: boolean }) {
return (
<div style={{
...MONO,
marginTop: 7, padding: '5px 9px',
background: isLight ? '#f1f5f9' : 'rgba(0,0,0,.38)',
border: `1px solid ${color}${isLight ? '55' : '55'}`, borderRadius: 6,
fontSize: 10, color: isLight ? '#475569' : 'rgba(236,233,247,.88)',
display: 'flex', alignItems: 'center', gap: 6,
whiteSpace: 'nowrap', overflow: 'hidden', height: 22,
}}>{children}</div>
)
}
function Caret({ color }: { color: string }) {
return (
<span style={{
display: 'inline-block', width: 5, height: 9, marginLeft: -2,
background: color, animation: 'v4Caret 1s step-end infinite',
}} />
)
}
// ── Per-node tickers ──────────────────────────────────────────────────────────
function TickCertifAI({ color, isLight }: { color: string; isLight: boolean }) {
const [n, setN] = useState(8421)
const [hash, setHash] = useState('9f3a…e10b')
const pool = 'abcdef0123456789'
const r = (k: number) => Array.from({ length: k }, () => pool[Math.floor(Math.random() * pool.length)]).join('')
useTicker(() => { setN(v => v + 1); setHash(`${r(4)}${r(4)}`) }, 1000, 2000, 0.1)
return (
<TickerShell color={color} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color, opacity: .85 }}>sig</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{n.toLocaleString()}</span>
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.55)' }}>{hash}</span>
</TickerShell>
)
}
function TickComplAI({ color, isLight }: { color: string; isLight: boolean }) {
const [evals, setEvals] = useState(1284)
const [rate, setRate] = useState(99.2)
useTicker(() => {
setEvals(v => v + 1 + Math.floor(Math.random() * 3))
setRate(r => Math.max(97, Math.min(99.9, r + (Math.random() - 0.5) * 0.4)))
}, 200, 500, 0.1)
return (
<TickerShell color={color} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color, opacity: .85 }}>eval</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{evals.toLocaleString()}</span>
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.45)' }}>pass</span>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}>{rate.toFixed(1)}%</span>
</TickerShell>
)
}
function TickScanner({ color, isLight }: { color: string; isLight: boolean }) {
const lines = [
{ k: 'PASS', c: '#16a34a', cd: '#4ade80', t: 'CWE-79 xss check' },
{ k: 'WARN', c: '#d97706', cd: '#fbbf24', t: 'drift: model v2.1→2.2' },
{ k: 'PASS', c: '#16a34a', cd: '#4ade80', t: 'bias: demographic parity' },
{ k: 'FAIL', c: '#dc2626', cd: '#f87171', t: 'license: GPL-3 detected' },
{ k: 'PASS', c: '#16a34a', cd: '#4ade80', t: 'prompt-inject: 214 vectors' },
{ k: 'SCAN', c: '#7c3aed', cd: '#a78bfa', t: 'artifact model-card.json' },
]
const [i, setI] = useState(0)
useTicker(() => setI(x => (x + 1) % lines.length), 700, 1200, 0.05)
const l = lines[i]
return (
<TickerShell color={color} isLight={isLight}>
<span style={{ color: isLight ? l.c : l.cd, fontWeight: 600, minWidth: 30 }}>{l.k}</span>
<span style={{ color: isLight ? '#334155' : 'rgba(236,233,247,.85)', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>{l.t}</span>
</TickerShell>
)
}
function TickLiteLLM({ color, isLight }: { color: string; isLight: boolean }) {
const [rps, setRps] = useState(428)
const [p50, setP50] = useState(84)
useTicker(() => {
setRps(v => Math.max(200, Math.min(800, v + (Math.random() - 0.5) * 60)))
setP50(v => Math.max(40, Math.min(160, v + (Math.random() - 0.5) * 20)))
}, 250, 500, 0.05)
return (
<TickerShell color={color} isLight={isLight}>
<span style={{ color: '#d97706' }}></span>
<span style={{ color, opacity: .9 }}>req/s</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{Math.round(rps)}</span>
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.4)' }}>·</span>
<span style={{ color, opacity: .9 }}>p50</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{Math.round(p50)}ms</span>
<Caret color={color} />
</TickerShell>
)
}
function TickLLM({ color, isLight }: { color: string; isLight: boolean }) {
const [tokens, setTokens] = useState(14832)
const [stream, setStream] = useState('t_a91f')
const pool = 'abcdef0123456789'
useTicker(() => {
setTokens(v => v + 1 + Math.floor(Math.random() * 5))
setStream('t_' + Array.from({ length: 4 }, () => pool[Math.floor(Math.random() * pool.length)]).join(''))
}, 120, 340, 0.15)
return (
<TickerShell color={color} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color, opacity: .85 }}>tok</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{tokens.toLocaleString()}</span>
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.35)' }}></span>
<span style={{ color }}>{stream}</span>
<Caret color={color} />
</TickerShell>
)
}
function TickEmbeddings({ color, isLight }: { color: string; isLight: boolean }) {
const [vecs, setVecs] = useState(284112)
useTicker(() => setVecs(v => v + 1 + Math.floor(Math.random() * 8)), 180, 420, 0.1)
return (
<TickerShell color={color} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color, opacity: .85 }}>idx</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{vecs.toLocaleString()}</span>
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.4)' }}>· 1024d</span>
<Caret color={color} />
</TickerShell>
)
}
function TickTools({ color, isLight }: { color: string; isLight: boolean }) {
const ops = [
'search("BSI C5 controls")', 'fetch eur-lex.europa.eu',
'grep -r "DSGVO"', 'read docs/policy.md',
'mcp.call(filesystem)', 'search("vLLM 0.6 release")',
]
const [i, setI] = useState(0)
useTicker(() => setI(x => (x + 1) % ops.length), 900, 1600, 0.05)
return (
<TickerShell color={color} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color, opacity: .85 }}>call</span>
<span style={{ color: isLight ? '#334155' : '#f5f3fc', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>{ops[i]}</span>
</TickerShell>
)
}
const NODE_TICKER: Record<NodeId, React.ComponentType<{ color: string; isLight: boolean }>> = {
certifai: TickCertifAI,
complai: TickComplAI,
scanner: TickScanner,
litellm: TickLiteLLM,
llm: TickLLM,
embeddings: TickEmbeddings,
tools: TickTools,
}
// ── Animated connector ────────────────────────────────────────────────────────
function LayerConnector({ tint }: { tint: string }) {
const tracks = [
{ x: '32%', primary: false },
{ x: '50%', primary: true },
{ x: '68%', primary: false },
]
return (
<div style={{ position: 'relative', height: 34, width: '100%', maxWidth: 960, margin: '0 auto' }}>
{tracks.map(({ x, primary }, ti) => {
const color = primary ? '#fbbf24' : tint
const dots = primary ? 4 : 3
const dur = primary ? 1.6 : 2.4
return (
<div key={ti} style={{ position: 'absolute', left: x, top: 0, bottom: 0, transform: 'translateX(-50%)' }}>
<div style={{
position: 'absolute', left: -0.75, top: 0, bottom: 0, width: 1.5,
background: `linear-gradient(180deg, ${color}00, ${color}55 40%, ${color}55 60%, ${color}00)`,
}} />
{Array.from({ length: dots }, (_, j) => (
<div key={j} style={{
position: 'absolute', top: 0, left: -3, width: 6, height: 6, borderRadius: '50%',
background: color, boxShadow: `0 0 7px ${color}`,
animation: `v4DotFall ${dur}s ${-(j / dots) * dur}s linear infinite`,
}} />
))}
</div>
)
})}
</div>
)
}
// ── Node card ─────────────────────────────────────────────────────────────────
function NodeCard({ node, selected, onClick, isLight }: {
node: NodeDef; selected: boolean; onClick: () => void; isLight: boolean
}) {
const [hover, setHover] = useState(false)
const active = hover || selected
const c = node.color
const Ticker = NODE_TICKER[node.id]
const Icon = node.icon
return (
<button
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
flex: 1,
background: active
? `linear-gradient(180deg, ${c}${isLight ? '20' : '33'}, ${c}${isLight ? '0a' : '12'})`
: isLight
? 'linear-gradient(180deg, #ffffff, #f8fafc)'
: 'linear-gradient(180deg, rgba(255,255,255,.055), rgba(255,255,255,.015))',
border: `1px solid ${active ? c : isLight ? 'rgba(0,0,0,.1)' : 'rgba(255,255,255,.14)'}`,
borderRadius: 12, padding: '12px 14px',
cursor: 'pointer', textAlign: 'left',
color: isLight ? '#1a1a2e' : '#ece9f7', fontFamily: 'inherit',
display: 'flex', flexDirection: 'column',
transition: 'all .2s ease',
transform: active ? 'translateY(-1px)' : 'none',
boxShadow: active
? `0 8px 26px ${c}44, 0 0 0 4px ${c}14`
: isLight ? '0 1px 4px rgba(0,0,0,.06)' : '0 1px 0 rgba(255,255,255,.04)',
minWidth: 0, position: 'relative',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
background: `linear-gradient(135deg, ${c}3a, ${c}10)`,
border: `1px solid ${c}66`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: c,
boxShadow: node.primary ? `inset 0 0 14px ${c}40` : 'none',
}}>
<Icon style={{ width: 18, height: 18 }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 600,
color: isLight ? '#1a1a2e' : '#f7f5fc',
letterSpacing: -0.1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{node.title}</div>
<div style={{
fontSize: 10.5,
color: isLight ? '#64748b' : 'rgba(236,233,247,.65)',
marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{node.subtitle}</div>
</div>
</div>
<Ticker color={c} isLight={isLight} />
{node.primary && (
<div style={{
position: 'absolute', top: -1, right: -1,
width: 6, height: 6, borderRadius: '50%',
background: '#fbbf24', boxShadow: '0 0 8px #fbbf24',
animation: 'v4Pulse 1.6s ease-in-out infinite',
}} />
)}
</button>
)
}
// ── 3D slab ───────────────────────────────────────────────────────────────────
function LayerSlab({ label, sublabel, nodes, tint, depth, selectedId, onSelect, isLight }: {
label: string; sublabel: string; nodes: NodeDef[]
tint: string; depth: number
selectedId: NodeId | null; onSelect: (id: NodeId) => void
isLight: boolean
}) {
const isProxy = nodes.length === 1 && !!nodes[0].primary
return (
<div style={{
position: 'relative', margin: '0 auto',
padding: '14px 20px 18px', width: '100%', maxWidth: 960,
background: isLight
? `linear-gradient(180deg, ${tint}18 0%, ${tint}08 60%, rgba(248,250,252,.98) 100%)`
: `linear-gradient(180deg, ${tint}26 0%, ${tint}12 60%, rgba(14,8,28,.85) 100%)`,
border: `1px solid ${tint}${isLight ? '44' : '66'}`,
borderRadius: 16,
boxShadow: isLight
? `0 -2px 16px ${tint}18, 0 8px 30px rgba(0,0,0,.05), inset 0 1px 0 ${tint}44`
: `0 -6px 30px ${tint}22, 0 24px 60px rgba(0,0,0,.6), inset 0 1px 0 ${tint}55, inset 0 -1px 0 rgba(0,0,0,.4)`,
transform: `perspective(2000px) rotateX(12deg) translateZ(${depth}px)`,
}}>
<div style={{
position: 'absolute', top: 0, left: 20, right: 20, height: 1,
background: `linear-gradient(90deg, transparent, ${tint}${isLight ? 'aa' : 'cc'}, transparent)`,
pointerEvents: 'none',
}} />
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{
fontSize: 9.5, letterSpacing: 2, textTransform: 'uppercase' as const, fontWeight: 600,
color: tint,
background: isLight ? `${tint}18` : `${tint}20`,
padding: '3px 9px', borderRadius: 99,
border: `1px solid ${tint}${isLight ? '44' : '50'}`, whiteSpace: 'nowrap',
}}>{label}</div>
<div style={{
fontSize: 11,
color: isLight ? '#64748b' : 'rgba(236,233,247,.55)',
whiteSpace: 'nowrap',
}}>{sublabel}</div>
</div>
<div style={{ display: 'flex', gap: 10, justifyContent: isProxy ? 'center' : undefined }}>
{isProxy ? (
<div style={{ width: '42%', minWidth: 260, display: 'flex' }}>
<NodeCard node={nodes[0]} selected={selectedId === nodes[0].id} onClick={() => onSelect(nodes[0].id)} isLight={isLight} />
</div>
) : (
nodes.map(n => (
<NodeCard key={n.id} node={n} selected={selectedId === n.id} onClick={() => onSelect(n.id)} isLight={isLight} />
))
)}
</div>
</div>
)
}
// ── Main slide ────────────────────────────────────────────────────────────────
export default function ArchitectureSlide({ lang }: ArchitectureSlideProps) {

View File

@@ -0,0 +1,289 @@
// CompetitionSlide data — extracted from CompetitionSlide.tsx
export type FeatureStatus = true | false | 'partial'
export interface ExtendedCompetitor {
name: string
flag: string
hq: string
hqCountry: string
offices: string[]
founded: number
employees: number
revenue: string
revenueNum: number
customers: number
customerCountries: string
fundingTotal: string
fundingRound: string
investors: string[]
aiUsage: 'full' | 'partial' | 'none'
aiDetail: { de: string; en: string }
market: { de: string; en: string }
pricing: string
isInternational: boolean
}
export interface ComparisonFeature {
de: string
en: string
bp: FeatureStatus
vanta: FeatureStatus
drata: FeatureStatus
sprinto: FeatureStatus
proliance: FeatureStatus
dataguard: FeatureStatus
heydata: FeatureStatus
isDiff: boolean
isUSP: boolean
group?: string
}
export interface PricingTier {
name: { de: string; en: string }
price: string
annual: string
notes: { de: string; en: string }
}
export interface CompetitorPricing {
name: string
flag: string
model: string
publicPricing: boolean
tiers: PricingTier[]
setupFee: string
isBP?: boolean
}
export interface AppSecCompetitor {
name: string
flag: string
hq: string
founded: number
employees: number
revenue: string
revenueNum: number
customers: string
funding: string
pricing: string
focus: { de: string; en: string }
}
export interface AppSecFeature {
de: string
en: string
bp: FeatureStatus
snyk: FeatureStatus
veracode: FeatureStatus
checkmarx: FeatureStatus
sonar: FeatureStatus
semgrep: FeatureStatus
pentera: FeatureStatus
invicti: FeatureStatus
intruder: FeatureStatus
isUSP: boolean
}
export const EXTENDED_COMPETITORS: ExtendedCompetitor[] = [
{
name: 'Vanta', flag: '\u{1F1FA}\u{1F1F8}', hq: 'San Francisco, CA', hqCountry: 'USA',
offices: ['New York', 'Dublin', 'London', 'Sydney'], founded: 2018, employees: 1695,
revenue: '$220M ARR', revenueNum: 220_000_000, customers: 12000, customerCountries: '58 L\u00e4nder',
fundingTotal: '$504M', fundingRound: 'Series D ($150M, $4.15B val.)',
investors: ['Sequoia Capital', 'Wellington Mgmt', 'Craft Ventures', 'CrowdStrike', 'Goldman Sachs', 'Y Combinator'],
aiUsage: 'full',
aiDetail: { de: 'Vanta AI Agent: Agentic Compliance, Policy-Gen, VRM-Agent, ISO 42001', en: 'Vanta AI Agent: Agentic compliance, policy gen, VRM agent, ISO 42001' },
market: { de: 'Global \u2014 SOC 2, ISO 27001, HIPAA, PCI DSS', en: 'Global \u2014 SOC 2, ISO 27001, HIPAA, PCI DSS' },
pricing: '$10K\u201380K/yr', isInternational: true,
},
{
name: 'Drata', flag: '\u{1F1FA}\u{1F1F8}', hq: 'San Diego, CA', hqCountry: 'USA',
offices: ['San Diego'], founded: 2020, employees: 732,
revenue: '$100M ARR', revenueNum: 100_000_000, customers: 8000, customerCountries: '80+ L\u00e4nder',
fundingTotal: '$328M', fundingRound: 'Series C ($200M, $2B val.)',
investors: ['ICONIQ Growth', 'GGV Capital', 'Salesforce Ventures', 'SentinelOne'],
aiUsage: 'full',
aiDetail: { de: 'AI Agent: VRM, Doc-Review, Risiko-Scoring, SafeBase AIQA', en: 'AI Agent: VRM, doc review, risk scoring, SafeBase AIQA' },
market: { de: 'Global \u2014 SOC 2, ISO, HIPAA, GDPR (oberfl.)', en: 'Global \u2014 SOC 2, ISO, HIPAA, GDPR (shallow)' },
pricing: '$10K\u2013100K/yr', isInternational: true,
},
{
name: 'Sprinto', flag: '\u{1F1EE}\u{1F1F3}', hq: 'Bangalore', hqCountry: 'Indien',
offices: ['Bangalore'], founded: 2020, employees: 316,
revenue: '$38M ARR', revenueNum: 38_000_000, customers: 3000, customerCountries: '75+ L\u00e4nder',
fundingTotal: '$32M', fundingRound: 'Series B ($20M, 2024)',
investors: ['Accel', 'Elevation Capital', 'Blume Ventures'],
aiUsage: 'full',
aiDetail: { de: 'Autonomous Compliance Engine, No-Code AI Agent Builder', en: 'Autonomous compliance engine, no-code AI agent builder' },
market: { de: 'Global SMBs \u2014 SOC 2, ISO, GDPR', en: 'Global SMBs \u2014 SOC 2, ISO, GDPR' },
pricing: '$6K\u201325K/yr', isInternational: true,
},
{
name: 'Proliance', flag: '\u{1F1E9}\u{1F1EA}', hq: 'Muenchen', hqCountry: 'Deutschland',
offices: ['Muenchen'], founded: 2017, employees: 65,
revenue: '~\u20AC3.9M', revenueNum: 3_900_000, customers: 2000, customerCountries: 'DACH',
fundingTotal: 'Pre-Seed', fundingRound: 'Pre-Seed (Possible Ventures)',
investors: ['Possible Ventures'],
aiUsage: 'none',
aiDetail: { de: 'Basis-Risikoerkennung, keine LLM/Agenten', en: 'Basic risk detection, no LLM/agents' },
market: { de: 'DACH \u2014 DSGVO, ePrivacy, KMUs', en: 'DACH \u2014 GDPR, ePrivacy, SMBs' },
pricing: '\u20AC1.5K\u20135.7K/yr', isInternational: false,
},
{
name: 'DataGuard', flag: '\u{1F1E9}\u{1F1EA}', hq: 'Muenchen', hqCountry: 'Deutschland',
offices: ['Muenchen', 'Berlin', 'London', 'Wien', 'Stockholm'], founded: 2017, employees: 250,
revenue: '~\u20AC52M', revenueNum: 52_000_000, customers: 4000, customerCountries: '50+ L\u00e4nder',
fundingTotal: '\u20AC80M', fundingRound: 'Series B (\u20AC61M, \u20AC341M val.)',
investors: ['Morgan Stanley Expansion', 'One Peak Partners'],
aiUsage: 'partial',
aiDetail: { de: 'Marketing: 40% weniger Aufwand, keine Agenten/LLM', en: 'Marketing: 40% effort reduction, no agents/LLM' },
market: { de: 'DACH + UK \u2014 GDPR, ISO 27001, TISAX', en: 'DACH + UK \u2014 GDPR, ISO 27001, TISAX' },
pricing: '\u20AC6K\u201324K+/yr', isInternational: false,
},
{
name: 'heyData', flag: '\u{1F1E9}\u{1F1EA}', hq: 'Berlin', hqCountry: 'Deutschland',
offices: ['Berlin'], founded: 2020, employees: 58,
revenue: '~\u20AC15M', revenueNum: 15_000_000, customers: 2000, customerCountries: 'EU',
fundingTotal: '\u20AC18.3M', fundingRound: 'Series A ($16.5M, Jan 2026)',
investors: ['Riverside Acceleration Capital'],
aiUsage: 'partial',
aiDetail: { de: 'KI-Marketing, keine sichtbaren Agenten', en: 'AI marketing, no visible agents' },
market: { de: 'DACH + EU \u2014 DSGVO, Kleinunternehmen', en: 'DACH + EU \u2014 GDPR, small businesses' },
pricing: '\u20AC1K\u20133.8K/yr', isInternational: false,
},
]
export const ALL_FEATURES: ComparisonFeature[] = [
// Code Security & DevSecOps
{ de: 'Code-Security & DevSecOps (6 Tools)', en: 'Code Security & DevSecOps (6 Tools)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
{ de: 'SAST (Static Application Security Testing)', en: 'SAST (Static Application Security Testing)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
{ de: 'DAST (Dynamic Application Security Testing)', en: 'DAST (Dynamic Application Security Testing)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
{ de: 'SBOM-Generator (CycloneDX/SPDX)', en: 'SBOM Generator (CycloneDX/SPDX)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
{ de: 'Container-Security Scanning (Trivy)', en: 'Container Security Scanning (Trivy)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
{ de: 'Secret Detection (Gitleaks)', en: 'Secret Detection (Gitleaks)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
{ de: 'LLM-Auto-Fix (automatische Code-Korrekturen)', en: 'LLM Auto-Fix (Automatic Code Corrections)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
{ de: 'Firmware & Embedded-Security', en: 'Firmware & Embedded Security', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
// KI & Daten
{ de: 'PII-Redaction LLM Gateway', en: 'PII Redaction LLM Gateway', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'ai-data' },
{ de: 'RAG mit 25.000+ Sicherheitskontrollen', en: 'RAG with 25,000+ Security Controls', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'ai-data' },
{ de: 'Autonomer KI-Support-Agent', en: 'Autonomous AI Support Agent', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'ai-data' },
{ de: 'KI-gest\u00fctzte Analyse', en: 'AI-Powered Analysis', bp: true, vanta: true, drata: true, sprinto: 'partial', proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'ai-data' },
// Regulatory Frameworks
{ de: 'DSGVO / GDPR', en: 'GDPR', bp: true, vanta: 'partial', drata: 'partial', sprinto: 'partial', proliance: true, dataguard: true, heydata: true, isDiff: false, isUSP: false, group: 'frameworks' },
{ de: 'AI Act', en: 'AI Act', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'frameworks' },
{ de: 'Cyber Resilience Act (CRA)', en: 'Cyber Resilience Act (CRA)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'frameworks' },
{ de: 'NIS2-Richtlinie', en: 'NIS2 Directive', bp: true, vanta: false, drata: 'partial', sprinto: false, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
{ de: 'SOC 2', en: 'SOC 2', bp: 'partial', vanta: true, drata: true, sprinto: true, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
{ de: 'ISO 27001', en: 'ISO 27001', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
{ de: 'HIPAA', en: 'HIPAA', bp: false, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
{ de: 'TISAX', en: 'TISAX', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
{ de: 'HinSchG (Whistleblower)', en: 'HinSchG (Whistleblower)', bp: true, vanta: false, drata: false, sprinto: false, proliance: 'partial', dataguard: false, heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
// Compliance Documentation
{ de: 'VVT (Art. 30 DSGVO)', en: 'Records of Processing (Art. 30)', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: true, isDiff: false, isUSP: false, group: 'documentation' },
{ de: 'TOM-Dokumentation', en: 'TOM Documentation', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: 'partial', isDiff: false, isUSP: false, group: 'documentation' },
{ de: 'DSFA (Art. 35 DSGVO)', en: 'DPIA (Art. 35 GDPR)', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'documentation' },
{ de: 'L\u00f6schkonzept / L\u00f6schfristen', en: 'Deletion Concept / Retention', bp: true, vanta: false, drata: false, sprinto: false, proliance: 'partial', dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'documentation' },
{ de: 'Policy-Generator', en: 'Policy Generator', bp: true, vanta: true, drata: true, sprinto: 'partial', proliance: true, dataguard: true, heydata: 'partial', isDiff: false, isUSP: false, group: 'documentation' },
{ de: 'Dokument-Generator (61 Vorlagen)', en: 'Document Generator (61 Templates)', bp: true, vanta: 'partial', drata: 'partial', sprinto: false, proliance: 'partial', dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'documentation' },
// Operative Compliance
{ de: 'Audit-Management', en: 'Audit Management', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'operations' },
{ de: 'Risikobewertung', en: 'Risk Assessment', bp: true, vanta: true, drata: true, sprinto: true, proliance: 'partial', dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'operations' },
{ de: 'Incident Response', en: 'Incident Response', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'operations' },
{ de: 'Consent Management', en: 'Consent Management', bp: true, vanta: false, drata: false, sprinto: false, proliance: 'partial', dataguard: false, heydata: 'partial', isDiff: false, isUSP: false, group: 'operations' },
{ de: 'Betroffenenrechte (DSR)', en: 'Data Subject Requests', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: 'partial', isDiff: false, isUSP: false, group: 'operations' },
{ de: 'Auftragsverarbeiter-Mgmt', en: 'Vendor/Processor Management', bp: true, vanta: true, drata: true, sprinto: 'partial', proliance: true, dataguard: true, heydata: 'partial', isDiff: false, isUSP: false, group: 'operations' },
{ de: 'Schulungs-Management', en: 'Training Management', bp: true, vanta: 'partial', drata: 'partial', sprinto: 'partial', proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'operations' },
{ de: 'Whistleblower-Portal', en: 'Whistleblower Portal', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'operations' },
// Technical Platform
{ de: 'Continuous Monitoring', en: 'Continuous Monitoring', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'platform' },
{ de: 'Automatische Evidence-Sammlung', en: 'Automatic Evidence Collection', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'platform' },
{ de: 'API / SDK', en: 'API / SDK', bp: true, vanta: true, drata: true, sprinto: 'partial', proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'platform' },
{ de: 'Integrations (Slack, Jira, etc.)', en: 'Integrations (Slack, Jira, etc.)', bp: 'partial', vanta: true, drata: true, sprinto: true, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'platform' },
{ de: 'Datensouveraenitaet (EU)', en: 'Data Sovereignty (EU)', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: true, isDiff: false, isUSP: false, group: 'platform' },
{ de: 'Mehrmandantenf\u00e4hig', en: 'Multi-Tenancy', bp: true, vanta: true, drata: true, sprinto: true, proliance: 'partial', dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'platform' },
{ de: 'Data Mapping / Datenfluss', en: 'Data Mapping / Data Flow', bp: true, vanta: 'partial', drata: 'partial', sprinto: false, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'platform' },
{ de: 'Cookie-Banner Generator', en: 'Cookie Banner Generator', bp: true, vanta: false, drata: false, sprinto: false, proliance: 'partial', dataguard: false, heydata: 'partial', isDiff: false, isUSP: false, group: 'platform' },
{ de: 'IPFS/Blockchain (optional)', en: 'IPFS/Blockchain (optional)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'platform' },
// Industry
{ de: 'Maschinenbau-Branchenfokus', en: 'Manufacturing Industry Focus', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'industry' },
]
export const DACH_NOTE = {
de: 'Weitere DACH-Anbieter: Secjur (Hamburg, KI-Compliance, ~\u20AC5.5M Seed), Usercentrics (nur CMP, $117M Rev), Caralegal (Privacy/Risk, M&A 2025), 2B Advice (Legacy, 20+ J.), OneTrust (US-Enterprise, $500M+ ARR). Keiner kombiniert DSGVO + Code-Security + Self-Hosted KI.',
en: 'Other DACH players: Secjur (Hamburg, AI compliance, ~\u20AC5.5M seed), Usercentrics (CMP only, $117M rev), Caralegal (privacy/risk, M&A 2025), 2B Advice (legacy, 20+ yrs), OneTrust (US enterprise, $500M+ ARR). None combines GDPR + code security + self-hosted AI.',
}
export const PRICING_COMPARISON: CompetitorPricing[] = [
{ name: 'ComplAI', flag: '\u{1F1E9}\u{1F1EA}', model: 'Cloud (BSI DE)', publicPricing: true, tiers: [
{ name: { de: 'Starter (<10 MA)', en: 'Starter (<10 emp.)' }, price: '\u20AC300/mo', annual: '\u20AC3.600/yr', notes: { de: '380+ Regularien, modular', en: '380+ regulations, modular' } },
{ name: { de: 'Professional (10-250)', en: 'Professional (10-250)' }, price: '\u20AC1.250\u20133.333/mo', annual: '\u20AC15.000\u201340.000/yr', notes: { de: 'Alle Module, Priority Support', en: 'All modules, priority support' } },
{ name: { de: 'Enterprise (250+)', en: 'Enterprise (250+)' }, price: 'ab \u20AC4.167/mo', annual: 'ab \u20AC50.000/yr', notes: { de: 'Dedicated, Custom, SLA', en: 'Dedicated, custom, SLA' } },
], setupFee: '\u20AC0', isBP: true },
{ name: 'Vanta', flag: '\u{1F1FA}\u{1F1F8}', model: 'SaaS', publicPricing: false, tiers: [
{ name: { de: 'Startup', en: 'Startup' }, price: '~$500/mo', annual: '~$6K/yr', notes: { de: '1 Framework, <50 MA', en: '1 framework, <50 employees' } },
{ name: { de: 'Business', en: 'Business' }, price: '~$2K/mo', annual: '~$25K/yr', notes: { de: 'Multi-Framework, VRM', en: 'Multi-framework, VRM' } },
{ name: { de: 'Enterprise', en: 'Enterprise' }, price: '~$5-7K/mo', annual: '~$60-80K/yr', notes: { de: 'Custom, SSO, RBAC', en: 'Custom, SSO, RBAC' } },
], setupFee: '~$5-15K' },
{ name: 'Drata', flag: '\u{1F1FA}\u{1F1F8}', model: 'SaaS', publicPricing: false, tiers: [
{ name: { de: 'Foundation', en: 'Foundation' }, price: '~$500/mo', annual: '~$5-8K/yr', notes: { de: '1 Framework, Basis', en: '1 framework, basic' } },
{ name: { de: 'Business', en: 'Business' }, price: '~$1.5K/mo', annual: '~$18-20K/yr', notes: { de: 'Multi-Framework, API', en: 'Multi-framework, API' } },
{ name: { de: 'Enterprise', en: 'Enterprise' }, price: '~$4-8K/mo', annual: '~$50-100K/yr', notes: { de: 'SafeBase, Custom', en: 'SafeBase, custom' } },
], setupFee: '~$5-10K' },
{ name: 'Sprinto', flag: '\u{1F1EE}\u{1F1F3}', model: 'SaaS', publicPricing: false, tiers: [
{ name: { de: 'Growth', en: 'Growth' }, price: '~$350/mo', annual: '~$4K/yr', notes: { de: '1 Framework, KMU', en: '1 framework, SMB' } },
{ name: { de: 'Business', en: 'Business' }, price: '~$1K/mo', annual: '~$12K/yr', notes: { de: 'Multi-Framework', en: 'Multi-framework' } },
{ name: { de: 'Enterprise', en: 'Enterprise' }, price: '~$2K+/mo', annual: '~$25K+/yr', notes: { de: 'Custom Integrations', en: 'Custom integrations' } },
], setupFee: '~$2-5K' },
{ name: 'Proliance', flag: '\u{1F1E9}\u{1F1EA}', model: 'SaaS', publicPricing: true, tiers: [
{ name: { de: 'Basis', en: 'Basic' }, price: '\u20AC99/mo', annual: '\u20AC1.188/yr', notes: { de: 'DSGVO-Grundlagen', en: 'GDPR basics' } },
{ name: { de: 'Professional', en: 'Professional' }, price: '\u20AC249/mo', annual: '\u20AC2.988/yr', notes: { de: '+ Audit, VVT', en: '+ Audit, records' } },
{ name: { de: 'Enterprise', en: 'Enterprise' }, price: '\u20AC499/mo', annual: '\u20AC5.988/yr', notes: { de: 'Multi-Standort, DSB', en: 'Multi-location, DPO' } },
], setupFee: '\u20AC0' },
{ name: 'DataGuard', flag: '\u{1F1E9}\u{1F1EA}', model: 'SaaS + Beratung', publicPricing: false, tiers: [
{ name: { de: 'Starter', en: 'Starter' }, price: '~\u20AC250/mo', annual: '~\u20AC3K/yr', notes: { de: 'Nur Software', en: 'Software only' } },
{ name: { de: 'Managed', en: 'Managed' }, price: '~\u20AC1K/mo', annual: '~\u20AC12K/yr', notes: { de: '+ Ext. DSB', en: '+ Ext. DPO' } },
{ name: { de: 'Enterprise', en: 'Enterprise' }, price: '~\u20AC2K+/mo', annual: '~\u20AC24K+/yr', notes: { de: 'ISO 27001 + TISAX', en: 'ISO 27001 + TISAX' } },
], setupFee: '~\u20AC2-5K' },
{ name: 'heyData', flag: '\u{1F1E9}\u{1F1EA}', model: 'SaaS', publicPricing: true, tiers: [
{ name: { de: 'Essential', en: 'Essential' }, price: '\u20AC83/mo', annual: '\u20AC996/yr', notes: { de: '1-19 MA, DSGVO', en: '1-19 empl., GDPR' } },
{ name: { de: 'Pro', en: 'Pro' }, price: '\u20AC199/mo', annual: '\u20AC2.388/yr', notes: { de: '20-99 MA, DSB', en: '20-99 empl., DPO' } },
{ name: { de: 'Premium', en: 'Premium' }, price: '\u20AC333/mo', annual: '\u20AC3.996/yr', notes: { de: '100+ MA, Audit', en: '100+ empl., audit' } },
], setupFee: '\u20AC0' },
]
export const APPSEC_COMPETITORS: AppSecCompetitor[] = [
{ name: 'Snyk', flag: '\u{1F1FA}\u{1F1F8}', hq: 'Boston', founded: 2015, employees: 1200, revenue: '~$300M ARR', revenueNum: 300_000_000, customers: '3.000+', funding: '$850M (Series G, $7.4B)', pricing: '$25K\u2013100K+/yr', focus: { de: 'SCA + SAST, Developer-First', en: 'SCA + SAST, developer-first' } },
{ name: 'Veracode', flag: '\u{1F1FA}\u{1F1F8}', hq: 'Burlington, MA', founded: 2006, employees: 1300, revenue: '~$300M', revenueNum: 300_000_000, customers: '3.500+', funding: 'PE (Thoma Bravo, $2.5B)', pricing: '$50K\u2013500K+/yr', focus: { de: 'SAST + DAST + SCA, Enterprise', en: 'SAST + DAST + SCA, enterprise' } },
{ name: 'Checkmarx', flag: '\u{1F1EE}\u{1F1F1}', hq: 'Tel Aviv', founded: 2006, employees: 1000, revenue: '~$250M', revenueNum: 250_000_000, customers: '1.800+', funding: 'PE (Hellman & Friedman)', pricing: '$40K\u2013300K+/yr', focus: { de: 'SAST + DAST + SCA + API', en: 'SAST + DAST + SCA + API' } },
{ name: 'SonarSource', flag: '\u{1F1E8}\u{1F1ED}', hq: 'Genf', founded: 2008, employees: 500, revenue: '~$250M', revenueNum: 250_000_000, customers: '400K+ Devs', funding: '$412M (Series D)', pricing: '$15K\u2013150K+/yr', focus: { de: 'Code-Qualitaet + SAST', en: 'Code quality + SAST' } },
{ name: 'Semgrep', flag: '\u{1F1FA}\u{1F1F8}', hq: 'San Francisco', founded: 2020, employees: 150, revenue: '~$30M ARR', revenueNum: 30_000_000, customers: '1.500+', funding: '$100M (Series C)', pricing: '$10K\u2013100K+/yr', focus: { de: 'Open-Source SAST, Supply Chain', en: 'Open-source SAST, supply chain' } },
{ name: 'Pentera', flag: '\u{1F1EE}\u{1F1F1}', hq: 'Tel Aviv', founded: 2015, employees: 400, revenue: '~$100M', revenueNum: 100_000_000, customers: '900+', funding: '$189M (Series C)', pricing: '$50K\u2013250K+/yr', focus: { de: 'Automatisiertes Pentesting/BAS', en: 'Automated pentesting/BAS' } },
{ name: 'Invicti', flag: '\u{1F1FA}\u{1F1F8}', hq: 'Austin, TX', founded: 2018, employees: 500, revenue: '~$100M', revenueNum: 100_000_000, customers: '3.000+', funding: 'PE (Turn/River)', pricing: '$15K\u2013100K+/yr', focus: { de: 'DAST (Acunetix + Netsparker)', en: 'DAST (Acunetix + Netsparker)' } },
{ name: 'Intruder', flag: '\u{1F1EC}\u{1F1E7}', hq: 'London', founded: 2015, employees: 100, revenue: '~$10M', revenueNum: 10_000_000, customers: '2.500+', funding: '$15M (Series A)', pricing: '$1.5K\u201320K+/yr', focus: { de: 'Vulnerability Scanner, SMB', en: 'Vulnerability scanner, SMB' } },
]
export const APPSEC_FEATURES: AppSecFeature[] = [
{ de: 'SAST (Static Analysis)', en: 'SAST (Static Analysis)', bp: true, snyk: true, veracode: true, checkmarx: true, sonar: true, semgrep: true, pentera: false, invicti: false, intruder: false, isUSP: false },
{ de: 'DAST (Dynamic Analysis)', en: 'DAST (Dynamic Analysis)', bp: true, snyk: false, veracode: true, checkmarx: true, sonar: false, semgrep: false, pentera: true, invicti: true, intruder: true, isUSP: false },
{ de: 'SCA (Software Composition)', en: 'SCA (Software Composition)', bp: true, snyk: true, veracode: true, checkmarx: true, sonar: 'partial', semgrep: 'partial', pentera: false, invicti: false, intruder: false, isUSP: false },
{ de: 'LLM-basierte Auto-Fixes', en: 'LLM-Based Auto-Fixes', bp: true, snyk: 'partial', veracode: 'partial', checkmarx: 'partial', sonar: 'partial', semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: false },
{ de: 'SBOM-Generierung', en: 'SBOM Generation', bp: true, snyk: true, veracode: 'partial', checkmarx: 'partial', sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: false },
{ de: 'Container-Security', en: 'Container Security', bp: true, snyk: true, veracode: true, checkmarx: true, sonar: false, semgrep: 'partial', pentera: false, invicti: false, intruder: false, isUSP: false },
{ de: 'Secret Detection', en: 'Secret Detection', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: 'partial', semgrep: true, pentera: false, invicti: false, intruder: false, isUSP: false },
{ de: 'IaC Scanning', en: 'IaC Scanning', bp: true, snyk: true, veracode: false, checkmarx: false, sonar: false, semgrep: true, pentera: false, invicti: false, intruder: false, isUSP: false },
{ de: 'CI/CD-Integration', en: 'CI/CD Integration', bp: true, snyk: true, veracode: true, checkmarx: true, sonar: true, semgrep: true, pentera: 'partial', invicti: 'partial', intruder: 'partial', isUSP: false },
{ de: 'API-Security Testing', en: 'API Security Testing', bp: true, snyk: false, veracode: 'partial', checkmarx: true, sonar: false, semgrep: false, pentera: 'partial', invicti: true, intruder: 'partial', isUSP: false },
{ de: 'Automatisiertes Pentesting', en: 'Automated Pentesting', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: false, semgrep: false, pentera: true, invicti: false, intruder: true, isUSP: false },
{ de: 'Self-Hosted / On-Premise', en: 'Self-Hosted / On-Premise', bp: true, snyk: false, veracode: false, checkmarx: 'partial', sonar: true, semgrep: 'partial', pentera: 'partial', invicti: 'partial', intruder: false, isUSP: false },
]
export const GROUP_LABELS: Record<string, { de: string; en: string; color: string }> = {
'code-security': { de: 'Code Security & DevSecOps', en: 'Code Security & DevSecOps', color: 'text-red-400' },
'ai-data': { de: 'KI & Daten', en: 'AI & Data', color: 'text-purple-400' },
'frameworks': { de: 'Regulatorische Frameworks', en: 'Regulatory Frameworks', color: 'text-blue-400' },
'documentation': { de: 'Compliance-Dokumentation', en: 'Compliance Documentation', color: 'text-emerald-400' },
'operations': { de: 'Operative Compliance', en: 'Operative Compliance', color: 'text-amber-400' },
'platform': { de: 'Technische Plattform', en: 'Technical Platform', color: 'text-cyan-400' },
'industry': { de: 'Branche & Spezial', en: 'Industry & Specialty', color: 'text-orange-400' },
}

View File

@@ -0,0 +1,216 @@
'use client'
import { Language } from '@/lib/types'
import { Users, DollarSign, Globe, Cpu, Star, Check, X, Minus } from 'lucide-react'
import BrandName from '../ui/BrandName'
import {
type FeatureStatus, type ExtendedCompetitor, type ComparisonFeature,
type AppSecCompetitor, type AppSecFeature, GROUP_LABELS,
} from './CompetitionSlide.data'
export function StatusIcon({ value }: { value: FeatureStatus }) {
if (value === true) return <Check className="w-3.5 h-3.5 text-green-400 mx-auto" />
if (value === 'partial') return <Minus className="w-3.5 h-3.5 text-yellow-400 mx-auto" />
return <X className="w-3.5 h-3.5 text-white/15 mx-auto" />
}
export function AiBadge({ level, lang }: { level: 'full' | 'partial' | 'none'; lang: Language }) {
const colors = { full: 'bg-green-500/15 text-green-400', partial: 'bg-yellow-500/15 text-yellow-400', none: 'bg-white/5 text-white/30' }
const labels = { full: { de: 'KI', en: 'AI' }, partial: { de: 'Basis', en: 'Basic' }, none: { de: 'Keine', en: 'None' } }
return (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${colors[level]}`}>
<Cpu className="w-2.5 h-2.5 inline mr-0.5 -mt-px" />
{labels[level][lang]}
</span>
)
}
export function ratio(a: number, b: number): string {
if (b === 0) return '\u2014'
const r = a / b
if (r >= 1_000_000) return `${(r / 1_000_000).toFixed(1)}M`
if (r >= 1_000) return `${(r / 1_000).toFixed(0)}k`
return r.toFixed(0)
}
export function CompetitorCard({ competitor: c, lang }: { competitor: ExtendedCompetitor; lang: Language }) {
return (
<div className="bg-white/[0.04] border border-white/5 rounded-xl p-2.5 text-[11px]">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">
<span className="text-sm">{c.flag}</span>
<span className="font-semibold text-white/80 text-xs">{c.name}</span>
</div>
<AiBadge level={c.aiUsage} lang={lang} />
</div>
<div className="text-[10px] text-white/40 mb-1.5 truncate" title={`HQ: ${c.hq}, ${c.hqCountry}` + (c.offices.length > 1 ? ` | Offices: ${c.offices.join(', ')}` : '')}>
<span className="text-white/55">{c.hq}, {c.hqCountry}</span>
{c.offices.length > 1 && (
<span className="ml-1">+ {c.offices.join(', ')}</span>
)}
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-white/50">
<div className="flex items-center gap-1">
<span className="text-white/30">{lang === 'de' ? 'Gr.' : 'Est.'}</span>
<span className="text-white/70">{c.founded}</span>
</div>
<div className="flex items-center gap-1">
<Users className="w-2.5 h-2.5 text-white/30" />
<span className="text-white/70">{c.employees.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1">
<DollarSign className="w-2.5 h-2.5 text-white/30" />
<span className="text-white/70">{c.revenue}</span>
</div>
<div className="flex items-center gap-1">
<Globe className="w-2.5 h-2.5 text-white/30" />
<span className="text-white/70">{c.customers.toLocaleString()} {lang === 'de' ? 'Kd.' : 'cust.'} ({c.customerCountries})</span>
</div>
</div>
<div className="mt-1.5 pt-1.5 border-t border-white/5">
<div className="text-white/40">
<span className="text-white/60 font-medium">{c.fundingTotal}</span>
<span className="ml-1 text-[10px]">{c.fundingRound}</span>
</div>
{c.investors.length > 0 && (
<div className="text-[10px] text-white/30 mt-0.5 truncate" title={c.investors.join(', ')}>
{c.investors.slice(0, 3).join(', ')}{c.investors.length > 3 ? ' +' + (c.investors.length - 3) : ''}
</div>
)}
</div>
<div className="mt-1 text-[10px] text-white/35 truncate" title={c.market[lang]}>
{c.market[lang]}
</div>
</div>
)
}
export function FeatureTable({
features, lang, cols, labels,
}: {
features: ComparisonFeature[]
lang: Language
cols: readonly string[]
labels: string[]
highlight?: boolean
}) {
const rowElements: React.ReactNode[] = []
let lastGroup = ''
features.forEach((f, i) => {
const grp = f.group || ''
if (grp && grp !== lastGroup) {
const gl = GROUP_LABELS[grp]
if (gl) {
rowElements.push(
<tr key={`grp-${grp}`} className="bg-white/[0.02]">
<td colSpan={cols.length + 1} className={`py-1.5 px-2 text-[10px] font-bold uppercase tracking-wider ${gl.color}`}>
{lang === 'de' ? gl.de : gl.en}
</td>
</tr>
)
}
lastGroup = grp
}
rowElements.push(
<tr key={i} className={`border-b border-white/5 ${f.isDiff ? 'bg-indigo-500/5' : ''}`}>
<td className="py-1.5 px-2 flex items-center gap-1.5">
{f.isDiff && <Star className="w-3 h-3 text-yellow-400 shrink-0" />}
<span className={f.isDiff ? 'text-white font-medium' : 'text-white/60'}>
{lang === 'de' ? f.de : f.en}
</span>
</td>
{cols.map(col => (
<td key={col} className="py-1.5 px-1.5 text-center">
<StatusIcon value={f[col as keyof ComparisonFeature] as FeatureStatus} />
</td>
))}
</tr>
)
})
return (
<div className="overflow-x-auto mt-1 mb-1">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-white/10">
<th className="text-left py-1.5 px-2 text-white/40 font-medium min-w-[180px]">Feature</th>
{labels.map((l, idx) => (
<th key={l} className={`py-1.5 px-1.5 font-medium text-center whitespace-nowrap ${idx === 0 ? 'text-indigo-400' : 'text-white/50'}`}>
{idx === 0 ? <BrandName className="text-[11px]" /> : l}
</th>
))}
</tr>
</thead>
<tbody>{rowElements}</tbody>
</table>
</div>
)
}
export function AppSecCard({ competitor: c, lang }: { competitor: AppSecCompetitor; lang: Language }) {
return (
<div className="bg-white/[0.04] border border-white/5 rounded-xl p-2 text-[11px]">
<div className="flex items-center gap-1.5 mb-1">
<span className="text-sm">{c.flag}</span>
<span className="font-semibold text-white/80 text-xs">{c.name}</span>
</div>
<div className="text-[10px] text-white/40 mb-1 truncate">{c.hq} · {c.founded}</div>
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 text-white/50">
<div className="flex items-center gap-1">
<Users className="w-2.5 h-2.5 text-white/30" />
<span className="text-white/70">{c.employees.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1">
<DollarSign className="w-2.5 h-2.5 text-white/30" />
<span className="text-white/70 truncate">{c.revenue}</span>
</div>
</div>
<div className="mt-1 pt-1 border-t border-white/5 text-[10px]">
<div className="text-white/40 truncate">{c.funding}</div>
<div className="text-white/50 mt-0.5">{c.pricing}</div>
</div>
<div className="mt-1 text-[10px] text-white/35 truncate" title={c.focus[lang]}>
{c.focus[lang]}
</div>
</div>
)
}
export function AppSecFeatureTable({ features, lang, highlight }: { features: AppSecFeature[]; lang: Language; highlight?: boolean }) {
const cols = ['bp', 'snyk', 'veracode', 'checkmarx', 'sonar', 'semgrep', 'pentera', 'invicti', 'intruder'] as const
const labels = ['ComplAI', 'Snyk', 'Veracode', 'Checkmarx', 'Sonar', 'Semgrep', 'Pentera', 'Invicti', 'Intruder']
return (
<div className="overflow-x-auto mt-1 mb-1">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-white/10">
<th className="text-left py-1.5 px-2 text-white/40 font-medium min-w-[160px]">Feature</th>
{labels.map((l, idx) => (
<th key={l} className={`py-1.5 px-1 font-medium text-center whitespace-nowrap ${idx === 0 ? 'text-indigo-400' : 'text-white/50'}`}>
{idx === 0 ? <BrandName className="text-[11px]" /> : l}
</th>
))}
</tr>
</thead>
<tbody>
{features.map((f, i) => (
<tr key={i} className={`border-b border-white/5 ${highlight && f.isUSP ? 'bg-indigo-500/5' : ''}`}>
<td className="py-1.5 px-2 flex items-center gap-1.5">
{f.isUSP && highlight && <Star className="w-3 h-3 text-yellow-400 shrink-0" />}
<span className={f.isUSP && highlight ? 'text-white font-medium' : 'text-white/60'}>
{lang === 'de' ? f.de : f.en}
</span>
</td>
{cols.map(col => (
<td key={col} className="py-1.5 px-1 text-center">
<StatusIcon value={f[col] as FeatureStatus} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -5,12 +5,17 @@ import { Language, PitchFeature, PitchCompetitor } from '@/lib/types'
import { t } from '@/lib/i18n'
import {
ChevronDown, ChevronRight, Globe, Building2, Users, TrendingUp,
DollarSign, Cpu, Star, Check, X, Minus, Shield, Tag,
DollarSign, Cpu, Star, Check, X, Minus, Shield,
} from 'lucide-react'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import GlassCard from '../ui/GlassCard'
import BrandName from '../ui/BrandName'
import {
type FeatureStatus, type ComparisonFeature,
EXTENDED_COMPETITORS, ALL_FEATURES, DACH_NOTE, PRICING_COMPARISON, APPSEC_COMPETITORS, APPSEC_FEATURES,
} from './CompetitionSlide.data'
import { StatusIcon, AiBadge, ratio, CompetitorCard, FeatureTable, AppSecCard, AppSecFeatureTable } from './CompetitionSlide.parts'
interface CompetitionSlideProps {
lang: Language
@@ -18,438 +23,8 @@ interface CompetitionSlideProps {
competitors: PitchCompetitor[]
}
// ─── Extended Competitor Data ──────────────────────────────────────────────────
interface ExtendedCompetitor {
name: string
flag: string
hq: string
hqCountry: string
offices: string[]
founded: number
employees: number
revenue: string
revenueNum: number
customers: number
customerCountries: string
fundingTotal: string
fundingRound: string
investors: string[]
aiUsage: 'full' | 'partial' | 'none'
aiDetail: { de: string; en: string }
market: { de: string; en: string }
pricing: string
isInternational: boolean
}
const EXTENDED_COMPETITORS: ExtendedCompetitor[] = [
{
name: 'Vanta',
flag: '🇺🇸',
hq: 'San Francisco, CA',
hqCountry: 'USA',
offices: ['New York', 'Dublin', 'London', 'Sydney'],
founded: 2018,
employees: 1695,
revenue: '$220M ARR',
revenueNum: 220_000_000,
customers: 12000,
customerCountries: '58 Länder',
fundingTotal: '$504M',
fundingRound: 'Series D ($150M, $4.15B val.)',
investors: ['Sequoia Capital', 'Wellington Mgmt', 'Craft Ventures', 'CrowdStrike', 'Goldman Sachs', 'Y Combinator'],
aiUsage: 'full',
aiDetail: { de: 'Vanta AI Agent: Agentic Compliance, Policy-Gen, VRM-Agent, ISO 42001', en: 'Vanta AI Agent: Agentic compliance, policy gen, VRM agent, ISO 42001' },
market: { de: 'Global — SOC 2, ISO 27001, HIPAA, PCI DSS', en: 'Global — SOC 2, ISO 27001, HIPAA, PCI DSS' },
pricing: '$10K80K/yr',
isInternational: true,
},
{
name: 'Drata',
flag: '🇺🇸',
hq: 'San Diego, CA',
hqCountry: 'USA',
offices: ['San Diego'],
founded: 2020,
employees: 732,
revenue: '$100M ARR',
revenueNum: 100_000_000,
customers: 8000,
customerCountries: '80+ Länder',
fundingTotal: '$328M',
fundingRound: 'Series C ($200M, $2B val.)',
investors: ['ICONIQ Growth', 'GGV Capital', 'Salesforce Ventures', 'SentinelOne'],
aiUsage: 'full',
aiDetail: { de: 'AI Agent: VRM, Doc-Review, Risiko-Scoring, SafeBase AIQA', en: 'AI Agent: VRM, doc review, risk scoring, SafeBase AIQA' },
market: { de: 'Global — SOC 2, ISO, HIPAA, GDPR (oberfl.)', en: 'Global — SOC 2, ISO, HIPAA, GDPR (shallow)' },
pricing: '$10K100K/yr',
isInternational: true,
},
{
name: 'Sprinto',
flag: '🇮🇳',
hq: 'Bangalore',
hqCountry: 'Indien',
offices: ['Bangalore'],
founded: 2020,
employees: 316,
revenue: '$38M ARR',
revenueNum: 38_000_000,
customers: 3000,
customerCountries: '75+ Länder',
fundingTotal: '$32M',
fundingRound: 'Series B ($20M, 2024)',
investors: ['Accel', 'Elevation Capital', 'Blume Ventures'],
aiUsage: 'full',
aiDetail: { de: 'Autonomous Compliance Engine, No-Code AI Agent Builder', en: 'Autonomous compliance engine, no-code AI agent builder' },
market: { de: 'Global SMBs — SOC 2, ISO, GDPR', en: 'Global SMBs — SOC 2, ISO, GDPR' },
pricing: '$6K25K/yr',
isInternational: true,
},
{
name: 'Proliance',
flag: '🇩🇪',
hq: 'Muenchen',
hqCountry: 'Deutschland',
offices: ['Muenchen'],
founded: 2017,
employees: 65,
revenue: '~€3.9M',
revenueNum: 3_900_000,
customers: 2000,
customerCountries: 'DACH',
fundingTotal: 'Pre-Seed',
fundingRound: 'Pre-Seed (Possible Ventures)',
investors: ['Possible Ventures'],
aiUsage: 'none',
aiDetail: { de: 'Basis-Risikoerkennung, keine LLM/Agenten', en: 'Basic risk detection, no LLM/agents' },
market: { de: 'DACH — DSGVO, ePrivacy, KMUs', en: 'DACH — GDPR, ePrivacy, SMBs' },
pricing: '€1.5K5.7K/yr',
isInternational: false,
},
{
name: 'DataGuard',
flag: '🇩🇪',
hq: 'Muenchen',
hqCountry: 'Deutschland',
offices: ['Muenchen', 'Berlin', 'London', 'Wien', 'Stockholm'],
founded: 2017,
employees: 250,
revenue: '~€52M',
revenueNum: 52_000_000,
customers: 4000,
customerCountries: '50+ Länder',
fundingTotal: '€80M',
fundingRound: 'Series B (€61M, €341M val.)',
investors: ['Morgan Stanley Expansion', 'One Peak Partners'],
aiUsage: 'partial',
aiDetail: { de: 'Marketing: 40% weniger Aufwand, keine Agenten/LLM', en: 'Marketing: 40% effort reduction, no agents/LLM' },
market: { de: 'DACH + UK — GDPR, ISO 27001, TISAX', en: 'DACH + UK — GDPR, ISO 27001, TISAX' },
pricing: '€6K24K+/yr',
isInternational: false,
},
{
name: 'heyData',
flag: '🇩🇪',
hq: 'Berlin',
hqCountry: 'Deutschland',
offices: ['Berlin'],
founded: 2020,
employees: 58,
revenue: '~€15M',
revenueNum: 15_000_000,
customers: 2000,
customerCountries: 'EU',
fundingTotal: '€18.3M',
fundingRound: 'Series A ($16.5M, Jan 2026)',
investors: ['Riverside Acceleration Capital'],
aiUsage: 'partial',
aiDetail: { de: 'KI-Marketing, keine sichtbaren Agenten', en: 'AI marketing, no visible agents' },
market: { de: 'DACH + EU — DSGVO, Kleinunternehmen', en: 'DACH + EU — GDPR, small businesses' },
pricing: '€1K3.8K/yr',
isInternational: false,
},
]
// ─── Feature Comparison Data ───────────────────────────────────────────────────
type FeatureStatus = true | false | 'partial'
interface ComparisonFeature {
de: string
en: string
bp: FeatureStatus
vanta: FeatureStatus
drata: FeatureStatus
sprinto: FeatureStatus
proliance: FeatureStatus
dataguard: FeatureStatus
heydata: FeatureStatus
isDiff: boolean
isUSP: boolean
group?: string
}
const ALL_FEATURES: ComparisonFeature[] = [
// ── Code Security & DevSecOps ──
{ de: 'Code-Security & DevSecOps (6 Tools)', en: 'Code Security & DevSecOps (6 Tools)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
{ de: 'SAST (Static Application Security Testing)', en: 'SAST (Static Application Security Testing)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
{ de: 'DAST (Dynamic Application Security Testing)', en: 'DAST (Dynamic Application Security Testing)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
{ de: 'SBOM-Generator (CycloneDX/SPDX)', en: 'SBOM Generator (CycloneDX/SPDX)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
{ de: 'Container-Security Scanning (Trivy)', en: 'Container Security Scanning (Trivy)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
{ de: 'Secret Detection (Gitleaks)', en: 'Secret Detection (Gitleaks)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
{ de: 'LLM-Auto-Fix (automatische Code-Korrekturen)', en: 'LLM Auto-Fix (Automatic Code Corrections)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
{ de: 'Firmware & Embedded-Security', en: 'Firmware & Embedded Security', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'code-security' },
// ── KI & Daten ──
{ de: 'PII-Redaction LLM Gateway', en: 'PII Redaction LLM Gateway', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'ai-data' },
{ de: 'RAG mit 25.000+ Sicherheitskontrollen', en: 'RAG with 25,000+ Security Controls', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'ai-data' },
{ de: 'Autonomer KI-Support-Agent', en: 'Autonomous AI Support Agent', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'ai-data' },
{ de: 'KI-gestützte Analyse', en: 'AI-Powered Analysis', bp: true, vanta: true, drata: true, sprinto: 'partial', proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'ai-data' },
// ── Regulatorische Frameworks ──
{ de: 'DSGVO / GDPR', en: 'GDPR', bp: true, vanta: 'partial', drata: 'partial', sprinto: 'partial', proliance: true, dataguard: true, heydata: true, isDiff: false, isUSP: false, group: 'frameworks' },
{ de: 'AI Act', en: 'AI Act', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'frameworks' },
{ de: 'Cyber Resilience Act (CRA)', en: 'Cyber Resilience Act (CRA)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'frameworks' },
{ de: 'NIS2-Richtlinie', en: 'NIS2 Directive', bp: true, vanta: false, drata: 'partial', sprinto: false, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
{ de: 'SOC 2', en: 'SOC 2', bp: 'partial', vanta: true, drata: true, sprinto: true, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
{ de: 'ISO 27001', en: 'ISO 27001', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
{ de: 'HIPAA', en: 'HIPAA', bp: false, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: false, heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
{ de: 'TISAX', en: 'TISAX', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
{ de: 'HinSchG (Whistleblower)', en: 'HinSchG (Whistleblower)', bp: true, vanta: false, drata: false, sprinto: false, proliance: 'partial', dataguard: false, heydata: false, isDiff: false, isUSP: false, group: 'frameworks' },
// ── Compliance-Dokumentation ──
{ de: 'VVT (Art. 30 DSGVO)', en: 'Records of Processing (Art. 30)', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: true, isDiff: false, isUSP: false, group: 'documentation' },
{ de: 'TOM-Dokumentation', en: 'TOM Documentation', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: 'partial', isDiff: false, isUSP: false, group: 'documentation' },
{ de: 'DSFA (Art. 35 DSGVO)', en: 'DPIA (Art. 35 GDPR)', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'documentation' },
{ de: 'Löschkonzept / Löschfristen', en: 'Deletion Concept / Retention', bp: true, vanta: false, drata: false, sprinto: false, proliance: 'partial', dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'documentation' },
{ de: 'Policy-Generator', en: 'Policy Generator', bp: true, vanta: true, drata: true, sprinto: 'partial', proliance: true, dataguard: true, heydata: 'partial', isDiff: false, isUSP: false, group: 'documentation' },
{ de: 'Dokument-Generator (61 Vorlagen)', en: 'Document Generator (61 Templates)', bp: true, vanta: 'partial', drata: 'partial', sprinto: false, proliance: 'partial', dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'documentation' },
// ── Operative Compliance ──
{ de: 'Audit-Management', en: 'Audit Management', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'operations' },
{ de: 'Risikobewertung', en: 'Risk Assessment', bp: true, vanta: true, drata: true, sprinto: true, proliance: 'partial', dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'operations' },
{ de: 'Incident Response', en: 'Incident Response', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'operations' },
{ de: 'Consent Management', en: 'Consent Management', bp: true, vanta: false, drata: false, sprinto: false, proliance: 'partial', dataguard: false, heydata: 'partial', isDiff: false, isUSP: false, group: 'operations' },
{ de: 'Betroffenenrechte (DSR)', en: 'Data Subject Requests', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: 'partial', isDiff: false, isUSP: false, group: 'operations' },
{ de: 'Auftragsverarbeiter-Mgmt', en: 'Vendor/Processor Management', bp: true, vanta: true, drata: true, sprinto: 'partial', proliance: true, dataguard: true, heydata: 'partial', isDiff: false, isUSP: false, group: 'operations' },
{ de: 'Schulungs-Management', en: 'Training Management', bp: true, vanta: 'partial', drata: 'partial', sprinto: 'partial', proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'operations' },
{ de: 'Whistleblower-Portal', en: 'Whistleblower Portal', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'operations' },
// ── Technische Plattform ──
{ de: 'Continuous Monitoring', en: 'Continuous Monitoring', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'platform' },
{ de: 'Automatische Evidence-Sammlung', en: 'Automatic Evidence Collection', bp: true, vanta: true, drata: true, sprinto: true, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'platform' },
{ de: 'API / SDK', en: 'API / SDK', bp: true, vanta: true, drata: true, sprinto: 'partial', proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'platform' },
{ de: 'Integrations (Slack, Jira, etc.)', en: 'Integrations (Slack, Jira, etc.)', bp: 'partial', vanta: true, drata: true, sprinto: true, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'platform' },
{ de: 'Datensouveraenitaet (EU)', en: 'Data Sovereignty (EU)', bp: true, vanta: false, drata: false, sprinto: false, proliance: true, dataguard: true, heydata: true, isDiff: false, isUSP: false, group: 'platform' },
{ de: 'Mehrmandantenfähig', en: 'Multi-Tenancy', bp: true, vanta: true, drata: true, sprinto: true, proliance: 'partial', dataguard: true, heydata: false, isDiff: false, isUSP: false, group: 'platform' },
{ de: 'Data Mapping / Datenfluss', en: 'Data Mapping / Data Flow', bp: true, vanta: 'partial', drata: 'partial', sprinto: false, proliance: false, dataguard: 'partial', heydata: false, isDiff: false, isUSP: false, group: 'platform' },
{ de: 'Cookie-Banner Generator', en: 'Cookie Banner Generator', bp: true, vanta: false, drata: false, sprinto: false, proliance: 'partial', dataguard: false, heydata: 'partial', isDiff: false, isUSP: false, group: 'platform' },
{ de: 'IPFS/Blockchain (optional)', en: 'IPFS/Blockchain (optional)', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'platform' },
// ── Branche & Spezial ──
{ de: 'Maschinenbau-Branchenfokus', en: 'Manufacturing Industry Focus', bp: true, vanta: false, drata: false, sprinto: false, proliance: false, dataguard: false, heydata: false, isDiff: true, isUSP: true, group: 'industry' },
]
// ─── DACH Landscape Note ───────────────────────────────────────────────────────
const DACH_NOTE = {
de: 'Weitere DACH-Anbieter: Secjur (Hamburg, KI-Compliance, ~€5.5M Seed), Usercentrics (nur CMP, $117M Rev), Caralegal (Privacy/Risk, M&A 2025), 2B Advice (Legacy, 20+ J.), OneTrust (US-Enterprise, $500M+ ARR). Keiner kombiniert DSGVO + Code-Security + Self-Hosted KI.',
en: 'Other DACH players: Secjur (Hamburg, AI compliance, ~€5.5M seed), Usercentrics (CMP only, $117M rev), Caralegal (privacy/risk, M&A 2025), 2B Advice (legacy, 20+ yrs), OneTrust (US enterprise, $500M+ ARR). None combines GDPR + code security + self-hosted AI.',
}
// ─── Pricing Comparison Data ──────────────────────────────────────────────────
interface PricingTier {
name: { de: string; en: string }
price: string
annual: string
notes: { de: string; en: string }
}
interface CompetitorPricing {
name: string
flag: string
model: string
publicPricing: boolean
tiers: PricingTier[]
setupFee: string
isBP?: boolean
}
const PRICING_COMPARISON: CompetitorPricing[] = [
{
name: 'ComplAI',
flag: '🇩🇪',
model: 'Cloud (BSI DE)',
publicPricing: true,
tiers: [
{ name: { de: 'Starter (<10 MA)', en: 'Starter (<10 emp.)' }, price: '€300/mo', annual: '€3.600/yr', notes: { de: '380+ Regularien, modular', en: '380+ regulations, modular' } },
{ name: { de: 'Professional (10-250)', en: 'Professional (10-250)' }, price: '€1.2503.333/mo', annual: '€15.00040.000/yr', notes: { de: 'Alle Module, Priority Support', en: 'All modules, priority support' } },
{ name: { de: 'Enterprise (250+)', en: 'Enterprise (250+)' }, price: 'ab €4.167/mo', annual: 'ab €50.000/yr', notes: { de: 'Dedicated, Custom, SLA', en: 'Dedicated, custom, SLA' } },
],
setupFee: '€0',
isBP: true,
},
{
name: 'Vanta',
flag: '🇺🇸',
model: 'SaaS',
publicPricing: false,
tiers: [
{ name: { de: 'Startup', en: 'Startup' }, price: '~$500/mo', annual: '~$6K/yr', notes: { de: '1 Framework, <50 MA', en: '1 framework, <50 employees' } },
{ name: { de: 'Business', en: 'Business' }, price: '~$2K/mo', annual: '~$25K/yr', notes: { de: 'Multi-Framework, VRM', en: 'Multi-framework, VRM' } },
{ name: { de: 'Enterprise', en: 'Enterprise' }, price: '~$5-7K/mo', annual: '~$60-80K/yr', notes: { de: 'Custom, SSO, RBAC', en: 'Custom, SSO, RBAC' } },
],
setupFee: '~$5-15K',
},
{
name: 'Drata',
flag: '🇺🇸',
model: 'SaaS',
publicPricing: false,
tiers: [
{ name: { de: 'Foundation', en: 'Foundation' }, price: '~$500/mo', annual: '~$5-8K/yr', notes: { de: '1 Framework, Basis', en: '1 framework, basic' } },
{ name: { de: 'Business', en: 'Business' }, price: '~$1.5K/mo', annual: '~$18-20K/yr', notes: { de: 'Multi-Framework, API', en: 'Multi-framework, API' } },
{ name: { de: 'Enterprise', en: 'Enterprise' }, price: '~$4-8K/mo', annual: '~$50-100K/yr', notes: { de: 'SafeBase, Custom', en: 'SafeBase, custom' } },
],
setupFee: '~$5-10K',
},
{
name: 'Sprinto',
flag: '🇮🇳',
model: 'SaaS',
publicPricing: false,
tiers: [
{ name: { de: 'Growth', en: 'Growth' }, price: '~$350/mo', annual: '~$4K/yr', notes: { de: '1 Framework, KMU', en: '1 framework, SMB' } },
{ name: { de: 'Business', en: 'Business' }, price: '~$1K/mo', annual: '~$12K/yr', notes: { de: 'Multi-Framework', en: 'Multi-framework' } },
{ name: { de: 'Enterprise', en: 'Enterprise' }, price: '~$2K+/mo', annual: '~$25K+/yr', notes: { de: 'Custom Integrations', en: 'Custom integrations' } },
],
setupFee: '~$2-5K',
},
{
name: 'Proliance',
flag: '🇩🇪',
model: 'SaaS',
publicPricing: true,
tiers: [
{ name: { de: 'Basis', en: 'Basic' }, price: '€99/mo', annual: '€1.188/yr', notes: { de: 'DSGVO-Grundlagen', en: 'GDPR basics' } },
{ name: { de: 'Professional', en: 'Professional' }, price: '€249/mo', annual: '€2.988/yr', notes: { de: '+ Audit, VVT', en: '+ Audit, records' } },
{ name: { de: 'Enterprise', en: 'Enterprise' }, price: '€499/mo', annual: '€5.988/yr', notes: { de: 'Multi-Standort, DSB', en: 'Multi-location, DPO' } },
],
setupFee: '€0',
},
{
name: 'DataGuard',
flag: '🇩🇪',
model: 'SaaS + Beratung',
publicPricing: false,
tiers: [
{ name: { de: 'Starter', en: 'Starter' }, price: '~€250/mo', annual: '~€3K/yr', notes: { de: 'Nur Software', en: 'Software only' } },
{ name: { de: 'Managed', en: 'Managed' }, price: '~€1K/mo', annual: '~€12K/yr', notes: { de: '+ Ext. DSB', en: '+ Ext. DPO' } },
{ name: { de: 'Enterprise', en: 'Enterprise' }, price: '~€2K+/mo', annual: '~€24K+/yr', notes: { de: 'ISO 27001 + TISAX', en: 'ISO 27001 + TISAX' } },
],
setupFee: '~€2-5K',
},
{
name: 'heyData',
flag: '🇩🇪',
model: 'SaaS',
publicPricing: true,
tiers: [
{ name: { de: 'Essential', en: 'Essential' }, price: '€83/mo', annual: '€996/yr', notes: { de: '1-19 MA, DSGVO', en: '1-19 empl., GDPR' } },
{ name: { de: 'Pro', en: 'Pro' }, price: '€199/mo', annual: '€2.388/yr', notes: { de: '20-99 MA, DSB', en: '20-99 empl., DPO' } },
{ name: { de: 'Premium', en: 'Premium' }, price: '€333/mo', annual: '€3.996/yr', notes: { de: '100+ MA, Audit', en: '100+ empl., audit' } },
],
setupFee: '€0',
},
]
// ─── AppSec / Pentesting Competitor Data ─────────────────────────────────────
interface AppSecCompetitor {
name: string
flag: string
hq: string
founded: number
employees: number
revenue: string
revenueNum: number
customers: string
funding: string
pricing: string
focus: { de: string; en: string }
}
const APPSEC_COMPETITORS: AppSecCompetitor[] = [
{ name: 'Snyk', flag: '🇺🇸', hq: 'Boston', founded: 2015, employees: 1200, revenue: '~$300M ARR', revenueNum: 300_000_000, customers: '3.000+', funding: '$850M (Series G, $7.4B)', pricing: '$25K100K+/yr', focus: { de: 'SCA + SAST, Developer-First', en: 'SCA + SAST, developer-first' } },
{ name: 'Veracode', flag: '🇺🇸', hq: 'Burlington, MA', founded: 2006, employees: 1300, revenue: '~$300M', revenueNum: 300_000_000, customers: '3.500+', funding: 'PE (Thoma Bravo, $2.5B)', pricing: '$50K500K+/yr', focus: { de: 'SAST + DAST + SCA, Enterprise', en: 'SAST + DAST + SCA, enterprise' } },
{ name: 'Checkmarx', flag: '🇮🇱', hq: 'Tel Aviv', founded: 2006, employees: 1000, revenue: '~$250M', revenueNum: 250_000_000, customers: '1.800+', funding: 'PE (Hellman & Friedman)', pricing: '$40K300K+/yr', focus: { de: 'SAST + DAST + SCA + API', en: 'SAST + DAST + SCA + API' } },
{ name: 'SonarSource', flag: '🇨🇭', hq: 'Genf', founded: 2008, employees: 500, revenue: '~$250M', revenueNum: 250_000_000, customers: '400K+ Devs', funding: '$412M (Series D)', pricing: '$15K150K+/yr', focus: { de: 'Code-Qualitaet + SAST', en: 'Code quality + SAST' } },
{ name: 'Semgrep', flag: '🇺🇸', hq: 'San Francisco', founded: 2020, employees: 150, revenue: '~$30M ARR', revenueNum: 30_000_000, customers: '1.500+', funding: '$100M (Series C)', pricing: '$10K100K+/yr', focus: { de: 'Open-Source SAST, Supply Chain', en: 'Open-source SAST, supply chain' } },
{ name: 'Pentera', flag: '🇮🇱', hq: 'Tel Aviv', founded: 2015, employees: 400, revenue: '~$100M', revenueNum: 100_000_000, customers: '900+', funding: '$189M (Series C)', pricing: '$50K250K+/yr', focus: { de: 'Automatisiertes Pentesting/BAS', en: 'Automated pentesting/BAS' } },
{ name: 'Invicti', flag: '🇺🇸', hq: 'Austin, TX', founded: 2018, employees: 500, revenue: '~$100M', revenueNum: 100_000_000, customers: '3.000+', funding: 'PE (Turn/River)', pricing: '$15K100K+/yr', focus: { de: 'DAST (Acunetix + Netsparker)', en: 'DAST (Acunetix + Netsparker)' } },
{ name: 'Intruder', flag: '🇬🇧', hq: 'London', founded: 2015, employees: 100, revenue: '~$10M', revenueNum: 10_000_000, customers: '2.500+', funding: '$15M (Series A)', pricing: '$1.5K20K+/yr', focus: { de: 'Vulnerability Scanner, SMB', en: 'Vulnerability scanner, SMB' } },
]
interface AppSecFeature {
de: string
en: string
bp: FeatureStatus
snyk: FeatureStatus
veracode: FeatureStatus
checkmarx: FeatureStatus
sonar: FeatureStatus
semgrep: FeatureStatus
pentera: FeatureStatus
invicti: FeatureStatus
intruder: FeatureStatus
isUSP: boolean
}
const APPSEC_FEATURES: AppSecFeature[] = [
// Pure AppSec Features only (Compliance USPs removed — belong on Compliance tabs)
{ de: 'SAST (Static Analysis)', en: 'SAST (Static Analysis)', bp: true, snyk: true, veracode: true, checkmarx: true, sonar: true, semgrep: true, pentera: false, invicti: false, intruder: false, isUSP: false },
{ de: 'DAST (Dynamic Analysis)', en: 'DAST (Dynamic Analysis)', bp: true, snyk: false, veracode: true, checkmarx: true, sonar: false, semgrep: false, pentera: true, invicti: true, intruder: true, isUSP: false },
{ de: 'SCA (Software Composition)', en: 'SCA (Software Composition)', bp: true, snyk: true, veracode: true, checkmarx: true, sonar: 'partial', semgrep: 'partial', pentera: false, invicti: false, intruder: false, isUSP: false },
{ de: 'LLM-basierte Auto-Fixes', en: 'LLM-Based Auto-Fixes', bp: true, snyk: 'partial', veracode: 'partial', checkmarx: 'partial', sonar: 'partial', semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: false },
{ de: 'SBOM-Generierung', en: 'SBOM Generation', bp: true, snyk: true, veracode: 'partial', checkmarx: 'partial', sonar: false, semgrep: false, pentera: false, invicti: false, intruder: false, isUSP: false },
{ de: 'Container-Security', en: 'Container Security', bp: true, snyk: true, veracode: true, checkmarx: true, sonar: false, semgrep: 'partial', pentera: false, invicti: false, intruder: false, isUSP: false },
{ de: 'Secret Detection', en: 'Secret Detection', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: 'partial', semgrep: true, pentera: false, invicti: false, intruder: false, isUSP: false },
{ de: 'IaC Scanning', en: 'IaC Scanning', bp: true, snyk: true, veracode: false, checkmarx: false, sonar: false, semgrep: true, pentera: false, invicti: false, intruder: false, isUSP: false },
{ de: 'CI/CD-Integration', en: 'CI/CD Integration', bp: true, snyk: true, veracode: true, checkmarx: true, sonar: true, semgrep: true, pentera: 'partial', invicti: 'partial', intruder: 'partial', isUSP: false },
{ de: 'API-Security Testing', en: 'API Security Testing', bp: true, snyk: false, veracode: 'partial', checkmarx: true, sonar: false, semgrep: false, pentera: 'partial', invicti: true, intruder: 'partial', isUSP: false },
{ de: 'Automatisiertes Pentesting', en: 'Automated Pentesting', bp: true, snyk: false, veracode: false, checkmarx: false, sonar: false, semgrep: false, pentera: true, invicti: false, intruder: true, isUSP: false },
{ de: 'Self-Hosted / On-Premise', en: 'Self-Hosted / On-Premise', bp: true, snyk: false, veracode: false, checkmarx: 'partial', sonar: true, semgrep: 'partial', pentera: 'partial', invicti: 'partial', intruder: false, isUSP: false },
]
// ─── Helpers ───────────────────────────────────────────────────────────────────
function StatusIcon({ value }: { value: FeatureStatus }) {
if (value === true) return <Check className="w-3.5 h-3.5 text-green-400 mx-auto" />
if (value === 'partial') return <Minus className="w-3.5 h-3.5 text-yellow-400 mx-auto" />
return <X className="w-3.5 h-3.5 text-white/15 mx-auto" />
}
function AiBadge({ level, lang }: { level: 'full' | 'partial' | 'none'; lang: Language }) {
const colors = { full: 'bg-green-500/15 text-green-400', partial: 'bg-yellow-500/15 text-yellow-400', none: 'bg-white/5 text-white/30' }
const labels = { full: { de: 'KI', en: 'AI' }, partial: { de: 'Basis', en: 'Basic' }, none: { de: 'Keine', en: 'None' } }
return (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${colors[level]}`}>
<Cpu className="w-2.5 h-2.5 inline mr-0.5 -mt-px" />
{labels[level][lang]}
</span>
)
}
function ratio(a: number, b: number): string {
if (b === 0) return '—'
const r = a / b
if (r >= 1_000_000) return `${(r / 1_000_000).toFixed(1)}M`
if (r >= 1_000) return `${(r / 1_000).toFixed(0)}k`
return r.toFixed(0)
}
// ─── Section Accordion ─────────────────────────────────────────────────────────
function SectionHeader({
@@ -844,205 +419,3 @@ export default function CompetitionSlide({ lang, features, competitors }: Compet
)
}
// ─── Sub-Components ────────────────────────────────────────────────────────────
function CompetitorCard({ competitor: c, lang }: { competitor: ExtendedCompetitor; lang: Language }) {
return (
<div className="bg-white/[0.04] border border-white/5 rounded-xl p-2.5 text-[11px]">
{/* Header */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1.5">
<span className="text-sm">{c.flag}</span>
<span className="font-semibold text-white/80 text-xs">{c.name}</span>
</div>
<AiBadge level={c.aiUsage} lang={lang} />
</div>
{/* HQ + Offices */}
<div className="text-[10px] text-white/40 mb-1.5 truncate" title={`HQ: ${c.hq}, ${c.hqCountry}` + (c.offices.length > 1 ? ` | Offices: ${c.offices.join(', ')}` : '')}>
<span className="text-white/55">{c.hq}, {c.hqCountry}</span>
{c.offices.length > 1 && (
<span className="ml-1">+ {c.offices.join(', ')}</span>
)}
</div>
{/* KPIs */}
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-white/50">
<div className="flex items-center gap-1">
<span className="text-white/30">{lang === 'de' ? 'Gr.' : 'Est.'}</span>
<span className="text-white/70">{c.founded}</span>
</div>
<div className="flex items-center gap-1">
<Users className="w-2.5 h-2.5 text-white/30" />
<span className="text-white/70">{c.employees.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1">
<DollarSign className="w-2.5 h-2.5 text-white/30" />
<span className="text-white/70">{c.revenue}</span>
</div>
<div className="flex items-center gap-1">
<Globe className="w-2.5 h-2.5 text-white/30" />
<span className="text-white/70">{c.customers.toLocaleString()} {lang === 'de' ? 'Kd.' : 'cust.'} ({c.customerCountries})</span>
</div>
</div>
{/* Funding + Investors */}
<div className="mt-1.5 pt-1.5 border-t border-white/5">
<div className="text-white/40">
<span className="text-white/60 font-medium">{c.fundingTotal}</span>
<span className="ml-1 text-[10px]">{c.fundingRound}</span>
</div>
{c.investors.length > 0 && (
<div className="text-[10px] text-white/30 mt-0.5 truncate" title={c.investors.join(', ')}>
{c.investors.slice(0, 3).join(', ')}{c.investors.length > 3 ? ' +' + (c.investors.length - 3) : ''}
</div>
)}
</div>
{/* Market */}
<div className="mt-1 text-[10px] text-white/35 truncate" title={c.market[lang]}>
{c.market[lang]}
</div>
</div>
)
}
const GROUP_LABELS: Record<string, { de: string; en: string; color: string }> = {
'code-security': { de: 'Code Security & DevSecOps', en: 'Code Security & DevSecOps', color: 'text-red-400' },
'ai-data': { de: 'KI & Daten', en: 'AI & Data', color: 'text-purple-400' },
'frameworks': { de: 'Regulatorische Frameworks', en: 'Regulatory Frameworks', color: 'text-blue-400' },
'documentation': { de: 'Compliance-Dokumentation', en: 'Compliance Documentation', color: 'text-emerald-400' },
'operations': { de: 'Operative Compliance', en: 'Operative Compliance', color: 'text-amber-400' },
'platform': { de: 'Technische Plattform', en: 'Technical Platform', color: 'text-cyan-400' },
'industry': { de: 'Branche & Spezial', en: 'Industry & Specialty', color: 'text-orange-400' },
}
function FeatureTable({
features,
lang,
cols,
labels,
}: {
features: ComparisonFeature[]
lang: Language
cols: readonly string[]
labels: string[]
highlight?: boolean
}) {
// Build rows with group headers
const rowElements: React.ReactNode[] = []
let lastGroup = ''
features.forEach((f, i) => {
const grp = f.group || ''
if (grp && grp !== lastGroup) {
const gl = GROUP_LABELS[grp]
if (gl) {
rowElements.push(
<tr key={`grp-${grp}`} className="bg-white/[0.02]">
<td colSpan={cols.length + 1} className={`py-1.5 px-2 text-[10px] font-bold uppercase tracking-wider ${gl.color}`}>
{lang === 'de' ? gl.de : gl.en}
</td>
</tr>
)
}
lastGroup = grp
}
rowElements.push(
<tr key={i} className={`border-b border-white/5 ${f.isDiff ? 'bg-indigo-500/5' : ''}`}>
<td className="py-1.5 px-2 flex items-center gap-1.5">
{f.isDiff && <Star className="w-3 h-3 text-yellow-400 shrink-0" />}
<span className={f.isDiff ? 'text-white font-medium' : 'text-white/60'}>
{lang === 'de' ? f.de : f.en}
</span>
</td>
{cols.map(col => (
<td key={col} className="py-1.5 px-1.5 text-center">
<StatusIcon value={f[col as keyof ComparisonFeature] as FeatureStatus} />
</td>
))}
</tr>
)
})
return (
<div className="overflow-x-auto mt-1 mb-1">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-white/10">
<th className="text-left py-1.5 px-2 text-white/40 font-medium min-w-[180px]">Feature</th>
{labels.map((l, idx) => (
<th key={l} className={`py-1.5 px-1.5 font-medium text-center whitespace-nowrap ${idx === 0 ? 'text-indigo-400' : 'text-white/50'}`}>
{idx === 0 ? <BrandName className="text-[11px]" /> : l}
</th>
))}
</tr>
</thead>
<tbody>{rowElements}</tbody>
</table>
</div>
)
}
function AppSecCard({ competitor: c, lang }: { competitor: AppSecCompetitor; lang: Language }) {
return (
<div className="bg-white/[0.04] border border-white/5 rounded-xl p-2 text-[11px]">
<div className="flex items-center gap-1.5 mb-1">
<span className="text-sm">{c.flag}</span>
<span className="font-semibold text-white/80 text-xs">{c.name}</span>
</div>
<div className="text-[10px] text-white/40 mb-1 truncate">{c.hq} · {c.founded}</div>
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 text-white/50">
<div className="flex items-center gap-1">
<Users className="w-2.5 h-2.5 text-white/30" />
<span className="text-white/70">{c.employees.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1">
<DollarSign className="w-2.5 h-2.5 text-white/30" />
<span className="text-white/70 truncate">{c.revenue}</span>
</div>
</div>
<div className="mt-1 pt-1 border-t border-white/5 text-[10px]">
<div className="text-white/40 truncate">{c.funding}</div>
<div className="text-white/50 mt-0.5">{c.pricing}</div>
</div>
<div className="mt-1 text-[10px] text-white/35 truncate" title={c.focus[lang]}>
{c.focus[lang]}
</div>
</div>
)
}
function AppSecFeatureTable({ features, lang, highlight }: { features: AppSecFeature[]; lang: Language; highlight?: boolean }) {
const cols = ['bp', 'snyk', 'veracode', 'checkmarx', 'sonar', 'semgrep', 'pentera', 'invicti', 'intruder'] as const
const labels = ['ComplAI', 'Snyk', 'Veracode', 'Checkmarx', 'Sonar', 'Semgrep', 'Pentera', 'Invicti', 'Intruder']
return (
<div className="overflow-x-auto mt-1 mb-1">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-white/10">
<th className="text-left py-1.5 px-2 text-white/40 font-medium min-w-[160px]">Feature</th>
{labels.map((l, idx) => (
<th key={l} className={`py-1.5 px-1 font-medium text-center whitespace-nowrap ${idx === 0 ? 'text-indigo-400' : 'text-white/50'}`}>
{idx === 0 ? <BrandName className="text-[11px]" /> : l}
</th>
))}
</tr>
</thead>
<tbody>
{features.map((f, i) => (
<tr key={i} className={`border-b border-white/5 ${highlight && f.isUSP ? 'bg-indigo-500/5' : ''}`}>
<td className="py-1.5 px-2 flex items-center gap-1.5">
{f.isUSP && highlight && <Star className="w-3 h-3 text-yellow-400 shrink-0" />}
<span className={f.isUSP && highlight ? 'text-white font-medium' : 'text-white/60'}>
{lang === 'de' ? f.de : f.en}
</span>
</td>
{cols.map(col => (
<td key={col} className="py-1.5 px-1 text-center">
<StatusIcon value={f[col] as FeatureStatus} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,221 @@
// ExecutiveSummarySlide PDF generation — extracted from ExecutiveSummarySlide.tsx
import { Language, PitchData } from '@/lib/types'
import { formatEur } from '@/lib/i18n'
interface PdfParams {
lang: Language
data: PitchData
es: Record<string, string>
funding: PitchData['funding']
tam: { value_eur: number } | undefined
sam: { value_eur: number } | undefined
som: { value_eur: number } | undefined
amountLabel: string
de: boolean
}
export function generatePdfHtml({ lang, data, es, funding, tam, sam, som, amountLabel, de }: PdfParams): string {
const tamVal = tam ? formatEur(tam.value_eur, lang) : '\u2014'
const samVal = sam ? formatEur(sam.value_eur, lang) : '\u2014'
const somVal = som ? formatEur(som.value_eur, lang) : '\u2014'
const teamHtml = data.team?.map(m =>
`<div class="founder"><strong>${m.name}</strong><span>${de ? m.role_de : m.role_en}</span></div>`
).join('') || ''
const useOfFundsHtml = funding?.use_of_funds?.map(f =>
`<div class="fund-row"><span>${de ? f.label_de : f.label_en}</span><strong>${f.percentage}%</strong></div>`
).join('') || ''
return `<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="utf-8">
<title>BreakPilot ComplAI \u2014 Executive Summary</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
@page { size: 297mm 680mm; margin: 30mm 12mm; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Plus Jakarta Sans', -apple-system, sans-serif;
background: #fff; color: #1a1a2e;
width: 100%; max-width: 273mm;
position: relative; font-size: 10.5px; line-height: 1.45;
}
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
.top-bar { height: 6px; background: linear-gradient(90deg, #6366f1, #8b5cf6, #a78bfa, #06b6d4); }
.container { padding: 18px 30px 12px; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
.header h1 { font-size: 28px; font-weight: 800; letter-spacing: -1px; color: #4f46e5; }
.header .tagline { font-size: 11px; color: #64748b; margin-top: 2px; }
.badge { background: #4f46e5; color: #fff; padding: 4px 14px; border-radius: 20px; font-size: 10px; font-weight: 700; }
.hero { background: linear-gradient(135deg, #eef2ff, #f0f9ff); border-radius: 10px; padding: 12px 18px; margin-bottom: 12px; border-left: 4px solid #6366f1; }
.hero p { font-size: 11.5px; line-height: 1.45; color: #334155; }
.hero strong { color: #4f46e5; font-weight: 700; }
.usp { background: linear-gradient(135deg, #4f46e5, #7c3aed); color: #fff; border-radius: 8px; padding: 10px 16px; margin-bottom: 12px; text-align: center; }
.usp strong { font-size: 11px; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 10px; }
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 10px; }
.grid4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 10px; }
.grid6 { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; margin-bottom: 10px; }
.section-title { font-size: 10px; font-weight: 700; color: #4f46e5; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 5px; border-bottom: 1px solid #e5e7eb; padding-bottom: 3px; }
.card { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 9px 11px; }
.card.highlight { border-left: 3px solid #6366f1; }
.card.cyan { border-left: 3px solid #06b6d4; }
.kpi { text-align: center; padding: 8px 4px; border-radius: 8px; background: #f8fafc; border: 1px solid #e2e8f0; }
.kpi .value { font-size: 18px; font-weight: 800; color: #4f46e5; }
.kpi .label { font-size: 7.5px; color: #64748b; margin-top: 1px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.3px; }
.product-card { border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px 13px; position: relative; overflow: hidden; }
.product-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; }
.product-card.scanner::before { background: linear-gradient(90deg, #6366f1, #8b5cf6); }
.product-card.platform::before { background: linear-gradient(90deg, #06b6d4, #0ea5e9); }
.product-card h3 { font-size: 12px; font-weight: 800; margin-bottom: 1px; }
.product-card.scanner h3 { color: #4f46e5; }
.product-card.platform h3 { color: #0891b2; }
.product-card .sub { font-size: 8px; color: #94a3b8; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 5px; }
.product-card ul { list-style: none; }
.product-card li { font-size: 9px; line-height: 1.3; padding: 1.5px 0 1.5px 12px; position: relative; color: #475569; }
.product-card li::before { content: ''; position: absolute; left: 0; top: 6px; width: 5px; height: 5px; border-radius: 50%; }
.product-card.scanner li::before { background: #818cf8; }
.product-card.platform li::before { background: #22d3ee; }
.roadmap-item { padding: 7px 9px; border-radius: 7px; border: 1px dashed #c7d2fe; background: #fefce8; }
.roadmap-item .rm-title { font-size: 9px; font-weight: 700; color: #92400e; margin-bottom: 1px; }
.roadmap-item .rm-desc { font-size: 7.5px; color: #78716c; line-height: 1.25; }
.bottom-card ul { list-style: none; }
.bottom-card li { font-size: 9px; color: #475569; padding: 1.5px 0 1.5px 11px; position: relative; line-height: 1.3; }
.bottom-card li::before { content: '\\2192'; position: absolute; left: 0; color: #8b5cf6; font-weight: 700; }
.market-row { display: flex; justify-content: space-between; margin-bottom: 2px; font-size: 9.5px; }
.market-label { font-weight: 700; color: #4f46e5; min-width: 35px; }
.fund-row { display: flex; justify-content: space-between; font-size: 9px; margin-bottom: 1px; }
.founder { display: flex; justify-content: space-between; font-size: 9px; margin-bottom: 2px; }
.founder span { color: #64748b; }
.footer { padding: 8px 30px; background: #f8fafc; border-top: 1px solid #e2e8f0; display: flex; justify-content: space-between; font-size: 8px; color: #94a3b8; }
</style>
</head>
<body>
<div class="top-bar"></div>
<div class="container">
<div class="header">
<div>
<h1>BreakPilot COMPL<span style="color:#4f46e5;">AI</span></h1>
<div class="tagline">Executive Summary</div>
</div>
<div class="badge">Pre-Seed ${funding?.target_date ? 'Q' + Math.ceil((new Date(funding.target_date).getMonth() + 1) / 3) + ' ' + new Date(funding.target_date).getFullYear() : 'Q4 2026'}</div>
</div>
<div class="hero">
<p>${de
? 'BreakPilot COMPL<strong>AI</strong> ist eine <strong>DSGVO-konforme KI-Plattform</strong>, die kontinuierliches Sicherheitsscanning mit intelligenter Compliance-Automatisierung vereint. Wir helfen unseren Kunden, ihren <strong>Code abzusichern</strong>, <strong>Compliance skalierbar durchzusetzen</strong> und <strong>volle Datensouver\\u00e4nit\\u00e4t zu bewahren</strong> \\u2014 gest\\u00fctzt auf \\u00fcber 25.000 atomaren Sicherheitskontrollen f\\u00fcr einen l\\u00fcckenlosen Audit-Trail.'
: 'BreakPilot COMPL<strong>AI</strong> is a <strong>GDPR-compliant AI platform</strong> that combines continuous security scanning with intelligent compliance automation. We help our customers <strong>secure their code</strong>, <strong>enforce compliance at scale</strong> and <strong>maintain full data sovereignty</strong> \\u2014 powered by over 25,000 atomic audit aspects for a complete audit trail.'
}</p>
</div>
<div class="usp"><strong>${es.usp}:</strong> ${es.uspText}</div>
<div class="grid2">
<div class="card highlight"><div class="section-title">${es.problem}</div><div style="font-size:10px;">${es.problemText}</div></div>
<div class="card highlight"><div class="section-title">${es.solution}</div><div style="font-size:10px;">${es.solutionText}</div></div>
</div>
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:10px;">
<div class="kpi"><div class="value">25.000+</div><div class="label">${es.controls}</div></div>
<div class="kpi"><div class="value">380+</div><div class="label">${es.regulations}</div></div>
<div class="kpi"><div class="value">10</div><div class="label">${es.industries}</div></div>
<div class="kpi"><div class="value">500K+</div><div class="label">${es.linesOfCode}</div></div>
<div class="kpi"><div class="value">${amountLabel}</div><div class="label">${es.theAsk}</div></div>
</div>
<div class="grid2">
<div class="product-card scanner">
<h3>${de ? 'Compliance Scanner' : 'Compliance Scanner'}</h3>
<div class="sub">${de ? 'Kontinuierlicher KI-Sicherheitsagent' : 'Continuous AI Security Agent'}</div>
<ul>
<li><strong>SAST + DAST + SBOM</strong> ${de ? '\\u2014 Vollumf\\u00e4ngliche Sicherheitstests bei jeder Code-\\u00c4nderung' : '\\u2014 Full security testing on every code change'}</li>
<li><strong>${de ? 'KI-gest\\u00fctztes Pentesting' : 'AI-powered Pentesting'}</strong> ${de ? '\\u2014 Kontinuierlich statt einmal im Jahr' : '\\u2014 Continuous instead of once a year'}</li>
<li><strong>CE-Software-Risikobeurteilung</strong> ${de ? '\\u2014 F\\u00fcr Maschinenverordnung und Produktsicherheit' : '\\u2014 For Machinery Regulation and product safety'}</li>
<li><strong>Issue-Tracker-Integration</strong> ${de ? '\\u2014 Findings als Tickets mit Implementierungsvorschl\\u00e4gen' : '\\u2014 Findings as tickets with implementation suggestions'}</li>
<li><strong>Audit-Trail</strong> ${de ? '\\u2014 L\\u00fcckenloser Nachweis von Erkennung bis Behebung' : '\\u2014 Complete evidence from detection to remediation'}</li>
</ul>
</div>
<div class="product-card platform">
<h3>${de ? 'ComplAI Plattform' : 'ComplAI Platform'}</h3>
<div class="sub">${de ? 'Souver\\u00e4ne Compliance-Infrastruktur' : 'Sovereign Compliance Infrastructure'}</div>
<ul>
<li><strong>${de ? 'Compliance-Dokumente' : 'Compliance Documents'}</strong> ${de ? '\\u2014 VVT, TOMs, DSFA, L\\u00f6schfristen automatisch' : '\\u2014 RoPA, TOMs, DPIA, retention automatically'}</li>
<li><strong>Audit Manager</strong> ${de ? '\\u2014 Abweichungen End-to-End: Rollen, Stichtage, Eskalation' : '\\u2014 Deviations end-to-end: roles, deadlines, escalation'}</li>
<li><strong>Compliance LLM</strong> ${de ? '\\u2014 GPT f\\u00fcr Text und Audio, sicher in der EU gehostet' : '\\u2014 GPT for text and audio, securely hosted in EU'}</li>
<li><strong>Academy</strong> ${de ? '\\u2014 Online-Schulungen f\\u00fcr GF und Mitarbeiter' : '\\u2014 Online training for management and employees'}</li>
<li><strong>${de ? 'BSI-Cloud DE / FR' : 'BSI Cloud DE / FR'}</strong> ${de ? '\\u2014 Keine US-SaaS, Jitsi, Matrix, volle Integration' : '\\u2014 No US SaaS, Jitsi, Matrix, full integration'}</li>
</ul>
</div>
</div>
<div class="section-title">${de ? 'Roadmap' : 'Roadmap'}</div>
<div class="grid4">
<div class="roadmap-item"><div class="rm-title">${de ? 'Q4 2026: Launch' : 'Q4 2026: Launch'}</div><div class="rm-desc">${de ? 'Gr\\u00fcndung, erste Pilotkunden, Cloud-Plattform live' : 'Founding, first pilot customers, cloud platform live'}</div></div>
<div class="roadmap-item"><div class="rm-title">${de ? 'Q2 2027: Scale' : 'Q2 2027: Scale'}</div><div class="rm-desc">${de ? 'Vertriebsteam, Messen, Marketing-Offensive' : 'Sales team, trade fairs, marketing push'}</div></div>
<div class="roadmap-item"><div class="rm-title">${de ? 'Q4 2027: Enterprise' : 'Q4 2027: Enterprise'}</div><div class="rm-desc">${de ? 'Enterprise-Kunden, Distributor-Partnerschaften' : 'Enterprise customers, distributor partnerships'}</div></div>
<div class="roadmap-item"><div class="rm-title">${de ? 'Q3 2029: Break-Even' : 'Q3 2029: Break-Even'}</div><div class="rm-desc">${de ? 'Profitabilit\\u00e4t, Series A Vorbereitung' : 'Profitability, Series A preparation'}</div></div>
</div>
<div class="grid2" style="grid-template-columns: 1fr 1fr 1fr 1fr; gap: 10px;">
<div class="card bottom-card">
<div class="section-title">${de ? 'Gesch\\u00e4ftsmodell' : 'Business Model'}</div>
<ul>
<li><strong>SaaS Cloud</strong> ${de ? '\\u2014 BSI DE / FR, mitarbeiterbasiert' : '\\u2014 BSI DE / FR, employee-based'}</li>
<li><strong>${de ? 'Modular w\\u00e4hlbar' : 'Modular choice'}</strong> ${de ? '\\u2014 Einzelne Module oder Full Compliance' : '\\u2014 Single modules or full compliance'}</li>
<li><strong>${de ? 'ROI ab Tag 1' : 'ROI from day 1'}</strong> ${de ? '\\u2014 KMU spart 55.000 EUR/Jahr (3,7x ROI)' : '\\u2014 SME saves EUR 55,000/year (3.7x ROI)'}</li>
</ul>
</div>
<div class="card bottom-card">
<div class="section-title">${de ? 'Zielm\\u00e4rkte' : 'Target Markets'}</div>
<ul>
<li><strong>${de ? 'Maschinenbau KMU' : 'Manufacturing SMEs'}</strong> ${de ? '\\u2014 10-500 MA, Eigenentwicklung' : '\\u2014 10-500 emp., own development'}</li>
<li><strong>${de ? 'Regulierte Branchen' : 'Regulated Industries'}</strong> ${de ? '\\u2014 Gesundheit, Finanzen, KRITIS' : '\\u2014 Healthcare, finance, critical infra'}</li>
<li><strong>${de ? 'EU-Datensouver\\u00e4nit\\u00e4t' : 'EU Data Sovereignty'}</strong> ${de ? '\\u2014 Unternehmen die US-SaaS ablehnen' : '\\u2014 Companies rejecting US SaaS'}</li>
</ul>
</div>
<div class="card bottom-card">
<div class="section-title">${de ? 'Gr\\u00fcnder' : 'Founders'}</div>
${teamHtml}
</div>
<div class="card bottom-card">
<div class="section-title">${es.theAsk} \\u2014 ${amountLabel}</div>
<div class="market-row"><span class="market-label">TAM</span><span>${tamVal}</span></div>
<div class="market-row"><span class="market-label">SAM</span><span>${samVal}</span></div>
<div class="market-row"><span class="market-label">SOM</span><span>${somVal}</span></div>
<div style="border-top:1px solid #e5e7eb;margin-top:4px;padding-top:4px;">
${useOfFundsHtml}
</div>
</div>
</div>
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:8px 12px;margin-top:10px;">
<div style="font-size:8px;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:3px;">${de ? 'Hinweis / Haftungsausschluss' : 'Disclaimer'}</div>
<div style="font-size:7px;color:#94a3b8;line-height:1.4;">${de
? 'Dieses Dokument dient ausschlie\\u00dflich Informationszwecken und stellt weder ein Angebot zum Verkauf noch eine Aufforderung zum Kauf von Anteilen oder Wertpapieren dar. Die enthaltenen Informationen wurden vom Team Breakpilot (Gr\\u00fcnderteam, noch keine Gesellschaft gegr\\u00fcndet) nach bestem Wissen und Gewissen erstellt, k\\u00f6nnen jedoch unvollst\\u00e4ndig sein und jederzeit ohne vorherige Ank\\u00fcndigung ge\\u00e4ndert werden. Es wird keine ausdr\\u00fcckliche oder konkludente Gew\\u00e4hr f\\u00fcr die Richtigkeit, Vollst\\u00e4ndigkeit oder Aktualit\\u00e4t der Inhalte \\u00fcbernommen. Es besteht keine Verpflichtung zur Aktualisierung der enthaltenen Informationen. Dieses Dokument enth\\u00e4lt zukunftsgerichtete Aussagen, die auf aktuellen Annahmen und Erwartungen beruhen und mit erheblichen Risiken und Unsicherheiten verbunden sind. Die tats\\u00e4chlichen Ergebnisse k\\u00f6nnen wesentlich von den dargestellten abweichen. Eine Investitionsentscheidung sollte ausschlie\\u00dflich auf Grundlage weitergehender, rechtlich verbindlicher Unterlagen sowie unter Hinzuziehung eigener rechtlicher, steuerlicher und finanzieller Beratung getroffen werden. Soweit gesetzlich zul\\u00e4ssig, wird jede Haftung des Team Breakpilot sowie seiner Mitglieder f\\u00fcr etwaige Sch\\u00e4den, die direkt oder indirekt aus der Nutzung dieses Dokuments entstehen, ausgeschlossen. Dieses Dokument ist vertraulich und ausschlie\\u00dflich f\\u00fcr den vorgesehenen Empf\\u00e4nger bestimmt. Eine Weitergabe, Vervielf\\u00e4ltigung oder Ver\\u00f6ffentlichung ist ohne vorherige schriftliche Zustimmung nicht gestattet.'
: 'This document is for informational purposes only and does not constitute an offer to sell or a solicitation to purchase shares or securities. The information contained herein was prepared by Team Breakpilot (founding team, no company incorporated yet) to the best of their knowledge, but may be incomplete and subject to change without prior notice. No express or implied warranty is given for the accuracy, completeness or timeliness of the content. This document contains forward-looking statements based on current assumptions and expectations that involve significant risks and uncertainties. Actual results may differ materially. Any investment decision should be based solely on further legally binding documents and with the advice of independent legal, tax and financial counsel. To the extent permitted by law, all liability of Team Breakpilot and its members for any damages arising directly or indirectly from the use of this document is excluded. This document is confidential and intended solely for the designated recipient. Distribution, reproduction or publication without prior written consent is prohibited.'
}</div>
</div>
</div>
<div class="footer">
<span>${de ? 'Vertraulich \\u2014 Nur f\\u00fcr Investoren' : 'Confidential \\u2014 Investors only'}</span>
<span>${data.company?.website || 'breakpilot.ai'} \\u2014 ${data.company?.hq_city || ''}</span>
<span>BreakPilot ComplAI \\u2014 ${de ? 'M\\u00e4rz' : 'March'} 2026</span>
</div>
</body></html>`
}

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Language, PitchData } from '@/lib/types'
import { t, formatEur } from '@/lib/i18n'
import { useFpKPIs } from '@/lib/hooks/useFpKPIs'
import { generatePdfHtml } from './ExecutiveSummarySlide.pdf'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import GlassCard from '../ui/GlassCard'
@@ -46,224 +47,8 @@ export default function ExecutiveSummarySlide({ lang, data, investorId, preferre
const printWindow = window.open('', '_blank')
if (!printWindow) return
const tamVal = tam ? formatEur(tam.value_eur, lang) : '—'
const samVal = sam ? formatEur(sam.value_eur, lang) : '—'
const somVal = som ? formatEur(som.value_eur, lang) : '—'
const teamHtml = data.team?.map(m =>
`<div class="founder"><strong>${m.name}</strong><span>${de ? m.role_de : m.role_en}</span></div>`
).join('') || ''
const useOfFundsHtml = funding?.use_of_funds?.map(f =>
`<div class="fund-row"><span>${de ? f.label_de : f.label_en}</span><strong>${f.percentage}%</strong></div>`
).join('') || ''
printWindow.document.write(`<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="utf-8">
<title>BreakPilot ComplAI — Executive Summary</title>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
@page { size: 297mm 680mm; margin: 30mm 12mm; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Plus Jakarta Sans', -apple-system, sans-serif;
background: #fff; color: #1a1a2e;
width: 100%; max-width: 273mm;
position: relative; font-size: 10.5px; line-height: 1.45;
}
@media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
.top-bar { height: 6px; background: linear-gradient(90deg, #6366f1, #8b5cf6, #a78bfa, #06b6d4); }
.container { padding: 18px 30px 12px; }
/* Header */
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
.header h1 { font-size: 28px; font-weight: 800; letter-spacing: -1px; color: #4f46e5; }
.header .tagline { font-size: 11px; color: #64748b; margin-top: 2px; }
.badge { background: #4f46e5; color: #fff; padding: 4px 14px; border-radius: 20px; font-size: 10px; font-weight: 700; }
/* Hero */
.hero { background: linear-gradient(135deg, #eef2ff, #f0f9ff); border-radius: 10px; padding: 12px 18px; margin-bottom: 12px; border-left: 4px solid #6366f1; }
.hero p { font-size: 11.5px; line-height: 1.45; color: #334155; }
.hero strong { color: #4f46e5; font-weight: 700; }
/* USP */
.usp { background: linear-gradient(135deg, #4f46e5, #7c3aed); color: #fff; border-radius: 8px; padding: 10px 16px; margin-bottom: 12px; text-align: center; }
.usp strong { font-size: 11px; }
/* Grid layouts */
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 10px; }
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 10px; }
.grid4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 10px; }
.grid6 { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; margin-bottom: 10px; }
/* Sections */
.section-title { font-size: 10px; font-weight: 700; color: #4f46e5; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 5px; border-bottom: 1px solid #e5e7eb; padding-bottom: 3px; }
.card { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 9px 11px; }
.card.highlight { border-left: 3px solid #6366f1; }
.card.cyan { border-left: 3px solid #06b6d4; }
/* KPIs */
.kpi { text-align: center; padding: 8px 4px; border-radius: 8px; background: #f8fafc; border: 1px solid #e2e8f0; }
.kpi .value { font-size: 18px; font-weight: 800; color: #4f46e5; }
.kpi .label { font-size: 7.5px; color: #64748b; margin-top: 1px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.3px; }
/* Product cards */
.product-card { border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px 13px; position: relative; overflow: hidden; }
.product-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; }
.product-card.scanner::before { background: linear-gradient(90deg, #6366f1, #8b5cf6); }
.product-card.platform::before { background: linear-gradient(90deg, #06b6d4, #0ea5e9); }
.product-card h3 { font-size: 12px; font-weight: 800; margin-bottom: 1px; }
.product-card.scanner h3 { color: #4f46e5; }
.product-card.platform h3 { color: #0891b2; }
.product-card .sub { font-size: 8px; color: #94a3b8; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 5px; }
.product-card ul { list-style: none; }
.product-card li { font-size: 9px; line-height: 1.3; padding: 1.5px 0 1.5px 12px; position: relative; color: #475569; }
.product-card li::before { content: ''; position: absolute; left: 0; top: 6px; width: 5px; height: 5px; border-radius: 50%; }
.product-card.scanner li::before { background: #818cf8; }
.product-card.platform li::before { background: #22d3ee; }
/* Roadmap */
.roadmap-item { padding: 7px 9px; border-radius: 7px; border: 1px dashed #c7d2fe; background: #fefce8; }
.roadmap-item .rm-title { font-size: 9px; font-weight: 700; color: #92400e; margin-bottom: 1px; }
.roadmap-item .rm-desc { font-size: 7.5px; color: #78716c; line-height: 1.25; }
/* Bottom sections */
.bottom-card ul { list-style: none; }
.bottom-card li { font-size: 9px; color: #475569; padding: 1.5px 0 1.5px 11px; position: relative; line-height: 1.3; }
.bottom-card li::before { content: '\\2192'; position: absolute; left: 0; color: #8b5cf6; font-weight: 700; }
.market-row { display: flex; justify-content: space-between; margin-bottom: 2px; font-size: 9.5px; }
.market-label { font-weight: 700; color: #4f46e5; min-width: 35px; }
.fund-row { display: flex; justify-content: space-between; font-size: 9px; margin-bottom: 1px; }
.founder { display: flex; justify-content: space-between; font-size: 9px; margin-bottom: 2px; }
.founder span { color: #64748b; }
/* Footer */
.footer { padding: 8px 30px; background: #f8fafc; border-top: 1px solid #e2e8f0; display: flex; justify-content: space-between; font-size: 8px; color: #94a3b8; }
</style>
</head>
<body>
<div class="top-bar"></div>
<div class="container">
<div class="header">
<div>
<h1>BreakPilot COMPL<span style="color:#4f46e5;">AI</span></h1>
<div class="tagline">Executive Summary</div>
</div>
<div class="badge">Pre-Seed ${funding?.target_date ? 'Q' + Math.ceil((new Date(funding.target_date).getMonth() + 1) / 3) + ' ' + new Date(funding.target_date).getFullYear() : 'Q4 2026'}</div>
</div>
<div class="hero">
<p>${de
? 'BreakPilot COMPL<strong>AI</strong> ist eine <strong>DSGVO-konforme KI-Plattform</strong>, die kontinuierliches Sicherheitsscanning mit intelligenter Compliance-Automatisierung vereint. Wir helfen unseren Kunden, ihren <strong>Code abzusichern</strong>, <strong>Compliance skalierbar durchzusetzen</strong> und <strong>volle Datensouver\\u00e4nit\\u00e4t zu bewahren</strong> \\u2014 gest\\u00fctzt auf \\u00fcber 25.000 atomaren Sicherheitskontrollen f\\u00fcr einen l\\u00fcckenlosen Audit-Trail.'
: 'BreakPilot COMPL<strong>AI</strong> is a <strong>GDPR-compliant AI platform</strong> that combines continuous security scanning with intelligent compliance automation. We help our customers <strong>secure their code</strong>, <strong>enforce compliance at scale</strong> and <strong>maintain full data sovereignty</strong> \\u2014 powered by over 25,000 atomic audit aspects for a complete audit trail.'
}</p>
</div>
<div class="usp"><strong>${es.usp}:</strong> ${es.uspText}</div>
<div class="grid2">
<div class="card highlight">
<div class="section-title">${es.problem}</div>
<div style="font-size:10px;">${es.problemText}</div>
</div>
<div class="card highlight">
<div class="section-title">${es.solution}</div>
<div style="font-size:10px;">${es.solutionText}</div>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:10px;">
<div class="kpi"><div class="value">25.000+</div><div class="label">${es.controls}</div></div>
<div class="kpi"><div class="value">380+</div><div class="label">${es.regulations}</div></div>
<div class="kpi"><div class="value">10</div><div class="label">${es.industries}</div></div>
<div class="kpi"><div class="value">500K+</div><div class="label">${es.linesOfCode}</div></div>
<div class="kpi"><div class="value">${amountLabel}</div><div class="label">${es.theAsk}</div></div>
</div>
<div class="grid2">
<div class="product-card scanner">
<h3>${de ? 'Compliance Scanner' : 'Compliance Scanner'}</h3>
<div class="sub">${de ? 'Kontinuierlicher KI-Sicherheitsagent' : 'Continuous AI Security Agent'}</div>
<ul>
<li><strong>SAST + DAST + SBOM</strong> ${de ? '\\u2014 Vollumf\\u00e4ngliche Sicherheitstests bei jeder Code-\\u00c4nderung' : '\\u2014 Full security testing on every code change'}</li>
<li><strong>${de ? 'KI-gest\\u00fctztes Pentesting' : 'AI-powered Pentesting'}</strong> ${de ? '\\u2014 Kontinuierlich statt einmal im Jahr' : '\\u2014 Continuous instead of once a year'}</li>
<li><strong>CE-Software-Risikobeurteilung</strong> ${de ? '\\u2014 F\\u00fcr Maschinenverordnung und Produktsicherheit' : '\\u2014 For Machinery Regulation and product safety'}</li>
<li><strong>Issue-Tracker-Integration</strong> ${de ? '\\u2014 Findings als Tickets mit Implementierungsvorschl\\u00e4gen' : '\\u2014 Findings as tickets with implementation suggestions'}</li>
<li><strong>Audit-Trail</strong> ${de ? '\\u2014 L\\u00fcckenloser Nachweis von Erkennung bis Behebung' : '\\u2014 Complete evidence from detection to remediation'}</li>
</ul>
</div>
<div class="product-card platform">
<h3>${de ? 'ComplAI Plattform' : 'ComplAI Platform'}</h3>
<div class="sub">${de ? 'Souver\\u00e4ne Compliance-Infrastruktur' : 'Sovereign Compliance Infrastructure'}</div>
<ul>
<li><strong>${de ? 'Compliance-Dokumente' : 'Compliance Documents'}</strong> ${de ? '\\u2014 VVT, TOMs, DSFA, L\\u00f6schfristen automatisch' : '\\u2014 RoPA, TOMs, DPIA, retention automatically'}</li>
<li><strong>Audit Manager</strong> ${de ? '\\u2014 Abweichungen End-to-End: Rollen, Stichtage, Eskalation' : '\\u2014 Deviations end-to-end: roles, deadlines, escalation'}</li>
<li><strong>Compliance LLM</strong> ${de ? '\\u2014 GPT f\\u00fcr Text und Audio, sicher in der EU gehostet' : '\\u2014 GPT for text and audio, securely hosted in EU'}</li>
<li><strong>Academy</strong> ${de ? '\\u2014 Online-Schulungen f\\u00fcr GF und Mitarbeiter' : '\\u2014 Online training for management and employees'}</li>
<li><strong>${de ? 'BSI-Cloud DE / FR' : 'BSI Cloud DE / FR'}</strong> ${de ? '\\u2014 Keine US-SaaS, Jitsi, Matrix, volle Integration' : '\\u2014 No US SaaS, Jitsi, Matrix, full integration'}</li>
</ul>
</div>
</div>
<div class="section-title">${de ? 'Roadmap' : 'Roadmap'}</div>
<div class="grid4">
<div class="roadmap-item"><div class="rm-title">${de ? 'Q4 2026: Launch' : 'Q4 2026: Launch'}</div><div class="rm-desc">${de ? 'Gr\\u00fcndung, erste Pilotkunden, Cloud-Plattform live' : 'Founding, first pilot customers, cloud platform live'}</div></div>
<div class="roadmap-item"><div class="rm-title">${de ? 'Q2 2027: Scale' : 'Q2 2027: Scale'}</div><div class="rm-desc">${de ? 'Vertriebsteam, Messen, Marketing-Offensive' : 'Sales team, trade fairs, marketing push'}</div></div>
<div class="roadmap-item"><div class="rm-title">${de ? 'Q4 2027: Enterprise' : 'Q4 2027: Enterprise'}</div><div class="rm-desc">${de ? 'Enterprise-Kunden, Distributor-Partnerschaften' : 'Enterprise customers, distributor partnerships'}</div></div>
<div class="roadmap-item"><div class="rm-title">${de ? 'Q3 2029: Break-Even' : 'Q3 2029: Break-Even'}</div><div class="rm-desc">${de ? 'Profitabilit\\u00e4t, Series A Vorbereitung' : 'Profitability, Series A preparation'}</div></div>
</div>
<div class="grid2" style="grid-template-columns: 1fr 1fr 1fr 1fr; gap: 10px;">
<div class="card bottom-card">
<div class="section-title">${de ? 'Gesch\\u00e4ftsmodell' : 'Business Model'}</div>
<ul>
<li><strong>SaaS Cloud</strong> ${de ? '\\u2014 BSI DE / FR, mitarbeiterbasiert' : '\\u2014 BSI DE / FR, employee-based'}</li>
<li><strong>${de ? 'Modular w\\u00e4hlbar' : 'Modular choice'}</strong> ${de ? '\\u2014 Einzelne Module oder Full Compliance' : '\\u2014 Single modules or full compliance'}</li>
<li><strong>${de ? 'ROI ab Tag 1' : 'ROI from day 1'}</strong> ${de ? '\\u2014 KMU spart 55.000 EUR/Jahr (3,7x ROI)' : '\\u2014 SME saves EUR 55,000/year (3.7x ROI)'}</li>
</ul>
</div>
<div class="card bottom-card">
<div class="section-title">${de ? 'Zielm\\u00e4rkte' : 'Target Markets'}</div>
<ul>
<li><strong>${de ? 'Maschinenbau KMU' : 'Manufacturing SMEs'}</strong> ${de ? '\\u2014 10-500 MA, Eigenentwicklung' : '\\u2014 10-500 emp., own development'}</li>
<li><strong>${de ? 'Regulierte Branchen' : 'Regulated Industries'}</strong> ${de ? '\\u2014 Gesundheit, Finanzen, KRITIS' : '\\u2014 Healthcare, finance, critical infra'}</li>
<li><strong>${de ? 'EU-Datensouver\\u00e4nit\\u00e4t' : 'EU Data Sovereignty'}</strong> ${de ? '\\u2014 Unternehmen die US-SaaS ablehnen' : '\\u2014 Companies rejecting US SaaS'}</li>
</ul>
</div>
<div class="card bottom-card">
<div class="section-title">${de ? 'Gr\\u00fcnder' : 'Founders'}</div>
${teamHtml}
</div>
<div class="card bottom-card">
<div class="section-title">${es.theAsk} \\u2014 ${amountLabel}</div>
<div class="market-row"><span class="market-label">TAM</span><span>${tamVal}</span></div>
<div class="market-row"><span class="market-label">SAM</span><span>${samVal}</span></div>
<div class="market-row"><span class="market-label">SOM</span><span>${somVal}</span></div>
<div style="border-top:1px solid #e5e7eb;margin-top:4px;padding-top:4px;">
${useOfFundsHtml}
</div>
</div>
</div>
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:8px 12px;margin-top:10px;">
<div style="font-size:8px;font-weight:700;color:#94a3b8;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:3px;">${de ? 'Hinweis / Haftungsausschluss' : 'Disclaimer'}</div>
<div style="font-size:7px;color:#94a3b8;line-height:1.4;">${de
? 'Dieses Dokument dient ausschlie\\u00dflich Informationszwecken und stellt weder ein Angebot zum Verkauf noch eine Aufforderung zum Kauf von Anteilen oder Wertpapieren dar. Die enthaltenen Informationen wurden vom Team Breakpilot (Gr\\u00fcnderteam, noch keine Gesellschaft gegr\\u00fcndet) nach bestem Wissen und Gewissen erstellt, k\\u00f6nnen jedoch unvollst\\u00e4ndig sein und jederzeit ohne vorherige Ank\\u00fcndigung ge\\u00e4ndert werden. Es wird keine ausdr\\u00fcckliche oder konkludente Gew\\u00e4hr f\\u00fcr die Richtigkeit, Vollst\\u00e4ndigkeit oder Aktualit\\u00e4t der Inhalte \\u00fcbernommen. Es besteht keine Verpflichtung zur Aktualisierung der enthaltenen Informationen. Dieses Dokument enth\\u00e4lt zukunftsgerichtete Aussagen, die auf aktuellen Annahmen und Erwartungen beruhen und mit erheblichen Risiken und Unsicherheiten verbunden sind. Die tats\\u00e4chlichen Ergebnisse k\\u00f6nnen wesentlich von den dargestellten abweichen. Eine Investitionsentscheidung sollte ausschlie\\u00dflich auf Grundlage weitergehender, rechtlich verbindlicher Unterlagen sowie unter Hinzuziehung eigener rechtlicher, steuerlicher und finanzieller Beratung getroffen werden. Soweit gesetzlich zul\\u00e4ssig, wird jede Haftung des Team Breakpilot sowie seiner Mitglieder f\\u00fcr etwaige Sch\\u00e4den, die direkt oder indirekt aus der Nutzung dieses Dokuments entstehen, ausgeschlossen. Dieses Dokument ist vertraulich und ausschlie\\u00dflich f\\u00fcr den vorgesehenen Empf\\u00e4nger bestimmt. Eine Weitergabe, Vervielf\\u00e4ltigung oder Ver\\u00f6ffentlichung ist ohne vorherige schriftliche Zustimmung nicht gestattet.'
: 'This document is for informational purposes only and does not constitute an offer to sell or a solicitation to purchase shares or securities. The information contained herein was prepared by Team Breakpilot (founding team, no company incorporated yet) to the best of their knowledge, but may be incomplete and subject to change without prior notice. No express or implied warranty is given for the accuracy, completeness or timeliness of the content. This document contains forward-looking statements based on current assumptions and expectations that involve significant risks and uncertainties. Actual results may differ materially. Any investment decision should be based solely on further legally binding documents and with the advice of independent legal, tax and financial counsel. To the extent permitted by law, all liability of Team Breakpilot and its members for any damages arising directly or indirectly from the use of this document is excluded. This document is confidential and intended solely for the designated recipient. Distribution, reproduction or publication without prior written consent is prohibited.'
}</div>
</div>
</div>
<div class="footer">
<span>${de ? 'Vertraulich \\u2014 Nur f\\u00fcr Investoren' : 'Confidential \\u2014 Investors only'}</span>
<span>${data.company?.website || 'breakpilot.ai'} \\u2014 ${data.company?.hq_city || ''}</span>
<span>BreakPilot ComplAI \\u2014 ${de ? 'M\\u00e4rz' : 'March'} 2026</span>
</div>
</body></html>`)
const html = generatePdfHtml({ lang, data, es, funding, tam, sam, som, amountLabel, de })
printWindow.document.write(html)
printWindow.document.close()
setTimeout(() => printWindow.print(), 300)

View File

@@ -0,0 +1,289 @@
'use client'
import GlassCard from '../ui/GlassCard'
import { formatCell } from './FinanzplanSlide.helpers'
interface ChartsTabProps {
fpKPIs: Record<string, Record<string, number>>
de: boolean
chartDetail: string | null
setChartDetail: (v: string | null) => void
}
const YEARS = ['y2026', 'y2027', 'y2028', 'y2029', 'y2030']
function fmtK(v: number) {
return Math.abs(v) >= 1000000 ? `${(v / 1000000).toFixed(1)}M` : `${Math.round(v / 1000)}k`
}
function fmtV(v: number) {
return v.toLocaleString('de-DE')
}
function getChartDetails(de: boolean): Record<string, { title: string; desc: string }> {
return {
mrr: { title: 'MRR (Monthly Recurring Revenue)', desc: de ? 'Monatlich wiederkehrender Umsatz im Dezember des jeweiligen Jahres — der wichtigste KPI für SaaS-Unternehmen. Zeigt den tatsächlichen monatlichen Run-Rate, nicht den Jahresdurchschnitt. ARR = MRR × 12.' : 'Monthly recurring revenue in December of each year — the most important SaaS KPI. Shows the actual monthly run rate, not the annual average. ARR = MRR × 12.' },
ebit: { title: 'EBIT (Earnings Before Interest & Taxes)', desc: de ? 'Operatives Ergebnis vor Zinsen und Steuern — zeigt die tatsächliche Profitabilität des Geschäftsbetriebs. Positiver EBIT = das Geschäftsmodell funktioniert.' : 'Operating profit before interest and taxes — shows actual profitability of operations. Positive EBIT = the business model works.' },
headcount: { title: de ? 'Personalaufbau' : 'Headcount', desc: de ? 'Geplanter Teamaufbau über 5 Jahre. Wir starten lean mit 2 Gründern und wachsen bedarfsorientiert. Jede Einstellung ist an einen konkreten Meilenstein geknüpft.' : 'Planned team growth over 5 years. We start lean with 2 founders and grow based on demand. Each hire is tied to a concrete milestone.' },
cash: { title: de ? 'Liquidität (Jahresende)' : 'Cash Position (Year-End)', desc: de ? 'Kontostand am Ende jedes Jahres. Zeigt ob genug Geld für den laufenden Betrieb vorhanden ist. Die Liquiditätskurve berücksichtigt alle Einnahmen, Ausgaben, Investitionen und Finanzierungen.' : 'Bank balance at end of each year. Shows if enough cash is available for operations. The liquidity curve accounts for all revenue, expenses, investments and financing.' },
revcost: { title: de ? 'Umsatz vs. Gesamtkosten' : 'Revenue vs. Total Costs', desc: de ? 'Vergleich zwischen Einnahmen und Ausgaben. Der Schnittpunkt zeigt den Break-Even — ab dann verdient das Unternehmen mehr als es ausgibt.' : 'Comparison between income and expenses. The intersection shows break-even — from then on, the company earns more than it spends.' },
acv: { title: 'ACV (Average Contract Value)', desc: de ? 'Durchschnittlicher Vertragswert pro Kunde und Jahr. Steigender ACV bedeutet: Kunden kaufen mehr Module oder wechseln in höhere Tiers.' : 'Average contract value per customer per year. Rising ACV means: customers buy more modules or upgrade to higher tiers.' },
grossMargin: { title: 'Gross Margin', desc: de ? 'Rohertragsmarge — wieviel Prozent vom Umsatz nach Abzug der direkten Kosten (Cloud-Infrastruktur, Lizenzen) übrig bleibt. Bei SaaS typisch 70-90%.' : 'How much revenue remains after direct costs (cloud infrastructure, licenses). Typical for SaaS: 70-90%.' },
nrr: { title: de ? 'Umsatzwachstum (YoY)' : 'Revenue Growth (YoY)', desc: de ? 'Jahresvergleich des Gesamtumsatzes — zeigt die Wachstumsgeschwindigkeit. In der Frühphase primär durch Neukundengewinnung getrieben, später zunehmend durch Expansion bestehender Kunden (Upselling in höhere Tiers).' : 'Year-over-year total revenue comparison — shows growth velocity. In early stages driven primarily by new customer acquisition, later increasingly by expansion of existing customers (upselling to higher tiers).' },
ebitMargin: { title: 'EBIT Margin', desc: de ? 'Operatives Ergebnis in Prozent vom Umsatz. Zeigt die Effizienz des Geschäftsmodells. Ziel für SaaS: 20-30% in der Reifephase.' : 'Operating result as percentage of revenue. Shows business model efficiency. Target for SaaS: 20-30% at maturity.' },
}
}
function ChartDetailOverlay({ chartDetail, fpKPIs, de, onClose }: {
chartDetail: string; fpKPIs: Record<string, Record<string, number>>; de: boolean; onClose: () => void
}) {
const chartDetails = getChartDetails(de)
const detailInfo = chartDetails[chartDetail]
if (!detailInfo) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div className="relative bg-slate-900/95 border border-white/10 rounded-2xl p-6 max-w-lg w-full" onClick={e => e.stopPropagation()}>
<button onClick={onClose} className="absolute top-4 right-4 text-white/40 hover:text-white/80 text-lg"></button>
<h3 className="text-xl font-bold text-white mb-3">{detailInfo.title}</h3>
<p className="text-sm text-white/60 leading-relaxed">{detailInfo.desc}</p>
{fpKPIs.y2026 && (
<div className="mt-4 pt-4 border-t border-white/10">
<div className="grid grid-cols-5 gap-2 text-center">
{[2026, 2027, 2028, 2029, 2030].map(y => {
const keyMap: Record<string, string> = { cash: 'liquiditaet', revcost: 'revenue', acv: 'arpu' }
const key = keyMap[chartDetail] || chartDetail
const v = fpKPIs[`y${y}`]?.[key as keyof typeof fpKPIs['y2026']] as number || 0
return (
<div key={y}>
<div className="text-[10px] text-white/40">{y}</div>
<div className="text-sm font-bold text-white">{typeof v === 'number' && Math.abs(v) >= 1000 ? fmtK(v) : `${v}`}</div>
</div>
)
})}
</div>
</div>
)}
</div>
</div>
)
}
export default function ChartsTab({ fpKPIs, de, chartDetail, setChartDetail }: ChartsTabProps) {
const detailInfo = chartDetail ? getChartDetails(de)[chartDetail] : null
return (
<div className="space-y-4">
{/* Detail overlay */}
{detailInfo && chartDetail && (
<ChartDetailOverlay chartDetail={chartDetail} fpKPIs={fpKPIs} de={de} onClose={() => setChartDetail(null)} />
)}
{/* MRR + Kunden Chart */}
<GlassCard hover={false} className="p-4 cursor-pointer" onClick={() => setChartDetail('mrr')}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-bold text-indigo-400 uppercase tracking-wider">{de ? 'MRR & Kundenentwicklung' : 'MRR & Customer Growth'}</h3>
<span className="text-[9px] text-white/30">{de ? 'Klicken für Details' : 'Click for details'}</span>
</div>
<div className="flex">
<div className="flex flex-col justify-between h-40 pr-2 text-[9px] text-white/30 text-right" style={{ width: 45 }}>
{(() => { const m = Math.max(...YEARS.map(y => fpKPIs[y]?.mrr || 0), 1); return [m, Math.round(m / 2), 0].map((v, i) => <span key={i}>{fmtK(v)} </span>) })()}
</div>
<div className="flex-1 grid grid-cols-5 gap-1 items-end h-40 border-l border-b border-white/10 pl-2 pb-1">
{YEARS.map((y, idx) => {
const d = { mrr: fpKPIs[y]?.mrr || 0, cust: fpKPIs[y]?.customers || 0 }
const maxMrr = Math.max(...YEARS.map(k => fpKPIs[k]?.mrr || 0), 1)
const maxCust = Math.max(...YEARS.map(k => fpKPIs[k]?.customers || 0), 1)
return (
<div key={idx} className="flex flex-col items-center gap-1">
<div className="flex items-end gap-1 w-full justify-center" style={{ height: '130px' }}>
<div className="w-8 bg-indigo-500/60 rounded-t transition-all" style={{ height: `${(d.mrr / maxMrr) * 120}px` }}>
<div className="text-[10px] text-white/80 text-center -mt-3.5 whitespace-nowrap font-semibold">{fmtK(d.mrr)}</div>
</div>
<div className="w-8 bg-emerald-500/60 rounded-t transition-all" style={{ height: `${(d.cust / maxCust) * 120}px` }}>
<div className="text-[10px] text-white/80 text-center -mt-3.5 whitespace-nowrap font-semibold">{d.cust}</div>
</div>
</div>
<span className="text-[10px] text-white/50 font-medium">{y.slice(1)}</span>
</div>
)
})}
</div>
</div>
<div className="flex justify-center gap-6 mt-2 text-[10px]">
<span className="flex items-center gap-1.5"><span className="w-3 h-2 bg-indigo-500/60 rounded inline-block" /> MRR (/Mon)</span>
<span className="flex items-center gap-1.5"><span className="w-3 h-2 bg-emerald-500/60 rounded inline-block" /> {de ? 'Bestandskunden (Dez)' : 'Customers (Dec)'}</span>
</div>
</GlassCard>
{/* EBIT + Headcount */}
<div className="grid md:grid-cols-2 gap-4">
<GlassCard hover={false} className="p-4 cursor-pointer" onClick={() => setChartDetail('ebit')}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-bold text-purple-400 uppercase tracking-wider">EBIT</h3>
<span className="text-[9px] text-white/30">/Jahr</span>
</div>
<div className="flex">
<div className="flex flex-col justify-between h-28 pr-2 text-[9px] text-white/30 text-right" style={{ width: 40 }}>
{(() => { const vals = YEARS.map(y => fpKPIs[y]?.ebit || 0); const mx = Math.max(...vals.map(Math.abs), 1); return [fmtK(mx), '0', fmtK(-mx)].map((v, i) => <span key={i}>{v}</span>) })()}
</div>
<div className="flex-1 grid grid-cols-5 gap-1 items-end h-28 border-l border-white/10 pl-2">
{YEARS.map((y, idx) => {
const v = fpKPIs[y]?.ebit || 0
const maxAbs = Math.max(...YEARS.map(k => Math.abs(fpKPIs[k]?.ebit || 0)), 1)
const h = Math.abs(v) / maxAbs * 90
return (
<div key={idx} className="flex flex-col items-center">
<div className="w-10 flex flex-col justify-end" style={{ height: '100px' }}>
<div className={`${v >= 0 ? 'bg-emerald-500/60 rounded-t' : 'bg-red-500/60 rounded-b'} w-full`} style={{ height: `${h}px` }}>
<div className={`text-[10px] ${v >= 0 ? 'text-emerald-300' : 'text-red-300'} text-center -mt-3.5 whitespace-nowrap font-semibold`}>{fmtK(v)}</div>
</div>
</div>
<span className="text-[10px] text-white/50 mt-1 font-medium">{y.slice(1)}</span>
</div>
)
})}
</div>
</div>
</GlassCard>
<GlassCard hover={false} className="p-4 cursor-pointer" onClick={() => setChartDetail('headcount')}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-bold text-amber-400 uppercase tracking-wider">{de ? 'Personalaufbau' : 'Headcount'}</h3>
<span className="text-[9px] text-white/30">{de ? 'Mitarbeiter (Dez)' : 'Employees (Dec)'}</span>
</div>
<div className="flex">
<div className="flex flex-col justify-between h-28 pr-2 text-[9px] text-white/30 text-right" style={{ width: 25 }}>
{(() => { const m = Math.max(...YEARS.map(y => fpKPIs[y]?.headcount || 0), 1); return [m, Math.round(m / 2), 0].map((v, i) => <span key={i}>{v}</span>) })()}
</div>
<div className="flex-1 grid grid-cols-5 gap-1 items-end h-28 border-l border-b border-white/10 pl-2 pb-1">
{YEARS.map((y, idx) => {
const v = fpKPIs[y]?.headcount || 0
const mx = Math.max(...YEARS.map(k => fpKPIs[k]?.headcount || 0), 1)
return (
<div key={idx} className="flex flex-col items-center">
<div className="w-10 flex flex-col justify-end" style={{ height: '90px' }}>
<div className="bg-amber-500/60 rounded-t w-full" style={{ height: `${(v / mx) * 80}px` }}>
<div className="text-[11px] text-amber-300 text-center -mt-3.5 font-bold">{v}</div>
</div>
</div>
<span className="text-[10px] text-white/50 mt-1 font-medium">{y.slice(1)}</span>
</div>
)
})}
</div>
</div>
</GlassCard>
</div>
{/* Liquiditat + Revenue vs Costs */}
<div className="grid md:grid-cols-2 gap-4">
<GlassCard hover={false} className="p-4 cursor-pointer" onClick={() => setChartDetail('cash')}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-bold text-cyan-400 uppercase tracking-wider">{de ? 'Liquidität' : 'Cash Position'}</h3>
<span className="text-[9px] text-white/30">{de ? 'Jahresende' : 'Year-End'}</span>
</div>
<div className="flex">
<div className="flex flex-col justify-between h-28 pr-2 text-[9px] text-white/30 text-right" style={{ width: 40 }}>
{(() => { const m = Math.max(...YEARS.map(y => Math.abs(fpKPIs[y]?.liquiditaet || 0)), 1); return [fmtK(m), fmtK(m / 2), '0'].map((v, i) => <span key={i}>{v}</span>) })()}
</div>
<div className="flex-1 grid grid-cols-5 gap-1 items-end h-28 border-l border-b border-white/10 pl-2 pb-1">
{YEARS.map((y, idx) => {
const v = fpKPIs[y]?.liquiditaet || 0
const mx = Math.max(...YEARS.map(k => Math.abs(fpKPIs[k]?.liquiditaet || 0)), 1)
const h = Math.abs(v) / mx * 90
return (
<div key={idx} className="flex flex-col items-center">
<div className="w-10 flex flex-col justify-end" style={{ height: '90px' }}>
<div className={`${v >= 0 ? 'bg-cyan-500/60 rounded-t' : 'bg-red-500/60 rounded-b'} w-full`} style={{ height: `${Math.max(h, 4)}px` }}>
<div className={`text-[10px] ${v >= 0 ? 'text-cyan-300' : 'text-red-300'} text-center -mt-3.5 whitespace-nowrap font-semibold`}>{fmtK(v)}</div>
</div>
</div>
<span className="text-[10px] text-white/50 mt-1 font-medium">{y.slice(1)}</span>
</div>
)
})}
</div>
</div>
</GlassCard>
<GlassCard hover={false} className="p-4 cursor-pointer" onClick={() => setChartDetail('revcost')}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-bold text-indigo-400 uppercase tracking-wider">{de ? 'Umsatz vs. Kosten' : 'Revenue vs. Costs'}</h3>
<span className="text-[9px] text-white/30">/Jahr</span>
</div>
<div className="flex">
<div className="flex flex-col justify-between h-28 pr-2 text-[9px] text-white/30 text-right" style={{ width: 40 }}>
{(() => {
const d = YEARS.map(y => ({ rev: fpKPIs[y]?.revenue || 0, costs: (fpKPIs[y]?.revenue || 0) - (fpKPIs[y]?.ebit || 0) }))
const mx = Math.max(...d.map(x => Math.max(x.rev, x.costs)), 1)
return [fmtK(mx), fmtK(mx / 2), '0'].map((v, i) => <span key={i}>{v}</span>)
})()}
</div>
<div className="flex-1 grid grid-cols-5 gap-1 items-end h-28 border-l border-b border-white/10 pl-2 pb-1">
{(() => {
const data = YEARS.map(y => ({ year: y.slice(1), rev: fpKPIs[y]?.revenue || 0, costs: (fpKPIs[y]?.revenue || 0) - (fpKPIs[y]?.ebit || 0) }))
const mx = Math.max(...data.map(d => Math.max(d.rev, d.costs)), 1)
return data.map((d, idx) => (
<div key={idx} className="flex flex-col items-center gap-1">
<div className="flex items-end gap-1 w-full justify-center" style={{ height: '90px' }}>
<div className="w-6 bg-indigo-500/60 rounded-t" style={{ height: `${(d.rev / mx) * 80}px` }}>
<div className="text-[9px] text-indigo-300 text-center -mt-3 whitespace-nowrap font-semibold">{fmtK(d.rev)}</div>
</div>
<div className="w-6 bg-red-500/40 rounded-t" style={{ height: `${(d.costs / mx) * 80}px` }}>
<div className="text-[9px] text-red-300 text-center -mt-3 whitespace-nowrap font-semibold">{fmtK(d.costs)}</div>
</div>
</div>
<span className="text-[10px] text-white/50 font-medium">{d.year}</span>
</div>
))
})()}
</div>
</div>
<div className="flex justify-center gap-4 mt-2 text-[9px]">
<span className="flex items-center gap-1"><span className="w-3 h-1.5 bg-indigo-500/60 rounded inline-block" /> {de ? 'Umsatz' : 'Revenue'}</span>
<span className="flex items-center gap-1"><span className="w-3 h-1.5 bg-red-500/40 rounded inline-block" /> {de ? 'Kosten' : 'Costs'}</span>
</div>
</GlassCard>
</div>
{/* Unit Economics */}
<GlassCard hover={false} className="p-4">
<h3 className="text-xs font-bold text-purple-400 uppercase tracking-wider mb-3">Unit Economics (20262030)</h3>
<div className="grid md:grid-cols-4 gap-3">
{[
{ label: 'ACV', key: 'arpu', unit: '€', color: 'text-indigo-300', bg: 'bg-indigo-500/60', detail: 'acv' },
{ label: 'Gross Margin', key: 'grossMargin', unit: '%', color: 'text-emerald-300', bg: 'bg-emerald-500/60', detail: 'grossMargin' },
{ label: de ? 'Wachstum' : 'Growth', key: 'nrr', unit: '%', color: 'text-purple-300', bg: 'bg-purple-500/60', detail: 'nrr' },
{ label: 'EBIT Margin', key: 'ebitMargin', unit: '%', color: 'text-amber-300', bg: 'bg-amber-500/60', detail: 'ebitMargin' },
].map((metric, mIdx) => (
<div key={mIdx} className="text-center cursor-pointer hover:bg-white/[0.03] rounded-lg p-2 transition-colors" onClick={() => setChartDetail(metric.detail)}>
<p className={`text-[10px] font-bold ${metric.color} uppercase tracking-wider mb-2`}>{metric.label}</p>
<div className="flex items-end justify-center gap-1 h-16">
{[2026, 2027, 2028, 2029, 2030].map((y, idx) => {
const val = fpKPIs[`y${y}`]?.[metric.key as keyof typeof fpKPIs['y2026']] as number || 0
const maxVal = Math.max(...[2026, 2027, 2028, 2029, 2030].map(yr => Math.abs(fpKPIs[`y${yr}`]?.[metric.key as keyof typeof fpKPIs['y2026']] as number || 0)), 1)
const h = Math.abs(val) / maxVal * 50
return (
<div key={idx} className="flex flex-col items-center">
<div className={`w-4 ${val >= 0 ? metric.bg + ' rounded-t' : 'bg-red-500/60 rounded-b'}`} style={{ height: `${Math.max(h, 2)}px` }} />
<span className="text-[8px] text-white/30 mt-0.5">{String(y).slice(2)}</span>
</div>
)
})}
</div>
<p className={`text-sm font-bold ${metric.color} mt-1`}>
{(() => {
const v = fpKPIs.y2030?.[metric.key as keyof typeof fpKPIs['y2026']] as number || 0
return metric.unit === '€' ? `${fmtV(v)}${metric.unit}` : `${v}${metric.unit}`
})()}
</p>
<p className="text-[8px] text-white/25">2030</p>
</div>
))}
</div>
</GlassCard>
</div>
)
}

View File

@@ -0,0 +1,348 @@
'use client'
import {
SheetRow, MONTH_LABELS, FORMULA_TOOLTIPS,
getLabel, getValues, formatCell,
} from './FinanzplanSlide.helpers'
/* ── Tooltip for formula-linked labels ── */
function LabelWithTooltip({ label }: { label: string }) {
const tooltip = FORMULA_TOOLTIPS[label]
if (!tooltip) return <span>{label}</span>
return (
<span className="group relative cursor-help">
{label}
<span className="invisible group-hover:visible absolute left-0 top-full mt-1 z-50 bg-slate-800 border border-white/10 text-[9px] text-white/70 px-2 py-1 rounded shadow-lg whitespace-nowrap">
{tooltip}
</span>
</span>
)
}
/* ── GuV Annual Table (y2026-y2030) ── */
interface GuvTableProps {
rows: SheetRow[]
de: boolean
}
export function GuvTable({ rows, de }: GuvTableProps) {
return (
<table className="w-full text-[10px]">
<thead>
<tr className="border-b border-white/10">
<th className="text-left py-1.5 px-2 text-white/60 font-medium sticky left-0 bg-slate-900/90 backdrop-blur min-w-[220px]">
{de ? 'GuV-Position' : 'P&L Item'}
</th>
{[2026, 2027, 2028, 2029, 2030].map(y => (
<th key={y} className="text-right py-1.5 px-3 text-white/60 font-medium min-w-[100px]">{y}</th>
))}
</tr>
</thead>
<tbody>
{rows.map(row => {
const values = getValues(row)
const label = getLabel(row)
const isMajorSum = label === 'EBIT' || label.includes('Rohergebnis') || label.includes('Jahresüberschuss') || label.includes('Ergebnis nach Steuern')
const isMinorSum = row.is_sum_row || label.includes('Summe') || label.includes('Gesamtleistung') || label.includes('Steuern gesamt')
const isSumRow = isMajorSum || isMinorSum
return (
<tr key={row.id} className={`${isMajorSum ? 'border-t-2 border-t-white/20 border-b border-b-white/[0.05] bg-white/[0.05]' : isMinorSum ? 'border-t border-t-white/10 border-b border-b-white/[0.03] bg-white/[0.03]' : 'border-b border-white/[0.03]'} hover:bg-white/[0.02]`}>
<td className={`py-1.5 px-2 sticky left-0 bg-slate-900/90 backdrop-blur ${isMajorSum ? 'font-bold text-white text-xs' : isMinorSum ? 'font-semibold text-white/80' : 'text-white/60'}`}>
<LabelWithTooltip label={label} />
</td>
{[2026, 2027, 2028, 2029, 2030].map(y => {
const v = values[`y${y}`] || 0
return (
<td key={y} className={`text-right py-1.5 px-3 ${v < 0 ? 'text-red-400' : v > 0 ? (isMajorSum ? 'text-white' : isSumRow ? 'text-white/80' : 'text-white/50') : 'text-white/15'} ${isMajorSum ? 'font-bold text-xs' : isSumRow ? 'font-semibold' : ''}`}>
{v === 0 ? '—' : Math.round(v).toLocaleString('de-DE', { maximumFractionDigits: 0 })}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
)
}
/* ── Monthly / Annual Grid (all sheets except GuV) ── */
interface MonthlyGridProps {
rows: SheetRow[]
activeSheet: string
de: boolean
yearOffset: number
openCats: Set<string>
toggleCat: (cat: string) => void
}
export function MonthlyGrid({ rows, activeSheet, de, yearOffset, openCats, toggleCat }: MonthlyGridProps) {
const currentYear = 2026 + yearOffset
const monthStart = yearOffset * 12 + 1
const monthEnd = monthStart + 11
/* ── Compute sum rows from detail rows (like Excel formulas) ── */
const computeRows = (): SheetRow[] => {
const computedRows = rows.map(row => {
const label = getLabel(row)
const cat = row.category as string || ''
const isSumLabel = row.is_sum_row || label.includes('Summe') || label.includes('SUMME') || label.includes('ÜBERSCHUSS') || label.includes('UEBERSCHUSS')
if (!isSumLabel) return row
let sourceRows: SheetRow[] = []
// === Betriebliche Aufwendungen: category-based sums ===
if (cat && cat !== 'summe') {
sourceRows = rows.filter(r => (r.category as string) === cat && !r.is_sum_row && getLabel(r) !== label)
} else if (label.includes('Summe sonstige')) {
sourceRows = rows.filter(r => {
const rCat = r.category as string || ''
const rLabel = getLabel(r)
return !r.is_sum_row && rCat !== 'personal' && rCat !== 'abschreibungen' && rCat !== 'summe' &&
!rLabel.includes('Personalkosten') && !rLabel.includes('Abschreibungen') && !rLabel.includes('Summe') && !rLabel.includes('SUMME')
})
} else if (label.includes('SUMME Betriebliche')) {
sourceRows = rows.filter(r => {
const rCat = r.category as string || ''
const rLabel = getLabel(r)
if (rLabel === 'Personalkosten' || rLabel === 'Abschreibungen') return true
return !r.is_sum_row && rCat !== 'summe' && !rLabel.includes('Summe') && !rLabel.includes('SUMME')
})
}
// === Liquidität: keep DB values (engine computed) ===
else if (activeSheet === 'liquiditaet' && (
label.includes('Summe') || label.includes('ÜBERSCHUSS') || label.includes('UEBERSCHUSS') ||
label.includes('LIQUIDITÄT') || label.includes('LIQUIDITAET') || label.includes('Kontostand')
)) {
return row
}
// === Umsatzerlöse: GESAMTUMSATZ = sum of revenue rows only ===
else if (label.includes('GESAMTUMSATZ')) {
sourceRows = rows.filter(r => {
const sec = (r as Record<string, unknown>).section as string || ''
return sec === 'revenue' && !getLabel(r).includes('GESAMTUMSATZ')
})
}
// === Materialaufwand: SUMME = sum of cost rows only ===
else if (label.includes('SUMME Material') || (activeSheet === 'materialaufwand' && label === 'SUMME')) {
sourceRows = rows.filter(r => {
const sec = (r as Record<string, unknown>).section as string || ''
return sec === 'cost' && getLabel(r) !== 'SUMME'
})
}
// === Kunden GESAMT rows — trust DB values ===
else if (label.includes('GESAMT') || label.includes('Bestandskunden gesamt')) {
return row
}
if (sourceRows.length === 0) return row
const computed: Record<string, number> = {}
for (let m = 1; m <= 60; m++) {
const key = `m${m}`
computed[key] = Math.round(sourceRows.reduce((sum, r) => sum + (getValues(r)[key] || 0), 0))
}
return { ...row, values: computed, values_total: computed }
})
// For betriebliche: reorder so category header comes BEFORE detail rows
if (activeSheet === 'betriebliche') {
const grouped: SheetRow[] = []
const cats = new Map<string, { header: SheetRow | null; details: SheetRow[] }>()
for (const row of computedRows) {
const cat = row.category as string || ''
if (!cats.has(cat)) cats.set(cat, { header: null, details: [] })
const g = cats.get(cat)!
if (row.is_sum_row) g.header = row
else g.details.push(row)
}
for (const [cat, g] of cats) {
if (g.header) grouped.push(g.header)
if (openCats.has(cat) || cat === 'summe' || cat === 'personal' || cat === 'abschreibungen') {
grouped.push(...g.details)
}
}
return grouped
}
return computedRows
}
const displayRows = computeRows()
return (
<table className="w-full text-[10px]">
<thead>
<tr className="border-b border-white/10">
<th className="text-left py-1.5 px-2 text-white/60 font-medium sticky left-0 bg-slate-900/90 backdrop-blur min-w-[160px]">
{de ? 'Position' : 'Item'}
</th>
{yearOffset === -1 ? (
[2026, 2027, 2028, 2029, 2030].map(y => (
<th key={y} className="text-right py-1.5 px-3 text-white/60 font-medium min-w-[80px]">{y}</th>
))
) : (
<>
<th className="text-right py-1.5 px-2 text-white/60 font-medium min-w-[70px]">
{currentYear}
</th>
{MONTH_LABELS.map((label, idx) => (
<th key={idx} className="text-right py-1.5 px-1.5 text-white/50 font-normal min-w-[55px]">
{label}
</th>
))}
</>
)}
</tr>
</thead>
<tbody>
{displayRows.map(row => {
const values = getValues(row)
const label = getLabel(row)
const isSumRow = row.is_sum_row || label.includes('GESAMT') || label.includes('Summe') || label.includes('ÜBERSCHUSS') || label.includes('LIQUIDITÄT') || label.includes('UEBERSCHUSS') || label.includes('LIQUIDITAET')
const isTotalRow = label.includes('GESAMT') || label.includes('Bestandskunden gesamt') || label.includes('GESAMTUMSATZ') || label.includes('SUMME')
const isEditable = false // read-only for investors
const cat = row.category as string || ''
const isCatHeader = activeSheet === 'betriebliche' && row.is_sum_row && cat !== 'summe' && cat !== 'personal' && cat !== 'abschreibungen'
const isCatOpen = openCats.has(cat)
const section = (row as Record<string, unknown>).section as string || ''
const isBalanceRow = label.includes('Kontostand') || label === 'LIQUIDITÄT' || label === 'LIQUIDITAET'
|| label.includes('Bestandskunden') || (activeSheet === 'kunden' && row.row_label === 'Bestandskunden')
|| label.includes('Anzahl Kunden') || section === 'quantity'
const isUnitPrice = section === 'unit_cost' || section === 'einkauf' || label.includes('Einkaufspreis')
|| section === 'price' || label.includes('Preis/Monat')
let annual = 0
if (isUnitPrice) {
annual = values[`m${monthEnd}`] || values[`m${monthStart}`] || 0
} else if (isBalanceRow) {
annual = values[`m${monthEnd}`] || 0
} else {
for (let m = monthStart; m <= monthEnd; m++) annual += values[`m${m}`] || 0
}
return (
<tr
key={row.id}
className={`${isTotalRow ? 'border-t-2 border-t-white/20 border-b border-b-white/[0.03]' : 'border-b border-white/[0.03]'} ${isSumRow ? 'bg-white/[0.03]' : ''} hover:bg-white/[0.02]`}
>
<td className={`py-1 px-2 sticky left-0 bg-slate-900/90 backdrop-blur ${isSumRow ? 'font-bold text-white/80' : 'text-white/60'} ${isCatHeader ? 'cursor-pointer select-none' : ''}`}
onClick={isCatHeader ? () => toggleCat(cat) : undefined}
>
<div className="flex items-center gap-1">
{isCatHeader && <span className="text-[10px] text-indigo-400 w-3 shrink-0">{isCatOpen ? '▾' : '▸'}</span>}
{isEditable && <span className="w-1 h-1 rounded-full bg-indigo-400 flex-shrink-0" />}
<span className="truncate"><LabelWithTooltip label={label} /></span>
{row.position && <span className="text-white/50 ml-1">({row.position})</span>}
</div>
</td>
{yearOffset === -1 ? (
[2026, 2027, 2028, 2029, 2030].map(y => {
const yStart = (y - 2026) * 12 + 1
const yEnd = yStart + 11
let yVal = 0
if (isUnitPrice) {
yVal = values[`m${yEnd}`] || 0
} else if (isBalanceRow) {
yVal = values[`m${yEnd}`] || 0
} else {
for (let m = yStart; m <= yEnd; m++) yVal += values[`m${m}`] || 0
}
return (
<td key={y} className={`text-right py-1 px-3 ${yVal < 0 ? 'text-red-400' : yVal > 0 ? (isSumRow ? 'text-white/80' : 'text-white/50') : 'text-white/15'} ${isSumRow ? 'font-bold' : ''}`}>
{formatCell(Math.round(yVal))}
</td>
)
})
) : (
<>
<td className={`text-right py-1 px-2 font-medium ${annual < 0 ? 'text-red-400' : isSumRow ? 'text-white/80' : 'text-white/50'}`}>
{formatCell(annual)}
</td>
{Array.from({ length: 12 }, (_, idx) => {
const mKey = `m${monthStart + idx}`
const v = values[mKey] || 0
return (
<td
key={idx}
className={`text-right py-1 px-1.5 ${
v < 0 ? 'text-red-400/70' : v > 0 ? (isSumRow ? 'text-white/70' : 'text-white/50') : 'text-white/15'
}`}
>
{formatCell(v)}
</td>
)
})}
</>
)}
</tr>
)
})}
</tbody>
{/* Footer sum row for personalkosten, investitionen */}
{['personalkosten', 'investitionen'].includes(activeSheet) && rows.length > 0 && (() => {
const nonSumRows = rows.filter(r => {
const l = getLabel(r)
return !(r.is_sum_row || l.includes('GESAMT') || l.includes('Summe') || l.includes('Gesamtkosten') || l === 'SUMME')
})
return (
<tfoot>
<tr className="border-t-2 border-white/20 bg-white/[0.05]">
<td className="py-1.5 px-2 sticky left-0 bg-slate-900/90 backdrop-blur font-bold text-white/80 text-xs">
{de ? 'SUMME' : 'TOTAL'}
</td>
{yearOffset === -1 ? (
[2026, 2027, 2028, 2029, 2030].map(y => {
const yStart = (y - 2026) * 12 + 1
const yEnd = yStart + 11
let yVal = 0
for (let m = yStart; m <= yEnd; m++) {
for (const row of nonSumRows) yVal += getValues(row)[`m${m}`] || 0
}
return (
<td key={y} className={`text-right py-1.5 px-3 font-bold text-xs ${yVal < 0 ? 'text-red-400' : 'text-white/80'}`}>
{formatCell(Math.round(yVal))}
</td>
)
})
) : (
<>
{(() => {
let sumAnnual = 0
for (let m = monthStart; m <= monthEnd; m++) {
for (const row of nonSumRows) sumAnnual += getValues(row)[`m${m}`] || 0
}
return (
<td className={`text-right py-1.5 px-2 font-bold text-xs ${sumAnnual < 0 ? 'text-red-400' : 'text-white/80'}`}>
{formatCell(sumAnnual)}
</td>
)
})()}
{Array.from({ length: 12 }, (_, idx) => {
const mKey = `m${monthStart + idx}`
let v = 0
for (const row of nonSumRows) v += getValues(row)[mKey] || 0
return (
<td key={idx} className={`text-right py-1.5 px-1.5 font-bold text-xs ${v < 0 ? 'text-red-400' : v > 0 ? 'text-white/70' : 'text-white/15'}`}>
{formatCell(v)}
</td>
)
})}
</>
)}
</tr>
</tfoot>
)
})()}
</table>
)
}

View File

@@ -0,0 +1,68 @@
// FinanzplanSlide helpers — extracted from FinanzplanSlide.tsx
export interface SheetMeta {
name: string
label_de: string
label_en: string
rows: number
}
export interface SheetRow {
id: number
row_label?: string
person_name?: string
item_name?: string
category?: string
section?: string
is_editable?: boolean
is_sum_row?: boolean
values?: Record<string, number>
values_total?: Record<string, number>
values_brutto?: Record<string, number>
brutto_monthly?: number
position?: string
start_date?: string
purchase_amount?: number
[key: string]: unknown
}
export interface FpScenario {
id: string
name: string
is_default: boolean
}
export const MONTH_LABELS = [
'Jan', 'Feb', 'Mrz', 'Apr', 'Mai', 'Jun',
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez',
]
export function getLabel(row: SheetRow): string {
return row.row_label || row.person_name || row.item_name || '—'
}
export const FORMULA_TOOLTIPS: Record<string, string> = {
'Fort-/Weiterbildungskosten (F)': 'Mitarbeiter (ohne Gründer) × 300 EUR/Mon',
'Fahrzeugkosten (F)': 'Mitarbeiter (ohne Gründer) × 200 EUR/Mon',
'KFZ-Steuern (F)': 'Mitarbeiter (ohne Gründer) × 25 EUR/Mon',
'KFZ-Versicherung (F)': 'Mitarbeiter (ohne Gründer) × 150 EUR/Mon',
'Reisekosten (F)': 'Headcount gesamt × 75 EUR/Mon',
'Bewirtungskosten (F)': 'Bestandskunden × 50 EUR/Mon',
'Internet/Mobilfunk (F)': 'Headcount gesamt × 50 EUR/Mon',
'Cloud-Hosting (SysEleven/Hetzner)': '1.500 EUR Basis + (Kunden - 10) × 100 EUR (erste 10 inkl.)',
'Berufsgenossenschaft (F)': '0,5% der Brutto-Lohnsumme (VBG IT/Büro)',
'Allgemeine Marketingkosten (F)': '8% vom Umsatz (2026-2028), 10% ab 2029',
'Gewerbesteuer (F)': '12,25% vom Gewinn (Messzahl 3,5% × Hebesatz 350%, nur bei Gewinn)',
'Personalkosten': 'Summe aus Tab Personalkosten',
'Abschreibungen': 'Summe AfA aus Tab Investitionen',
}
export function getValues(row: SheetRow): Record<string, number> {
return row.values || row.values_total || row.values_brutto || (row as Record<string, unknown>).values_invest as Record<string, number> || {}
}
export function formatCell(v: number | undefined): string {
if (v === undefined || v === null) return ''
if (v === 0) return '—'
return Math.round(v).toLocaleString('de-DE', { maximumFractionDigits: 0 })
}

View File

@@ -0,0 +1,68 @@
'use client'
import GlassCard from '../ui/GlassCard'
import { formatCell } from './FinanzplanSlide.helpers'
interface KPIsTabProps {
fpKPIs: Record<string, Record<string, number>>
de: boolean
}
export default function KPIsTab({ fpKPIs, de }: KPIsTabProps) {
const years = ['y2026', 'y2027', 'y2028', 'y2029', 'y2030']
const v = (yk: string, key: string) => fpKPIs[yk]?.[key] || 0
return (
<GlassCard hover={false} className="p-4">
<h3 className="text-sm font-bold text-emerald-400 uppercase tracking-wider mb-4">{de ? 'Wichtige Kennzahlen (pro Jahr)' : 'Key Metrics (per year)'}</h3>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-white/10">
<th className="text-left py-2 px-2 text-white/60 font-medium">KPI</th>
{[2026, 2027, 2028, 2029, 2030].map(y => (
<th key={y} className="text-right py-2 px-3 text-white/60 font-medium">{y}</th>
))}
</tr>
</thead>
<tbody>
{!fpKPIs['y2026'] ? (
<tr><td colSpan={6} className="text-center py-4 text-white/30">{de ? 'Finanzplan wird geladen...' : 'Loading financial plan...'}</td></tr>
) : [
{ label: 'MRR (Dez)', values: years.map(y => v(y, 'mrr')), unit: '\u20AC', bold: true },
{ label: 'ARR (Dez \u00d7 12)', values: years.map(y => v(y, 'arr')), unit: '\u20AC', bold: true },
{ label: de ? 'Kunden (Dez)' : 'Customers (Dec)', values: years.map(y => v(y, 'customers')), unit: '', bold: false },
{ label: de ? 'ACV (Umsatz/Kunden)' : 'ACV (Revenue/Customers)', values: years.map(y => v(y, 'arpu')), unit: '\u20AC', bold: false },
{ label: 'Gross Margin', values: years.map(y => v(y, 'grossMargin')), unit: '%', bold: false },
{ label: de ? 'Umsatzwachstum (YoY)' : 'Revenue Growth (YoY)', values: years.map(y => v(y, 'nrr')), unit: '%', bold: false },
{ label: de ? 'Mitarbeiter' : 'Employees', values: years.map(y => v(y, 'headcount')), unit: '', bold: false },
{ label: de ? 'Umsatz/Mitarbeiter' : 'Revenue/Employee', values: years.map(y => v(y, 'revPerEmp')), unit: '\u20AC', bold: false },
{ label: de ? 'Personalkosten' : 'Personnel Costs', values: years.map(y => v(y, 'personal')), unit: '\u20AC', bold: false },
{ label: 'EBIT', values: years.map(y => v(y, 'ebit')), unit: '\u20AC', bold: true },
{ label: de ? 'EBIT-Marge' : 'EBIT Margin', values: years.map(y => v(y, 'ebitMargin')), unit: '%', bold: false },
{ label: de ? 'Steuern' : 'Taxes', values: years.map(y => v(y, 'steuern')), unit: '\u20AC', bold: false },
{ label: de ? 'Jahres\u00fcberschuss' : 'Net Income', values: years.map(y => v(y, 'netIncome')), unit: '\u20AC', bold: true },
{ label: de ? 'Liquidit\u00e4t (Dez)' : 'Cash (Dec)', values: years.map(y => v(y, 'liquiditaet')), unit: '\u20AC', bold: true },
{ label: 'Burn Rate', values: years.map(y => v(y, 'burnRate')), unit: '\u20AC/Mo', bold: false },
].map((row, idx) => (
<tr key={idx} className={`border-b border-white/[0.03] ${row.bold ? 'bg-white/[0.03]' : ''}`}>
<td className={`py-1.5 px-2 ${row.bold ? 'font-bold text-white/80' : 'text-white/60'}`}>{row.label}</td>
{row.values.map((val, i) => {
const num = typeof val === 'number' ? val : 0
const display = typeof val === 'string' ? val : (
row.unit === '%' ? `${val}%` :
row.unit === '\u20AC/Mo' ? formatCell(num) + '/Mo' :
formatCell(num)
)
return (
<td key={i} className={`text-right py-1.5 px-3 font-mono ${num < 0 ? 'text-red-400' : row.bold ? 'text-white/80 font-bold' : 'text-white/50'}`}>
{display}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</GlassCard>
)
}

View File

@@ -0,0 +1,139 @@
'use client'
import { useState } from 'react'
import GlassCard from '../ui/GlassCard'
interface SKRTabProps {
de: boolean
}
export default function SKRTab({ de }: SKRTabProps) {
const [openSKR, setOpenSKR] = useState<Set<string>>(new Set(['4', '6', '7']))
const toggleSKR = (k: string) => setOpenSKR(prev => { const n = new Set(prev); n.has(k) ? n.delete(k) : n.add(k); return n })
const skr04: { klasse: string; title: string; color: string; accounts: { nr: string; name: string; used?: boolean }[] }[] = [
{ klasse: '0', title: de ? 'Anlagevermögen' : 'Fixed Assets', color: 'text-slate-400', accounts: [
{ nr: '0200', name: de ? 'Technische Anlagen & Maschinen' : 'Technical Equipment', used: false },
{ nr: '0400', name: de ? 'Betriebs- und Geschäftsausstattung' : 'Office & Business Equipment', used: true },
{ nr: '0420', name: de ? 'EDV-Hardware' : 'IT Hardware', used: true },
{ nr: '0440', name: de ? 'Geringwertige Wirtschaftsgüter (GWG)' : 'Low-Value Assets (GWG)', used: true },
{ nr: '0480', name: de ? 'Immaterielle Vermögensgegenstände' : 'Intangible Assets', used: true },
]},
{ klasse: '1', title: de ? 'Umlaufvermögen' : 'Current Assets', color: 'text-blue-400', accounts: [
{ nr: '1200', name: de ? 'Bank (Geschäftskonto)' : 'Bank (Business Account)', used: true },
{ nr: '1210', name: de ? 'Bank (Festgeld/Rücklagen)' : 'Bank (Fixed Deposit)', used: false },
{ nr: '1400', name: de ? 'Forderungen aus L&L' : 'Accounts Receivable', used: true },
{ nr: '1590', name: de ? 'Durchlaufende Posten' : 'Transit Items', used: false },
]},
{ klasse: '2', title: de ? 'Eigenkapital & Verbindlichkeiten' : 'Equity & Liabilities', color: 'text-purple-400', accounts: [
{ nr: '2000', name: de ? 'Gezeichnetes Kapital (Stammkapital)' : 'Share Capital', used: true },
{ nr: '2010', name: de ? 'Kapitalrücklage (Wandeldarlehen)' : 'Capital Reserve (Convertible Loan)', used: true },
{ nr: '2100', name: de ? 'Gewinnvortrag / Verlustvortrag' : 'Retained Earnings / Losses', used: true },
{ nr: '2900', name: de ? 'Jahresüberschuss / -fehlbetrag' : 'Net Income / Loss', used: true },
]},
{ klasse: '3', title: de ? 'Rückstellungen & Verbindlichkeiten' : 'Provisions & Payables', color: 'text-indigo-400', accounts: [
{ nr: '3070', name: de ? 'Verbindlichkeiten ggü. Kreditinstituten' : 'Bank Liabilities', used: true },
{ nr: '3100', name: de ? 'Verbindlichkeiten aus L&L' : 'Accounts Payable', used: true },
{ nr: '3150', name: de ? 'Darlehen (L-Bank Pre-Seed)' : 'Loan (L-Bank Pre-Seed)', used: true },
{ nr: '3500', name: de ? 'Umsatzsteuer-Verbindlichkeit' : 'VAT Payable', used: true },
{ nr: '3520', name: de ? 'Lohnsteuer-Verbindlichkeit' : 'Payroll Tax Payable', used: true },
{ nr: '3550', name: de ? 'Sozialversicherungs-Verbindlichkeit' : 'Social Security Payable', used: true },
{ nr: '3900', name: de ? 'Rückstellungen (Jahresabschluss etc.)' : 'Provisions (Closing etc.)', used: true },
]},
{ klasse: '4', title: de ? 'Betriebliche Erträge' : 'Operating Revenue', color: 'text-emerald-400', accounts: [
{ nr: '4400', name: de ? 'Erlöse Software-Lizenzen (SaaS)' : 'Software License Revenue (SaaS)', used: true },
{ nr: '4410', name: de ? 'Erlöse Beratung & Service' : 'Consulting & Service Revenue', used: true },
{ nr: '4440', name: de ? 'Erlöse Hardware (Mac Mini/Studio)' : 'Hardware Revenue', used: false },
{ nr: '4500', name: de ? 'Sonstige betriebliche Erträge' : 'Other Operating Revenue', used: true },
{ nr: '4510', name: de ? 'Fördergelder / Grants' : 'Grants / Subsidies', used: true },
{ nr: '4520', name: de ? 'Forschungszulage (§ 27a EStG)' : 'Research Tax Credit', used: true },
]},
{ klasse: '5', title: de ? 'Materialaufwand / COGS' : 'Material Costs / COGS', color: 'text-orange-400', accounts: [
{ nr: '5400', name: de ? 'Cloud-Hosting (SysEleven/Hetzner)' : 'Cloud Hosting', used: true },
{ nr: '5410', name: de ? 'LLM-Inferenzkosten' : 'LLM Inference Costs', used: true },
{ nr: '5420', name: de ? '3rd Party API (Tavily etc.)' : '3rd Party API', used: true },
{ nr: '5430', name: de ? 'Datenbank-Hosting (PostgreSQL/Qdrant)' : 'Database Hosting', used: true },
{ nr: '5440', name: de ? 'CDN / Storage / Monitoring' : 'CDN / Storage / Monitoring', used: true },
]},
{ klasse: '6', title: de ? 'Personalaufwand' : 'Personnel Costs', color: 'text-cyan-400', accounts: [
{ nr: '6000', name: de ? 'Löhne und Gehälter' : 'Wages and Salaries', used: true },
{ nr: '6010', name: de ? 'Geschäftsführer-Gehälter' : 'Managing Director Salaries', used: true },
{ nr: '6100', name: de ? 'Soziale Abgaben (AG-Anteil)' : 'Social Security (Employer)', used: true },
{ nr: '6110', name: de ? 'Berufsgenossenschaft (VBG)' : 'Professional Association (VBG)', used: true },
{ nr: '6130', name: de ? 'Vermögenswirksame Leistungen' : 'Capital-Forming Benefits', used: false },
{ nr: '6170', name: de ? 'Freiwillige Sozialleistungen' : 'Voluntary Social Benefits', used: false },
]},
{ klasse: '7', title: de ? 'Sonstige betriebliche Aufwendungen' : 'Other Operating Expenses', color: 'text-amber-400', accounts: [
{ nr: '7000', name: de ? 'Raumkosten / Miete' : 'Rent / Room Costs', used: true },
{ nr: '7100', name: de ? 'Versicherungen (D&O, Cyber, Haftpflicht)' : 'Insurance (D&O, Cyber, Liability)', used: true },
{ nr: '7200', name: de ? 'Fahrzeugkosten / KFZ' : 'Vehicle Costs', used: true },
{ nr: '7300', name: de ? 'Werbe- und Marketingkosten' : 'Marketing Costs', used: true },
{ nr: '7310', name: de ? 'Teilnahme an Messen' : 'Trade Fair Participation', used: true },
{ nr: '7320', name: de ? 'Bewirtungskosten' : 'Entertainment Costs', used: true },
{ nr: '7400', name: de ? 'Reisekosten' : 'Travel Costs', used: true },
{ nr: '7500', name: de ? 'Internet / Mobilfunk' : 'Internet / Mobile', used: true },
{ nr: '7510', name: de ? 'Serverkosten / Cloud (→ Klasse 5)' : 'Server / Cloud (→ Class 5)', used: false },
{ nr: '7600', name: de ? 'Rechts-/Beratungskosten' : 'Legal / Advisory Costs', used: true },
{ nr: '7610', name: de ? 'Buchführungskosten' : 'Bookkeeping Costs', used: true },
{ nr: '7620', name: de ? 'Jahresabschlusskosten' : 'Annual Closing Costs', used: true },
{ nr: '7630', name: de ? 'Ext. Datenschutzbeauftragter' : 'Ext. Data Protection Officer', used: true },
{ nr: '7640', name: de ? 'Zertifizierung (ISO 27001 / BSI C5)' : 'Certification (ISO 27001 / BSI C5)', used: true },
{ nr: '7650', name: de ? 'Recruiting / Stellenanzeigen' : 'Recruiting / Job Ads', used: true },
{ nr: '7680', name: de ? 'IHK / Kammerbeiträge' : 'Chamber of Commerce Fees', used: true },
{ nr: '7690', name: de ? 'Rundfunkbeitrag' : 'Broadcasting Fee', used: true },
{ nr: '7700', name: de ? 'Abschreibungen (AfA)' : 'Depreciation', used: true },
{ nr: '7750', name: de ? 'Fort- und Weiterbildung' : 'Training & Development', used: true },
{ nr: '7800', name: de ? 'Bankgebühren / Nebenkosten Geldverkehr' : 'Bank Fees', used: true },
{ nr: '7900', name: de ? 'Schutzrechte / Lizenzkosten' : 'IP Rights / License Costs', used: true },
]},
{ klasse: '8', title: de ? 'Finanzerträge & -aufwendungen' : 'Financial Income & Expenses', color: 'text-red-400', accounts: [
{ nr: '8100', name: de ? 'Zinserträge' : 'Interest Income', used: false },
{ nr: '8200', name: de ? 'Zinsaufwendungen' : 'Interest Expenses', used: true },
{ nr: '8210', name: de ? 'Zinsen L-Bank Wandeldarlehen (8%)' : 'Interest L-Bank Convertible (8%)', used: true },
]},
{ klasse: '9', title: de ? 'Steuern & Jahresabschluss' : 'Taxes & Closing', color: 'text-rose-400', accounts: [
{ nr: '9000', name: de ? 'Gewerbesteuer' : 'Trade Tax', used: true },
{ nr: '9100', name: de ? 'Körperschaftsteuer + Soli' : 'Corporate Tax + Surcharge', used: true },
{ nr: '9200', name: de ? 'Umsatzsteuer (Zahllast)' : 'VAT (Payable)', used: true },
{ nr: '9300', name: de ? 'Lohnsteuer' : 'Payroll Tax', used: true },
]},
]
return (
<GlassCard hover={false} className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-bold text-white/80">{de ? 'Kontenrahmen SKR04 — Breakpilot COMPLAI GmbH' : 'Chart of Accounts SKR04 — Breakpilot COMPLAI GmbH'}</h3>
<div className="flex items-center gap-3 text-[9px]">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-emerald-400 inline-block" /> {de ? 'Aktiv genutzt' : 'Actively used'}</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-white/15 inline-block" /> {de ? 'Geplant / nicht aktiv' : 'Planned / inactive'}</span>
</div>
</div>
<div className="space-y-1">
{skr04.map(k => (
<div key={k.klasse}>
<button onClick={() => toggleSKR(k.klasse)} className="w-full flex items-center gap-2 py-1.5 px-2 rounded hover:bg-white/[0.03] transition-colors text-left">
<span className="text-[10px] text-white/30 w-3">{openSKR.has(k.klasse) ? '▾' : '▸'}</span>
<span className={`text-xs font-bold ${k.color}`}>Klasse {k.klasse}</span>
<span className="text-xs text-white/60"> {k.title}</span>
<span className="text-[9px] text-white/25 ml-auto">{k.accounts.filter(a => a.used).length}/{k.accounts.length}</span>
</button>
{openSKR.has(k.klasse) && (
<div className="ml-7 mb-2 space-y-0.5">
{k.accounts.map(a => (
<div key={a.nr} className={`flex items-center gap-2 py-0.5 px-2 rounded text-[11px] ${a.used ? 'text-white/70' : 'text-white/25'}`}>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${a.used ? 'bg-emerald-400' : 'bg-white/15'}`} />
<span className="font-mono text-white/30 w-10">{a.nr}</span>
<span>{a.name}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
<div className="mt-3 pt-2 border-t border-white/5 text-[10px] text-white/25 text-center">
SKR04 (Industriekontenrahmen) · {de ? 'Angepasst für SaaS/Tech GmbH' : 'Adapted for SaaS/Tech GmbH'} · {de ? '10 Klassen · 62 Konten' : '10 classes · 62 accounts'}
</div>
</GlassCard>
)
}

View File

@@ -2,12 +2,18 @@
import { useCallback, useEffect, useState } from 'react'
import { Language } from '@/lib/types'
import { t } from '@/lib/i18n'
import ProjectionFooter from '../ui/ProjectionFooter'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import GlassCard from '../ui/GlassCard'
import { RefreshCw, Download, ChevronLeft, ChevronRight, BarChart3, Target } from 'lucide-react'
import { BarChart3, Target } from 'lucide-react'
import KPIsTab from './FinanzplanSlide.kpis'
import ChartsTab from './FinanzplanSlide.charts'
import SKRTab from './FinanzplanSlide.skr'
import {
SheetMeta, SheetRow, FpScenario,
} from './FinanzplanSlide.helpers'
import { GuvTable, MonthlyGrid } from './FinanzplanSlide.datagrid'
interface FinanzplanSlideProps {
lang: Language
@@ -16,82 +22,6 @@ interface FinanzplanSlideProps {
isWandeldarlehen?: boolean
}
interface SheetMeta {
name: string
label_de: string
label_en: string
rows: number
}
interface SheetRow {
id: number
row_label?: string
person_name?: string
item_name?: string
category?: string
section?: string
is_editable?: boolean
is_sum_row?: boolean
values?: Record<string, number>
values_total?: Record<string, number>
values_brutto?: Record<string, number>
brutto_monthly?: number
position?: string
start_date?: string
purchase_amount?: number
[key: string]: unknown
}
const MONTH_LABELS = [
'Jan', 'Feb', 'Mrz', 'Apr', 'Mai', 'Jun',
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez',
]
function getLabel(row: SheetRow): string {
return row.row_label || row.person_name || row.item_name || '—'
}
const FORMULA_TOOLTIPS: Record<string, string> = {
'Fort-/Weiterbildungskosten (F)': 'Mitarbeiter (ohne Gründer) × 300 EUR/Mon',
'Fahrzeugkosten (F)': 'Mitarbeiter (ohne Gründer) × 200 EUR/Mon',
'KFZ-Steuern (F)': 'Mitarbeiter (ohne Gründer) × 25 EUR/Mon',
'KFZ-Versicherung (F)': 'Mitarbeiter (ohne Gründer) × 150 EUR/Mon',
'Reisekosten (F)': 'Headcount gesamt × 75 EUR/Mon',
'Bewirtungskosten (F)': 'Bestandskunden × 50 EUR/Mon',
'Internet/Mobilfunk (F)': 'Headcount gesamt × 50 EUR/Mon',
'Cloud-Hosting (SysEleven/Hetzner)': '1.500 EUR Basis + (Kunden - 10) × 100 EUR (erste 10 inkl.)',
'Berufsgenossenschaft (F)': '0,5% der Brutto-Lohnsumme (VBG IT/Büro)',
'Allgemeine Marketingkosten (F)': '8% vom Umsatz (2026-2028), 10% ab 2029',
'Gewerbesteuer (F)': '12,25% vom Gewinn (Messzahl 3,5% × Hebesatz 350%, nur bei Gewinn)',
'Personalkosten': 'Summe aus Tab Personalkosten',
'Abschreibungen': 'Summe AfA aus Tab Investitionen',
}
function LabelWithTooltip({ label }: { label: string }) {
const tooltip = FORMULA_TOOLTIPS[label]
if (!tooltip) return <span>{label}</span>
return (
<span className="group relative cursor-help">
{label}
<span className="invisible group-hover:visible absolute left-0 top-full mt-1 z-50 bg-slate-800 border border-white/10 text-[9px] text-white/70 px-2 py-1 rounded shadow-lg whitespace-nowrap">
{tooltip}
</span>
</span>
)
}
function getValues(row: SheetRow): Record<string, number> {
return row.values || row.values_total || row.values_brutto || (row as Record<string, unknown>).values_invest as Record<string, number> || {}
}
function formatCell(v: number | undefined): string {
if (v === undefined || v === null) return ''
if (v === 0) return '—'
return Math.round(v).toLocaleString('de-DE', { maximumFractionDigits: 0 })
}
interface FpScenario { id: string; name: string; is_default: boolean }
export default function FinanzplanSlide({ lang, investorId, preferredScenarioId, isWandeldarlehen }: FinanzplanSlideProps) {
const [sheets, setSheets] = useState<SheetMeta[]>([])
const [scenarios, setScenarios] = useState<FpScenario[]>([])
@@ -103,8 +33,6 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId,
const [loading, setLoading] = useState(false)
const [yearOffset, setYearOffset] = useState(0) // 0=2026, 1=2027, ...
const [chartDetail, setChartDetail] = useState<string | null>(null)
const [openSKR, setOpenSKR] = useState<Set<string>>(new Set(['4', '6', '7']))
const toggleSKR = (k: string) => setOpenSKR(prev => { const n = new Set(prev); n.has(k) ? n.delete(k) : n.add(k); return n })
const de = lang === 'de'
// KPIs loaded directly from fp_* tables (source of truth)
@@ -207,10 +135,6 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId,
useEffect(() => { loadSheet(activeSheet) }, [activeSheet, loadSheet])
const currentYear = 2026 + yearOffset
const monthStart = yearOffset * 12 + 1
const monthEnd = monthStart + 11
return (
<div className="max-w-[95vw] mx-auto">
<FadeInView className="text-center mb-4">
@@ -259,453 +183,15 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId,
</div>
{/* === KPIs Tab === */}
{activeSheet === 'kpis' && (
<GlassCard hover={false} className="p-4">
<h3 className="text-sm font-bold text-emerald-400 uppercase tracking-wider mb-4">{de ? 'Wichtige Kennzahlen (pro Jahr)' : 'Key Metrics (per year)'}</h3>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-white/10">
<th className="text-left py-2 px-2 text-white/60 font-medium">KPI</th>
{[2026, 2027, 2028, 2029, 2030].map(y => (
<th key={y} className="text-right py-2 px-3 text-white/60 font-medium">{y}</th>
))}
</tr>
</thead>
<tbody>
{(() => {
const years = ['y2026', 'y2027', 'y2028', 'y2029', 'y2030']
if (!fpKPIs['y2026']) return (
<tr><td colSpan={6} className="text-center py-4 text-white/30">{de ? 'Finanzplan wird geladen...' : 'Loading financial plan...'}</td></tr>
)
const v = (yk: string, key: string) => fpKPIs[yk]?.[key] || 0
const kpiRows = [
{ label: 'MRR (Dez)', values: years.map(y => v(y, 'mrr')), unit: '€', bold: true },
{ label: 'ARR (Dez × 12)', values: years.map(y => v(y, 'arr')), unit: '€', bold: true },
{ label: de ? 'Kunden (Dez)' : 'Customers (Dec)', values: years.map(y => v(y, 'customers')), unit: '', bold: false },
{ label: de ? 'ACV (Umsatz/Kunden)' : 'ACV (Revenue/Customers)', values: years.map(y => v(y, 'arpu')), unit: '€', bold: false },
{ label: 'Gross Margin', values: years.map(y => v(y, 'grossMargin')), unit: '%', bold: false },
{ label: de ? 'Umsatzwachstum (YoY)' : 'Revenue Growth (YoY)', values: years.map(y => v(y, 'nrr')), unit: '%', bold: false },
{ label: de ? 'Mitarbeiter' : 'Employees', values: years.map(y => v(y, 'headcount')), unit: '', bold: false },
{ label: de ? 'Umsatz/Mitarbeiter' : 'Revenue/Employee', values: years.map(y => v(y, 'revPerEmp')), unit: '€', bold: false },
{ label: de ? 'Personalkosten' : 'Personnel Costs', values: years.map(y => v(y, 'personal')), unit: '<27><>', bold: false },
{ label: 'EBIT', values: years.map(y => v(y, 'ebit')), unit: '€', bold: true },
{ label: de ? 'EBIT-Marge' : 'EBIT Margin', values: years.map(y => v(y, 'ebitMargin')), unit: '%', bold: false },
{ label: de ? 'Steuern' : 'Taxes', values: years.map(y => v(y, 'steuern')), unit: '€', bold: false },
{ label: de ? 'Jahresüberschuss' : 'Net Income', values: years.map(y => v(y, 'netIncome')), unit: '€', bold: true },
{ label: de ? 'Liquidität (Dez)' : 'Cash (Dec)', values: years.map(y => v(y, 'liquiditaet')), unit: '€', bold: true },
{ label: 'Burn Rate', values: years.map(y => v(y, 'burnRate')), unit: '€/Mo', bold: false },
]
return kpiRows.map((row, idx) => (
<tr key={idx} className={`border-b border-white/[0.03] ${row.bold ? 'bg-white/[0.03]' : ''}`}>
<td className={`py-1.5 px-2 ${row.bold ? 'font-bold text-white/80' : 'text-white/60'}`}>{row.label}</td>
{row.values.map((v, i) => {
const num = typeof v === 'number' ? v : 0
const display = typeof v === 'string' ? v : (
row.unit === '%' ? `${v}%` :
row.unit === '€/Mo' ? formatCell(num) + '/Mo' :
formatCell(num)
)
return (
<td key={i} className={`text-right py-1.5 px-3 font-mono ${num < 0 ? 'text-red-400' : row.bold ? 'text-white/80 font-bold' : 'text-white/50'}`}>
{display}
</td>
)
})}
</tr>
))
})()}
</tbody>
</table>
</GlassCard>
)}
{activeSheet === 'kpis' && <KPIsTab fpKPIs={fpKPIs} de={de} />}
{/* === Charts Tab === */}
{activeSheet === 'charts' && (() => {
const years = ['y2026','y2027','y2028','y2029','y2030']
const fmtK = (v: number) => Math.abs(v) >= 1000000 ? `${(v/1000000).toFixed(1)}M` : `${Math.round(v/1000)}k`
const fmtV = (v: number) => v.toLocaleString('de-DE')
const chartDetails: Record<string, { title: string; desc: string }> = {
mrr: { title: 'MRR (Monthly Recurring Revenue)', desc: de ? 'Monatlich wiederkehrender Umsatz im Dezember des jeweiligen Jahres — der wichtigste KPI für SaaS-Unternehmen. Zeigt den tatsächlichen monatlichen Run-Rate, nicht den Jahresdurchschnitt. ARR = MRR × 12.' : 'Monthly recurring revenue in December of each year — the most important SaaS KPI. Shows the actual monthly run rate, not the annual average. ARR = MRR × 12.' },
ebit: { title: 'EBIT (Earnings Before Interest & Taxes)', desc: de ? 'Operatives Ergebnis vor Zinsen und Steuern — zeigt die tatsächliche Profitabilität des Geschäftsbetriebs. Positiver EBIT = das Geschäftsmodell funktioniert.' : 'Operating profit before interest and taxes — shows actual profitability of operations. Positive EBIT = the business model works.' },
headcount: { title: de ? 'Personalaufbau' : 'Headcount', desc: de ? 'Geplanter Teamaufbau über 5 Jahre. Wir starten lean mit 2 Gründern und wachsen bedarfsorientiert. Jede Einstellung ist an einen konkreten Meilenstein geknüpft.' : 'Planned team growth over 5 years. We start lean with 2 founders and grow based on demand. Each hire is tied to a concrete milestone.' },
cash: { title: de ? 'Liquidität (Jahresende)' : 'Cash Position (Year-End)', desc: de ? 'Kontostand am Ende jedes Jahres. Zeigt ob genug Geld für den laufenden Betrieb vorhanden ist. Die Liquiditätskurve berücksichtigt alle Einnahmen, Ausgaben, Investitionen und Finanzierungen.' : 'Bank balance at end of each year. Shows if enough cash is available for operations. The liquidity curve accounts for all revenue, expenses, investments and financing.' },
revcost: { title: de ? 'Umsatz vs. Gesamtkosten' : 'Revenue vs. Total Costs', desc: de ? 'Vergleich zwischen Einnahmen und Ausgaben. Der Schnittpunkt zeigt den Break-Even — ab dann verdient das Unternehmen mehr als es ausgibt.' : 'Comparison between income and expenses. The intersection shows break-even — from then on, the company earns more than it spends.' },
acv: { title: 'ACV (Average Contract Value)', desc: de ? 'Durchschnittlicher Vertragswert pro Kunde und Jahr. Steigender ACV bedeutet: Kunden kaufen mehr Module oder wechseln in höhere Tiers.' : 'Average contract value per customer per year. Rising ACV means: customers buy more modules or upgrade to higher tiers.' },
grossMargin: { title: 'Gross Margin', desc: de ? 'Rohertragsmarge — wieviel Prozent vom Umsatz nach Abzug der direkten Kosten (Cloud-Infrastruktur, Lizenzen) übrig bleibt. Bei SaaS typisch 70-90%.' : 'How much revenue remains after direct costs (cloud infrastructure, licenses). Typical for SaaS: 70-90%.' },
nrr: { title: de ? 'Umsatzwachstum (YoY)' : 'Revenue Growth (YoY)', desc: de ? 'Jahresvergleich des Gesamtumsatzes — zeigt die Wachstumsgeschwindigkeit. In der Frühphase primär durch Neukundengewinnung getrieben, später zunehmend durch Expansion bestehender Kunden (Upselling in höhere Tiers).' : 'Year-over-year total revenue comparison — shows growth velocity. In early stages driven primarily by new customer acquisition, later increasingly by expansion of existing customers (upselling to higher tiers).' },
ebitMargin: { title: 'EBIT Margin', desc: de ? 'Operatives Ergebnis in Prozent vom Umsatz. Zeigt die Effizienz des Geschäftsmodells. Ziel für SaaS: 20-30% in der Reifephase.' : 'Operating result as percentage of revenue. Shows business model efficiency. Target for SaaS: 20-30% at maturity.' },
}
const detailInfo = chartDetail ? chartDetails[chartDetail] : null
return (
<div className="space-y-4">
{/* Detail overlay */}
{detailInfo && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setChartDetail(null)}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div className="relative bg-slate-900/95 border border-white/10 rounded-2xl p-6 max-w-lg w-full" onClick={e => e.stopPropagation()}>
<button onClick={() => setChartDetail(null)} className="absolute top-4 right-4 text-white/40 hover:text-white/80 text-lg"></button>
<h3 className="text-xl font-bold text-white mb-3">{detailInfo.title}</h3>
<p className="text-sm text-white/60 leading-relaxed">{detailInfo.desc}</p>
{chartDetail && fpKPIs.y2026 && (
<div className="mt-4 pt-4 border-t border-white/10">
<div className="grid grid-cols-5 gap-2 text-center">
{[2026,2027,2028,2029,2030].map(y => {
const keyMap: Record<string, string> = { cash: 'liquiditaet', revcost: 'revenue', acv: 'arpu' }
const key = keyMap[chartDetail!] || chartDetail
const v = fpKPIs[`y${y}`]?.[key as keyof typeof fpKPIs['y2026']] as number || 0
return (
<div key={y}>
<div className="text-[10px] text-white/40">{y}</div>
<div className="text-sm font-bold text-white">{typeof v === 'number' && Math.abs(v) >= 1000 ? fmtK(v) : `${v}`}</div>
</div>
)
})}
</div>
</div>
)}
</div>
</div>
)}
{/* MRR + Kunden Chart */}
<GlassCard hover={false} className="p-4 cursor-pointer" onClick={() => setChartDetail('mrr')}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-bold text-indigo-400 uppercase tracking-wider">{de ? 'MRR & Kundenentwicklung' : 'MRR & Customer Growth'}</h3>
<span className="text-[9px] text-white/30">{de ? 'Klicken für Details' : 'Click for details'}</span>
</div>
<div className="flex">
{/* Y-axis labels */}
<div className="flex flex-col justify-between h-40 pr-2 text-[9px] text-white/30 text-right" style={{ width: 45 }}>
{(() => { const m = Math.max(...years.map(y => fpKPIs[y]?.mrr || 0), 1); return [m, Math.round(m/2), 0].map((v,i) => <span key={i}>{fmtK(v)} </span>) })()}
</div>
<div className="flex-1 grid grid-cols-5 gap-1 items-end h-40 border-l border-b border-white/10 pl-2 pb-1">
{years.map((y, idx) => {
const d = { mrr: fpKPIs[y]?.mrr || 0, cust: fpKPIs[y]?.customers || 0 }
const maxMrr = Math.max(...years.map(k => fpKPIs[k]?.mrr || 0), 1)
const maxCust = Math.max(...years.map(k => fpKPIs[k]?.customers || 0), 1)
return (
<div key={idx} className="flex flex-col items-center gap-1">
<div className="flex items-end gap-1 w-full justify-center" style={{ height: '130px' }}>
<div className="w-8 bg-indigo-500/60 rounded-t transition-all" style={{ height: `${(d.mrr / maxMrr) * 120}px` }}>
<div className="text-[10px] text-white/80 text-center -mt-3.5 whitespace-nowrap font-semibold">{fmtK(d.mrr)}</div>
</div>
<div className="w-8 bg-emerald-500/60 rounded-t transition-all" style={{ height: `${(d.cust / maxCust) * 120}px` }}>
<div className="text-[10px] text-white/80 text-center -mt-3.5 whitespace-nowrap font-semibold">{d.cust}</div>
</div>
</div>
<span className="text-[10px] text-white/50 font-medium">{y.slice(1)}</span>
</div>
)
})}
</div>
</div>
<div className="flex justify-center gap-6 mt-2 text-[10px]">
<span className="flex items-center gap-1.5"><span className="w-3 h-2 bg-indigo-500/60 rounded inline-block" /> MRR (/Mon)</span>
<span className="flex items-center gap-1.5"><span className="w-3 h-2 bg-emerald-500/60 rounded inline-block" /> {de ? 'Bestandskunden (Dez)' : 'Customers (Dec)'}</span>
</div>
</GlassCard>
{/* EBIT + Headcount */}
<div className="grid md:grid-cols-2 gap-4">
<GlassCard hover={false} className="p-4 cursor-pointer" onClick={() => setChartDetail('ebit')}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-bold text-purple-400 uppercase tracking-wider">EBIT</h3>
<span className="text-[9px] text-white/30">/Jahr</span>
</div>
<div className="flex">
<div className="flex flex-col justify-between h-28 pr-2 text-[9px] text-white/30 text-right" style={{ width: 40 }}>
{(() => { const vals = years.map(y => fpKPIs[y]?.ebit || 0); const mx = Math.max(...vals.map(Math.abs), 1); return [fmtK(mx), '0', fmtK(-mx)].map((v,i) => <span key={i}>{v}</span>) })()}
</div>
<div className="flex-1 grid grid-cols-5 gap-1 items-end h-28 border-l border-white/10 pl-2">
{years.map((y, idx) => {
const v = fpKPIs[y]?.ebit || 0
const maxAbs = Math.max(...years.map(k => Math.abs(fpKPIs[k]?.ebit || 0)), 1)
const h = Math.abs(v) / maxAbs * 90
return (
<div key={idx} className="flex flex-col items-center">
<div className="w-10 flex flex-col justify-end" style={{ height: '100px' }}>
<div className={`${v >= 0 ? 'bg-emerald-500/60 rounded-t' : 'bg-red-500/60 rounded-b'} w-full`} style={{ height: `${h}px` }}>
<div className={`text-[10px] ${v >= 0 ? 'text-emerald-300' : 'text-red-300'} text-center -mt-3.5 whitespace-nowrap font-semibold`}>{fmtK(v)}</div>
</div>
</div>
<span className="text-[10px] text-white/50 mt-1 font-medium">{y.slice(1)}</span>
</div>
)
})}
</div>
</div>
</GlassCard>
<GlassCard hover={false} className="p-4 cursor-pointer" onClick={() => setChartDetail('headcount')}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-bold text-amber-400 uppercase tracking-wider">{de ? 'Personalaufbau' : 'Headcount'}</h3>
<span className="text-[9px] text-white/30">{de ? 'Mitarbeiter (Dez)' : 'Employees (Dec)'}</span>
</div>
<div className="flex">
<div className="flex flex-col justify-between h-28 pr-2 text-[9px] text-white/30 text-right" style={{ width: 25 }}>
{(() => { const m = Math.max(...years.map(y => fpKPIs[y]?.headcount || 0), 1); return [m, Math.round(m/2), 0].map((v,i) => <span key={i}>{v}</span>) })()}
</div>
<div className="flex-1 grid grid-cols-5 gap-1 items-end h-28 border-l border-b border-white/10 pl-2 pb-1">
{years.map((y, idx) => {
const v = fpKPIs[y]?.headcount || 0
const mx = Math.max(...years.map(k => fpKPIs[k]?.headcount || 0), 1)
return (
<div key={idx} className="flex flex-col items-center">
<div className="w-10 flex flex-col justify-end" style={{ height: '90px' }}>
<div className="bg-amber-500/60 rounded-t w-full" style={{ height: `${(v / mx) * 80}px` }}>
<div className="text-[11px] text-amber-300 text-center -mt-3.5 font-bold">{v}</div>
</div>
</div>
<span className="text-[10px] text-white/50 mt-1 font-medium">{y.slice(1)}</span>
</div>
)
})}
</div>
</div>
</GlassCard>
</div>
{/* Liquidität + Revenue vs Costs */}
<div className="grid md:grid-cols-2 gap-4">
<GlassCard hover={false} className="p-4 cursor-pointer" onClick={() => setChartDetail('cash')}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-bold text-cyan-400 uppercase tracking-wider">{de ? 'Liquidität' : 'Cash Position'}</h3>
<span className="text-[9px] text-white/30">{de ? 'Jahresende' : 'Year-End'}</span>
</div>
<div className="flex">
<div className="flex flex-col justify-between h-28 pr-2 text-[9px] text-white/30 text-right" style={{ width: 40 }}>
{(() => { const m = Math.max(...years.map(y => Math.abs(fpKPIs[y]?.liquiditaet || 0)), 1); return [fmtK(m), fmtK(m/2), '0'].map((v,i) => <span key={i}>{v}</span>) })()}
</div>
<div className="flex-1 grid grid-cols-5 gap-1 items-end h-28 border-l border-b border-white/10 pl-2 pb-1">
{years.map((y, idx) => {
const v = fpKPIs[y]?.liquiditaet || 0
const mx = Math.max(...years.map(k => Math.abs(fpKPIs[k]?.liquiditaet || 0)), 1)
const h = Math.abs(v) / mx * 90
return (
<div key={idx} className="flex flex-col items-center">
<div className="w-10 flex flex-col justify-end" style={{ height: '90px' }}>
<div className={`${v >= 0 ? 'bg-cyan-500/60 rounded-t' : 'bg-red-500/60 rounded-b'} w-full`} style={{ height: `${Math.max(h, 4)}px` }}>
<div className={`text-[10px] ${v >= 0 ? 'text-cyan-300' : 'text-red-300'} text-center -mt-3.5 whitespace-nowrap font-semibold`}>{fmtK(v)}</div>
</div>
</div>
<span className="text-[10px] text-white/50 mt-1 font-medium">{y.slice(1)}</span>
</div>
)
})}
</div>
</div>
</GlassCard>
<GlassCard hover={false} className="p-4 cursor-pointer" onClick={() => setChartDetail('revcost')}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-bold text-indigo-400 uppercase tracking-wider">{de ? 'Umsatz vs. Kosten' : 'Revenue vs. Costs'}</h3>
<span className="text-[9px] text-white/30">/Jahr</span>
</div>
<div className="flex">
<div className="flex flex-col justify-between h-28 pr-2 text-[9px] text-white/30 text-right" style={{ width: 40 }}>
{(() => {
const d = years.map(y => ({ rev: fpKPIs[y]?.revenue || 0, costs: (fpKPIs[y]?.revenue || 0) - (fpKPIs[y]?.ebit || 0) }))
const mx = Math.max(...d.map(x => Math.max(x.rev, x.costs)), 1)
return [fmtK(mx), fmtK(mx/2), '0'].map((v,i) => <span key={i}>{v}</span>)
})()}
</div>
<div className="flex-1 grid grid-cols-5 gap-1 items-end h-28 border-l border-b border-white/10 pl-2 pb-1">
{(() => {
const data = years.map(y => ({ year: y.slice(1), rev: fpKPIs[y]?.revenue || 0, costs: (fpKPIs[y]?.revenue || 0) - (fpKPIs[y]?.ebit || 0) }))
const mx = Math.max(...data.map(d => Math.max(d.rev, d.costs)), 1)
return data.map((d, idx) => (
<div key={idx} className="flex flex-col items-center gap-1">
<div className="flex items-end gap-1 w-full justify-center" style={{ height: '90px' }}>
<div className="w-6 bg-indigo-500/60 rounded-t" style={{ height: `${(d.rev / mx) * 80}px` }}>
<div className="text-[9px] text-indigo-300 text-center -mt-3 whitespace-nowrap font-semibold">{fmtK(d.rev)}</div>
</div>
<div className="w-6 bg-red-500/40 rounded-t" style={{ height: `${(d.costs / mx) * 80}px` }}>
<div className="text-[9px] text-red-300 text-center -mt-3 whitespace-nowrap font-semibold">{fmtK(d.costs)}</div>
</div>
</div>
<span className="text-[10px] text-white/50 font-medium">{d.year}</span>
</div>
))
})()}
</div>
</div>
<div className="flex justify-center gap-4 mt-2 text-[9px]">
<span className="flex items-center gap-1"><span className="w-3 h-1.5 bg-indigo-500/60 rounded inline-block" /> {de ? 'Umsatz' : 'Revenue'}</span>
<span className="flex items-center gap-1"><span className="w-3 h-1.5 bg-red-500/40 rounded inline-block" /> {de ? 'Kosten' : 'Costs'}</span>
</div>
</GlassCard>
</div>
{/* Unit Economics — clickable cards */}
<GlassCard hover={false} className="p-4">
<h3 className="text-xs font-bold text-purple-400 uppercase tracking-wider mb-3">Unit Economics (20262030)</h3>
<div className="grid md:grid-cols-4 gap-3">
{[
{ label: 'ACV', key: 'arpu', unit: '€', color: 'text-indigo-300', bg: 'bg-indigo-500/60', detail: 'acv' },
{ label: 'Gross Margin', key: 'grossMargin', unit: '%', color: 'text-emerald-300', bg: 'bg-emerald-500/60', detail: 'grossMargin' },
{ label: de ? 'Wachstum' : 'Growth', key: 'nrr', unit: '%', color: 'text-purple-300', bg: 'bg-purple-500/60', detail: 'nrr' },
{ label: 'EBIT Margin', key: 'ebitMargin', unit: '%', color: 'text-amber-300', bg: 'bg-amber-500/60', detail: 'ebitMargin' },
].map((metric, mIdx) => (
<div key={mIdx} className="text-center cursor-pointer hover:bg-white/[0.03] rounded-lg p-2 transition-colors" onClick={() => setChartDetail(metric.detail)}>
<p className={`text-[10px] font-bold ${metric.color} uppercase tracking-wider mb-2`}>{metric.label}</p>
<div className="flex items-end justify-center gap-1 h-16">
{[2026,2027,2028,2029,2030].map((y, idx) => {
const val = fpKPIs[`y${y}`]?.[metric.key as keyof typeof fpKPIs['y2026']] as number || 0
const maxVal = Math.max(...[2026,2027,2028,2029,2030].map(yr => Math.abs(fpKPIs[`y${yr}`]?.[metric.key as keyof typeof fpKPIs['y2026']] as number || 0)), 1)
const h = Math.abs(val) / maxVal * 50
return (
<div key={idx} className="flex flex-col items-center">
<div className={`w-4 ${val >= 0 ? metric.bg + ' rounded-t' : 'bg-red-500/60 rounded-b'}`} style={{ height: `${Math.max(h, 2)}px` }} />
<span className="text-[8px] text-white/30 mt-0.5">{String(y).slice(2)}</span>
</div>
)
})}
</div>
<p className={`text-sm font-bold ${metric.color} mt-1`}>
{(() => {
const v = fpKPIs.y2030?.[metric.key as keyof typeof fpKPIs['y2026']] as number || 0
return metric.unit === '€' ? `${fmtV(v)}${metric.unit}` : `${v}${metric.unit}`
})()}
</p>
<p className="text-[8px] text-white/25">2030</p>
</div>
))}
</div>
</GlassCard>
</div>
)
})()}
{activeSheet === 'charts' && (
<ChartsTab fpKPIs={fpKPIs} de={de} chartDetail={chartDetail} setChartDetail={setChartDetail} />
)}
{/* === Kontenrahmen SKR04 === */}
{activeSheet === 'skr' && (() => {
const skr04: { klasse: string; title: string; color: string; accounts: { nr: string; name: string; used?: boolean }[] }[] = [
{ klasse: '0', title: de ? 'Anlagevermögen' : 'Fixed Assets', color: 'text-slate-400', accounts: [
{ nr: '0200', name: de ? 'Technische Anlagen & Maschinen' : 'Technical Equipment', used: false },
{ nr: '0400', name: de ? 'Betriebs- und Geschäftsausstattung' : 'Office & Business Equipment', used: true },
{ nr: '0420', name: de ? 'EDV-Hardware' : 'IT Hardware', used: true },
{ nr: '0440', name: de ? 'Geringwertige Wirtschaftsgüter (GWG)' : 'Low-Value Assets (GWG)', used: true },
{ nr: '0480', name: de ? 'Immaterielle Vermögensgegenstände' : 'Intangible Assets', used: true },
]},
{ klasse: '1', title: de ? 'Umlaufvermögen' : 'Current Assets', color: 'text-blue-400', accounts: [
{ nr: '1200', name: de ? 'Bank (Geschäftskonto)' : 'Bank (Business Account)', used: true },
{ nr: '1210', name: de ? 'Bank (Festgeld/Rücklagen)' : 'Bank (Fixed Deposit)', used: false },
{ nr: '1400', name: de ? 'Forderungen aus L&L' : 'Accounts Receivable', used: true },
{ nr: '1590', name: de ? 'Durchlaufende Posten' : 'Transit Items', used: false },
]},
{ klasse: '2', title: de ? 'Eigenkapital & Verbindlichkeiten' : 'Equity & Liabilities', color: 'text-purple-400', accounts: [
{ nr: '2000', name: de ? 'Gezeichnetes Kapital (Stammkapital)' : 'Share Capital', used: true },
{ nr: '2010', name: de ? 'Kapitalrücklage (Wandeldarlehen)' : 'Capital Reserve (Convertible Loan)', used: true },
{ nr: '2100', name: de ? 'Gewinnvortrag / Verlustvortrag' : 'Retained Earnings / Losses', used: true },
{ nr: '2900', name: de ? 'Jahresüberschuss / -fehlbetrag' : 'Net Income / Loss', used: true },
]},
{ klasse: '3', title: de ? 'Rückstellungen & Verbindlichkeiten' : 'Provisions & Payables', color: 'text-indigo-400', accounts: [
{ nr: '3070', name: de ? 'Verbindlichkeiten ggü. Kreditinstituten' : 'Bank Liabilities', used: true },
{ nr: '3100', name: de ? 'Verbindlichkeiten aus L&L' : 'Accounts Payable', used: true },
{ nr: '3150', name: de ? 'Darlehen (L-Bank Pre-Seed)' : 'Loan (L-Bank Pre-Seed)', used: true },
{ nr: '3500', name: de ? 'Umsatzsteuer-Verbindlichkeit' : 'VAT Payable', used: true },
{ nr: '3520', name: de ? 'Lohnsteuer-Verbindlichkeit' : 'Payroll Tax Payable', used: true },
{ nr: '3550', name: de ? 'Sozialversicherungs-Verbindlichkeit' : 'Social Security Payable', used: true },
{ nr: '3900', name: de ? 'Rückstellungen (Jahresabschluss etc.)' : 'Provisions (Closing etc.)', used: true },
]},
{ klasse: '4', title: de ? 'Betriebliche Erträge' : 'Operating Revenue', color: 'text-emerald-400', accounts: [
{ nr: '4400', name: de ? 'Erlöse Software-Lizenzen (SaaS)' : 'Software License Revenue (SaaS)', used: true },
{ nr: '4410', name: de ? 'Erlöse Beratung & Service' : 'Consulting & Service Revenue', used: true },
{ nr: '4440', name: de ? 'Erlöse Hardware (Mac Mini/Studio)' : 'Hardware Revenue', used: false },
{ nr: '4500', name: de ? 'Sonstige betriebliche Erträge' : 'Other Operating Revenue', used: true },
{ nr: '4510', name: de ? 'Fördergelder / Grants' : 'Grants / Subsidies', used: true },
{ nr: '4520', name: de ? 'Forschungszulage (§ 27a EStG)' : 'Research Tax Credit', used: true },
]},
{ klasse: '5', title: de ? 'Materialaufwand / COGS' : 'Material Costs / COGS', color: 'text-orange-400', accounts: [
{ nr: '5400', name: de ? 'Cloud-Hosting (SysEleven/Hetzner)' : 'Cloud Hosting', used: true },
{ nr: '5410', name: de ? 'LLM-Inferenzkosten' : 'LLM Inference Costs', used: true },
{ nr: '5420', name: de ? '3rd Party API (Tavily etc.)' : '3rd Party API', used: true },
{ nr: '5430', name: de ? 'Datenbank-Hosting (PostgreSQL/Qdrant)' : 'Database Hosting', used: true },
{ nr: '5440', name: de ? 'CDN / Storage / Monitoring' : 'CDN / Storage / Monitoring', used: true },
]},
{ klasse: '6', title: de ? 'Personalaufwand' : 'Personnel Costs', color: 'text-cyan-400', accounts: [
{ nr: '6000', name: de ? 'Löhne und Gehälter' : 'Wages and Salaries', used: true },
{ nr: '6010', name: de ? 'Geschäftsführer-Gehälter' : 'Managing Director Salaries', used: true },
{ nr: '6100', name: de ? 'Soziale Abgaben (AG-Anteil)' : 'Social Security (Employer)', used: true },
{ nr: '6110', name: de ? 'Berufsgenossenschaft (VBG)' : 'Professional Association (VBG)', used: true },
{ nr: '6130', name: de ? 'Vermögenswirksame Leistungen' : 'Capital-Forming Benefits', used: false },
{ nr: '6170', name: de ? 'Freiwillige Sozialleistungen' : 'Voluntary Social Benefits', used: false },
]},
{ klasse: '7', title: de ? 'Sonstige betriebliche Aufwendungen' : 'Other Operating Expenses', color: 'text-amber-400', accounts: [
{ nr: '7000', name: de ? 'Raumkosten / Miete' : 'Rent / Room Costs', used: true },
{ nr: '7100', name: de ? 'Versicherungen (D&O, Cyber, Haftpflicht)' : 'Insurance (D&O, Cyber, Liability)', used: true },
{ nr: '7200', name: de ? 'Fahrzeugkosten / KFZ' : 'Vehicle Costs', used: true },
{ nr: '7300', name: de ? 'Werbe- und Marketingkosten' : 'Marketing Costs', used: true },
{ nr: '7310', name: de ? 'Teilnahme an Messen' : 'Trade Fair Participation', used: true },
{ nr: '7320', name: de ? 'Bewirtungskosten' : 'Entertainment Costs', used: true },
{ nr: '7400', name: de ? 'Reisekosten' : 'Travel Costs', used: true },
{ nr: '7500', name: de ? 'Internet / Mobilfunk' : 'Internet / Mobile', used: true },
{ nr: '7510', name: de ? 'Serverkosten / Cloud (→ Klasse 5)' : 'Server / Cloud (→ Class 5)', used: false },
{ nr: '7600', name: de ? 'Rechts-/Beratungskosten' : 'Legal / Advisory Costs', used: true },
{ nr: '7610', name: de ? 'Buchführungskosten' : 'Bookkeeping Costs', used: true },
{ nr: '7620', name: de ? 'Jahresabschlusskosten' : 'Annual Closing Costs', used: true },
{ nr: '7630', name: de ? 'Ext. Datenschutzbeauftragter' : 'Ext. Data Protection Officer', used: true },
{ nr: '7640', name: de ? 'Zertifizierung (ISO 27001 / BSI C5)' : 'Certification (ISO 27001 / BSI C5)', used: true },
{ nr: '7650', name: de ? 'Recruiting / Stellenanzeigen' : 'Recruiting / Job Ads', used: true },
{ nr: '7680', name: de ? 'IHK / Kammerbeiträge' : 'Chamber of Commerce Fees', used: true },
{ nr: '7690', name: de ? 'Rundfunkbeitrag' : 'Broadcasting Fee', used: true },
{ nr: '7700', name: de ? 'Abschreibungen (AfA)' : 'Depreciation', used: true },
{ nr: '7750', name: de ? 'Fort- und Weiterbildung' : 'Training & Development', used: true },
{ nr: '7800', name: de ? 'Bankgebühren / Nebenkosten Geldverkehr' : 'Bank Fees', used: true },
{ nr: '7900', name: de ? 'Schutzrechte / Lizenzkosten' : 'IP Rights / License Costs', used: true },
]},
{ klasse: '8', title: de ? 'Finanzerträge & -aufwendungen' : 'Financial Income & Expenses', color: 'text-red-400', accounts: [
{ nr: '8100', name: de ? 'Zinserträge' : 'Interest Income', used: false },
{ nr: '8200', name: de ? 'Zinsaufwendungen' : 'Interest Expenses', used: true },
{ nr: '8210', name: de ? 'Zinsen L-Bank Wandeldarlehen (8%)' : 'Interest L-Bank Convertible (8%)', used: true },
]},
{ klasse: '9', title: de ? 'Steuern & Jahresabschluss' : 'Taxes & Closing', color: 'text-rose-400', accounts: [
{ nr: '9000', name: de ? 'Gewerbesteuer' : 'Trade Tax', used: true },
{ nr: '9100', name: de ? 'Körperschaftsteuer + Soli' : 'Corporate Tax + Surcharge', used: true },
{ nr: '9200', name: de ? 'Umsatzsteuer (Zahllast)' : 'VAT (Payable)', used: true },
{ nr: '9300', name: de ? 'Lohnsteuer' : 'Payroll Tax', used: true },
]},
]
return (
<GlassCard hover={false} className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-bold text-white/80">{de ? 'Kontenrahmen SKR04 — Breakpilot COMPLAI GmbH' : 'Chart of Accounts SKR04 — Breakpilot COMPLAI GmbH'}</h3>
<div className="flex items-center gap-3 text-[9px]">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-emerald-400 inline-block" /> {de ? 'Aktiv genutzt' : 'Actively used'}</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-white/15 inline-block" /> {de ? 'Geplant / nicht aktiv' : 'Planned / inactive'}</span>
</div>
</div>
<div className="space-y-1">
{skr04.map(k => (
<div key={k.klasse}>
<button onClick={() => toggleSKR(k.klasse)} className="w-full flex items-center gap-2 py-1.5 px-2 rounded hover:bg-white/[0.03] transition-colors text-left">
<span className="text-[10px] text-white/30 w-3">{openSKR.has(k.klasse) ? '▾' : '▸'}</span>
<span className={`text-xs font-bold ${k.color}`}>Klasse {k.klasse}</span>
<span className="text-xs text-white/60"> {k.title}</span>
<span className="text-[9px] text-white/25 ml-auto">{k.accounts.filter(a => a.used).length}/{k.accounts.length}</span>
</button>
{openSKR.has(k.klasse) && (
<div className="ml-7 mb-2 space-y-0.5">
{k.accounts.map(a => (
<div key={a.nr} className={`flex items-center gap-2 py-0.5 px-2 rounded text-[11px] ${a.used ? 'text-white/70' : 'text-white/25'}`}>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${a.used ? 'bg-emerald-400' : 'bg-white/15'}`} />
<span className="font-mono text-white/30 w-10">{a.nr}</span>
<span>{a.name}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
<div className="mt-3 pt-2 border-t border-white/5 text-[10px] text-white/25 text-center">
SKR04 (Industriekontenrahmen) · {de ? 'Angepasst für SaaS/Tech GmbH' : 'Adapted for SaaS/Tech GmbH'} · {de ? '10 Klassen · 62 Konten' : '10 classes · 62 accounts'}
</div>
</GlassCard>
)
})()}
{activeSheet === 'skr' && <SKRTab de={de} />}
{/* Year Navigation — not for GuV, KPIs, Charts */}
{!['guv', 'kpis', 'charts', 'skr'].includes(activeSheet) && (
@@ -734,318 +220,16 @@ export default function FinanzplanSlide({ lang, investorId, preferredScenarioId,
{loading ? (
<div className="text-center py-8 text-white/30 text-sm">{de ? 'Lade...' : 'Loading...'}</div>
) : activeSheet === 'guv' ? (
/* === GuV: Annual table (y2026-y2030) === */
<table className="w-full text-[10px]">
<thead>
<tr className="border-b border-white/10">
<th className="text-left py-1.5 px-2 text-white/60 font-medium sticky left-0 bg-slate-900/90 backdrop-blur min-w-[220px]">
{de ? 'GuV-Position' : 'P&L Item'}
</th>
{[2026, 2027, 2028, 2029, 2030].map(y => (
<th key={y} className="text-right py-1.5 px-3 text-white/60 font-medium min-w-[100px]">{y}</th>
))}
</tr>
</thead>
<tbody>
{rows.map(row => {
const values = getValues(row)
const label = getLabel(row)
const isMajorSum = label === 'EBIT' || label.includes('Rohergebnis') || label.includes('Jahresüberschuss') || label.includes('Ergebnis nach Steuern')
const isMinorSum = row.is_sum_row || label.includes('Summe') || label.includes('Gesamtleistung') || label.includes('Steuern gesamt')
const isSumRow = isMajorSum || isMinorSum
return (
<tr key={row.id} className={`${isMajorSum ? 'border-t-2 border-t-white/20 border-b border-b-white/[0.05] bg-white/[0.05]' : isMinorSum ? 'border-t border-t-white/10 border-b border-b-white/[0.03] bg-white/[0.03]' : 'border-b border-white/[0.03]'} hover:bg-white/[0.02]`}>
<td className={`py-1.5 px-2 sticky left-0 bg-slate-900/90 backdrop-blur ${isMajorSum ? 'font-bold text-white text-xs' : isMinorSum ? 'font-semibold text-white/80' : 'text-white/60'}`}>
<LabelWithTooltip label={label} />
</td>
{[2026, 2027, 2028, 2029, 2030].map(y => {
const v = values[`y${y}`] || 0
return (
<td key={y} className={`text-right py-1.5 px-3 ${v < 0 ? 'text-red-400' : v > 0 ? (isMajorSum ? 'text-white' : isSumRow ? 'text-white/80' : 'text-white/50') : 'text-white/15'} ${isMajorSum ? 'font-bold text-xs' : isSumRow ? 'font-semibold' : ''}`}>
{v === 0 ? '—' : Math.round(v).toLocaleString('de-DE', { maximumFractionDigits: 0 })}
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
<GuvTable rows={rows} de={de} />
) : (
/* === Monthly/Annual Grid (all other sheets) === */
<table className="w-full text-[10px]">
<thead>
<tr className="border-b border-white/10">
<th className="text-left py-1.5 px-2 text-white/60 font-medium sticky left-0 bg-slate-900/90 backdrop-blur min-w-[160px]">
{de ? 'Position' : 'Item'}
</th>
{yearOffset === -1 ? (
// All years view
[2026, 2027, 2028, 2029, 2030].map(y => (
<th key={y} className="text-right py-1.5 px-3 text-white/60 font-medium min-w-[80px]">{y}</th>
))
) : (
<>
<th className="text-right py-1.5 px-2 text-white/60 font-medium min-w-[70px]">
{currentYear}
</th>
{MONTH_LABELS.map((label, idx) => (
<th key={idx} className="text-right py-1.5 px-1.5 text-white/50 font-normal min-w-[55px]">
{label}
</th>
))}
</>
)}
</tr>
</thead>
<tbody>
{(() => {
// Live-compute sum rows from detail rows (like Excel formulas)
const computedRows = rows.map(row => {
const label = getLabel(row)
const cat = row.category as string || ''
const rowType = (row as Record<string, unknown>).row_type as string || ''
const isSumLabel = row.is_sum_row || label.includes('Summe') || label.includes('SUMME') || label.includes('ÜBERSCHUSS') || label.includes('UEBERSCHUSS')
if (!isSumLabel) return row
let sourceRows: SheetRow[] = []
// === Betriebliche Aufwendungen: category-based sums ===
if (cat && cat !== 'summe') {
sourceRows = rows.filter(r => (r.category as string) === cat && !r.is_sum_row && getLabel(r) !== label)
} else if (label.includes('Summe sonstige')) {
sourceRows = rows.filter(r => {
const rCat = r.category as string || ''
const rLabel = getLabel(r)
return !r.is_sum_row && rCat !== 'personal' && rCat !== 'abschreibungen' && rCat !== 'summe' &&
!rLabel.includes('Personalkosten') && !rLabel.includes('Abschreibungen') && !rLabel.includes('Summe') && !rLabel.includes('SUMME')
})
} else if (label.includes('SUMME Betriebliche')) {
// Include ALL rows: personal + abschreibungen + all detail rows (but not other sum rows)
sourceRows = rows.filter(r => {
const rCat = r.category as string || ''
const rLabel = getLabel(r)
// Include Personalkosten and Abschreibungen (they have is_sum_row=true but are real data)
if (rLabel === 'Personalkosten' || rLabel === 'Abschreibungen') return true
// Exclude sum rows and category "summe"
return !r.is_sum_row && rCat !== 'summe' && !rLabel.includes('Summe') && !rLabel.includes('SUMME')
})
}
// === Liquidität: ALL sum/balance rows — keep DB values (engine computed) ===
else if (activeSheet === 'liquiditaet' && (
label.includes('Summe') || label.includes('ÜBERSCHUSS') || label.includes('UEBERSCHUSS') ||
label.includes('LIQUIDITÄT') || label.includes('LIQUIDITAET') || label.includes('Kontostand')
)) {
return row
}
// === Umsatzerlöse: GESAMTUMSATZ = sum of revenue rows only ===
else if (label.includes('GESAMTUMSATZ')) {
sourceRows = rows.filter(r => {
const sec = (r as Record<string, unknown>).section as string || ''
return sec === 'revenue' && !getLabel(r).includes('GESAMTUMSATZ')
})
}
// === Materialaufwand: SUMME = sum of cost rows only ===
else if (label.includes('SUMME Material') || (activeSheet === 'materialaufwand' && label === 'SUMME')) {
sourceRows = rows.filter(r => {
const sec = (r as Record<string, unknown>).section as string || ''
return sec === 'cost' && getLabel(r) !== 'SUMME'
})
}
// === Kunden GESAMT rows — trust DB values (engine computed) ===
else if (label.includes('GESAMT') || label.includes('Bestandskunden gesamt')) {
return row
}
if (sourceRows.length === 0) return row
const computed: Record<string, number> = {}
for (let m = 1; m <= 60; m++) {
const key = `m${m}`
computed[key] = Math.round(sourceRows.reduce((sum, r) => sum + (getValues(r)[key] || 0), 0))
}
return { ...row, values: computed, values_total: computed }
})
// For betriebliche: reorder so category header (sum_row) comes BEFORE detail rows
if (activeSheet === 'betriebliche') {
const grouped: SheetRow[] = []
const cats = new Map<string, { header: SheetRow | null; details: SheetRow[] }>()
// Collect by category
for (const row of computedRows) {
const cat = row.category as string || ''
if (!cats.has(cat)) cats.set(cat, { header: null, details: [] })
const g = cats.get(cat)!
if (row.is_sum_row) g.header = row
else g.details.push(row)
}
// Output: header first, then details (if open)
for (const [cat, g] of cats) {
if (g.header) grouped.push(g.header)
if (openCats.has(cat) || cat === 'summe' || cat === 'personal' || cat === 'abschreibungen') {
grouped.push(...g.details)
}
}
return grouped
}
return computedRows
})().map(row => {
const values = getValues(row)
const label = getLabel(row)
const isSumRow = row.is_sum_row || label.includes('GESAMT') || label.includes('Summe') || label.includes('ÜBERSCHUSS') || label.includes('LIQUIDITÄT') || label.includes('UEBERSCHUSS') || label.includes('LIQUIDITAET')
const isTotalRow = label.includes('GESAMT') || label.includes('Bestandskunden gesamt') || label.includes('GESAMTUMSATZ') || label.includes('SUMME')
const isEditable = false // read-only for investors
const cat = row.category as string || ''
// Make category sum rows clickable (accordion)
const isCatHeader = activeSheet === 'betriebliche' && row.is_sum_row && cat !== 'summe' && cat !== 'personal' && cat !== 'abschreibungen'
const isCatOpen = openCats.has(cat)
// Balance rows show Dec value, flow rows show annual sum
const section = (row as Record<string, unknown>).section as string || ''
const isBalanceRow = label.includes('Kontostand') || label === 'LIQUIDITÄT' || label === 'LIQUIDITAET'
|| label.includes('Bestandskunden') || (activeSheet === 'kunden' && row.row_label === 'Bestandskunden')
|| label.includes('Anzahl Kunden') || section === 'quantity'
const isUnitPrice = section === 'unit_cost' || section === 'einkauf' || label.includes('Einkaufspreis')
|| section === 'price' || label.includes('Preis/Monat')
let annual = 0
if (isUnitPrice) {
// Unit prices: show the price, not a sum
annual = values[`m${monthEnd}`] || values[`m${monthStart}`] || 0
} else if (isBalanceRow) {
// Point-in-time: show last month (December) value
annual = values[`m${monthEnd}`] || 0
} else {
// Flow: sum all 12 months
for (let m = monthStart; m <= monthEnd; m++) annual += values[`m${m}`] || 0
}
return (
<tr
key={row.id}
className={`${isTotalRow ? 'border-t-2 border-t-white/20 border-b border-b-white/[0.03]' : 'border-b border-white/[0.03]'} ${isSumRow ? 'bg-white/[0.03]' : ''} hover:bg-white/[0.02]`}
>
<td className={`py-1 px-2 sticky left-0 bg-slate-900/90 backdrop-blur ${isSumRow ? 'font-bold text-white/80' : 'text-white/60'} ${isCatHeader ? 'cursor-pointer select-none' : ''}`}
onClick={isCatHeader ? () => toggleCat(cat) : undefined}
>
<div className="flex items-center gap-1">
{isCatHeader && <span className="text-[10px] text-indigo-400 w-3 shrink-0">{isCatOpen ? '▾' : '▸'}</span>}
{isEditable && <span className="w-1 h-1 rounded-full bg-indigo-400 flex-shrink-0" />}
<span className="truncate"><LabelWithTooltip label={label} /></span>
{row.position && <span className="text-white/50 ml-1">({row.position})</span>}
</div>
</td>
{yearOffset === -1 ? (
// All years view: show annual values per year
[2026, 2027, 2028, 2029, 2030].map(y => {
const yStart = (y - 2026) * 12 + 1
const yEnd = yStart + 11
let yVal = 0
if (isUnitPrice) {
yVal = values[`m${yEnd}`] || 0
} else if (isBalanceRow) {
yVal = values[`m${yEnd}`] || 0
} else {
for (let m = yStart; m <= yEnd; m++) yVal += values[`m${m}`] || 0
}
return (
<td key={y} className={`text-right py-1 px-3 ${yVal < 0 ? 'text-red-400' : yVal > 0 ? (isSumRow ? 'text-white/80' : 'text-white/50') : 'text-white/15'} ${isSumRow ? 'font-bold' : ''}`}>
{formatCell(Math.round(yVal))}
</td>
)
})
) : (
<>
<td className={`text-right py-1 px-2 font-medium ${annual < 0 ? 'text-red-400' : isSumRow ? 'text-white/80' : 'text-white/50'}`}>
{formatCell(annual)}
</td>
{Array.from({ length: 12 }, (_, idx) => {
const mKey = `m${monthStart + idx}`
const v = values[mKey] || 0
return (
<td
key={idx}
className={`text-right py-1 px-1.5 ${
v < 0 ? 'text-red-400/70' : v > 0 ? (isSumRow ? 'text-white/70' : 'text-white/50') : 'text-white/15'
} ${isEditable ? 'cursor-pointer hover:bg-indigo-500/10' : ''}`}
onDoubleClick={() => {
if (!isEditable) return
const input = prompt(`${label}${MONTH_LABELS[idx]} ${currentYear}`, String(v))
if (input !== null) handleCellEdit(row.id, mKey, input)
}}
>
{formatCell(v)}
</td>
)
})}
</>
)}
</tr>
)
})}
</tbody>
{/* Summenzeile für relevante Sheets */}
{['personalkosten', 'investitionen'].includes(activeSheet) && rows.length > 0 && (() => {
const nonSumRows = rows.filter(r => {
const l = getLabel(r)
return !(r.is_sum_row || l.includes('GESAMT') || l.includes('Summe') || l.includes('Gesamtkosten') || l === 'SUMME')
})
return (
<tfoot>
<tr className="border-t-2 border-white/20 bg-white/[0.05]">
<td className="py-1.5 px-2 sticky left-0 bg-slate-900/90 backdrop-blur font-bold text-white/80 text-xs">
{de ? 'SUMME' : 'TOTAL'}
</td>
{yearOffset === -1 ? (
[2026, 2027, 2028, 2029, 2030].map(y => {
const yStart = (y - 2026) * 12 + 1
const yEnd = yStart + 11
let yVal = 0
for (let m = yStart; m <= yEnd; m++) {
for (const row of nonSumRows) yVal += getValues(row)[`m${m}`] || 0
}
return (
<td key={y} className={`text-right py-1.5 px-3 font-bold text-xs ${yVal < 0 ? 'text-red-400' : 'text-white/80'}`}>
{formatCell(Math.round(yVal))}
</td>
)
})
) : (
<>
{(() => {
let sumAnnual = 0
for (let m = monthStart; m <= monthEnd; m++) {
for (const row of nonSumRows) sumAnnual += getValues(row)[`m${m}`] || 0
}
return (
<td className={`text-right py-1.5 px-2 font-bold text-xs ${sumAnnual < 0 ? 'text-red-400' : 'text-white/80'}`}>
{formatCell(sumAnnual)}
</td>
)
})()}
{Array.from({ length: 12 }, (_, idx) => {
const mKey = `m${monthStart + idx}`
let v = 0
for (const row of nonSumRows) v += getValues(row)[mKey] || 0
return (
<td key={idx} className={`text-right py-1.5 px-1.5 font-bold text-xs ${v < 0 ? 'text-red-400' : v > 0 ? 'text-white/70' : 'text-white/15'}`}>
{formatCell(v)}
</td>
)
})}
</>
)}
</tr>
</tfoot>
)
})()}
</table>
<MonthlyGrid
rows={rows}
activeSheet={activeSheet}
de={de}
yearOffset={yearOffset}
openCats={openCats}
toggleCat={toggleCat}
/>
)}
</GlassCard>
)}

View File

@@ -0,0 +1,155 @@
// MilestonesSlide data — extracted from MilestonesSlide.tsx
export const TODAY_POSITION = 0.56
export interface Milestone {
id: string
when: string
tick: string
title: { de: string; en: string }
short: { de: string; en: string }
body: { de: string; en: string }
bullets: { de: string[]; en: string[] }
tint: string
done: boolean
next?: boolean
}
export interface StatItem { k: { de: string; en: string }; v: string; tint: string }
export const MILESTONES: Milestone[] = [
{
id: 'ihk', when: 'Okt. 2025', tick: '10 \u00b7 25',
title: { de: 'Gr\u00fcnderzuschuss & IHK', en: 'Founder Grant & IHK' },
short: { de: 'Abstimmung mit Agentur f\u00fcr Arbeit und IHK Konstanz.', en: 'Coordination with Employment Agency and IHK Konstanz.' },
body: {
de: 'Seit Oktober 2025 Gr\u00fcnderzuschussantrag in Abstimmung mit der Agentur f\u00fcr Arbeit und der IHK Konstanz. Grundlage f\u00fcr die Unternehmensgr\u00fcndung.',
en: 'Since October 2025, founder grant application in coordination with the Employment Agency and IHK Konstanz. Foundation for company formation.',
},
bullets: {
de: ['Gr\u00fcnderzuschuss beantragt', 'Beratung IHK Konstanz', 'Businessplan finalisiert'],
en: ['Founder grant applied', 'IHK Konstanz advisory', 'Business plan finalized'],
},
tint: '#a78bfa', done: true,
},
{
id: 'brand', when: '11. Nov. 2025', tick: '11 \u00b7 25',
title: { de: 'Markenanmeldung & Domains', en: 'Trademark Filing & Domains' },
short: { de: 'DPMA-Anmeldung BreakPilot + Domain-Portfolio.', en: 'DPMA filing BreakPilot + domain portfolio.' },
body: {
de: 'Markenanmeldung BreakPilot beim DPMA am 11.11.2025. Domain-Kauf breakpilot.com, .de, .ai und brakepilot.com, .de, .ai am 21.11.2025.',
en: 'BreakPilot trademark filed with DPMA on 11.11.2025. Domain purchase breakpilot.com, .de, .ai and brakepilot.com, .de, .ai on 21.11.2025.',
},
bullets: {
de: ['DPMA-Markenanmeldung 11.11.2025', 'Domains .com .de .ai gesichert', 'Typo-Domains (.brakepilot) gesichert'],
en: ['DPMA trademark filed 11.11.2025', 'Domains .com .de .ai secured', 'Typo domains (.brakepilot) secured'],
},
tint: '#a78bfa', done: true,
},
{
id: 'dev', when: 'Jan. 2026', tick: '01 \u00b7 26',
title: { de: 'Plattform-Entwicklung gestartet', en: 'Platform Development Started' },
short: { de: '500.000+ Lines of Code, vollst\u00e4ndige Architektur.', en: '500,000+ lines of code, full architecture.' },
body: {
de: 'Start der Plattform-Entwicklung mit 500.000+ Lines of Code. Vollst\u00e4ndige Microservice-Architektur mit Go, Python und TypeScript.',
en: 'Platform development started with 500,000+ lines of code. Full microservice architecture with Go, Python and TypeScript.',
},
bullets: {
de: ['500K+ Lines of Code', 'Go + Python + TypeScript', 'Vollst\u00e4ndige Architektur'],
en: ['500K+ lines of code', 'Go + Python + TypeScript', 'Full architecture'],
},
tint: '#c084fc', done: true,
},
{
id: 'dpma', when: '27. M\u00e4r. 2026', tick: '03 \u00b7 26',
title: { de: 'Markeneintragung DPMA', en: 'DPMA Trademark Registration' },
short: { de: 'BreakPilot offiziell eingetragen.', en: 'BreakPilot officially registered.' },
body: {
de: 'Markeneintragung BreakPilot beim Deutschen Patent- und Markenamt (DPMA) am 27.03.2026.',
en: 'BreakPilot trademark registration at the German Patent and Trademark Office (DPMA) on 27.03.2026.',
},
bullets: {
de: ['DPMA-Eintragung 27.03.2026', 'Markenschutz Deutschland'],
en: ['DPMA registration 27.03.2026', 'Trademark protection Germany'],
},
tint: '#c084fc', done: true,
},
{
id: 'rag', when: 'Apr. 2026', tick: '04 \u00b7 26',
title: { de: 'RAG mit 375+ Dokumenten', en: 'RAG with 375+ Documents' },
short: { de: 'EU + DACH Regularien indexiert.', en: 'EU + DACH regulations indexed.' },
body: {
de: '375+ Gesetze, Verordnungen, Leitlinien und Urteile in die RAG-Pipeline ingestiert. 25.000+ Pr\u00fcfaspekte generiert.',
en: '375+ laws, regulations, guidelines and rulings ingested into the RAG pipeline. 25,000+ audit controls generated.',
},
bullets: {
de: ['375+ Dokumente im RAG', '25.000+ Pr\u00fcfaspekte', 'EU + DACH Abdeckung'],
en: ['375+ documents in RAG', '25,000+ audit controls', 'EU + DACH coverage'],
},
tint: '#c084fc', done: true,
},
{
id: 'euipo', when: '1. Mai 2026', tick: '05 \u00b7 26',
title: { de: 'Markenanmeldung EUIPO', en: 'EUIPO Trademark Filing' },
short: { de: 'EU-weiter Markenschutz beantragt.', en: 'EU-wide trademark protection filed.' },
body: {
de: 'Markenanmeldung BreakPilot beim EUIPO (Amt der Europ\u00e4ischen Union f\u00fcr geistiges Eigentum) am 01.05.2026 f\u00fcr EU-weiten Markenschutz.',
en: 'BreakPilot trademark filing with EUIPO (European Union Intellectual Property Office) on 01.05.2026 for EU-wide trademark protection.',
},
bullets: {
de: ['EUIPO-Anmeldung 01.05.2026', 'EU-weiter Markenschutz'],
en: ['EUIPO filing 01.05.2026', 'EU-wide trademark protection'],
},
tint: '#fbbf24', done: false, next: true,
},
{
id: 'gmbh', when: 'Aug. 2026', tick: '08 \u00b7 26',
title: { de: 'GmbH-Gr\u00fcndung', en: 'GmbH Incorporation' },
short: { de: 'Breakpilot COMPLAI GmbH gegr\u00fcndet.', en: 'Breakpilot COMPLAI GmbH incorporated.' },
body: {
de: 'Gr\u00fcndung der Breakpilot COMPLAI GmbH im August 2026. Notartermin, Handelsregistereintrag, operative Aufnahme.',
en: 'Incorporation of Breakpilot COMPLAI GmbH in August 2026. Notary appointment, commercial register entry, start of operations.',
},
bullets: {
de: ['GmbH-Gr\u00fcndung August 2026', 'Handelsregistereintrag', 'Operativer Start'],
en: ['GmbH incorporation August 2026', 'Commercial register entry', 'Start of operations'],
},
tint: '#fbbf24', done: false,
},
{
id: 'customers', when: 'Aug. 2026', tick: '08 \u00b7 26',
title: { de: '2 zahlende Kunden', en: '2 Paying Customers' },
short: { de: 'Erste Ums\u00e4tze ab Gr\u00fcndung.', en: 'First revenue from incorporation.' },
body: {
de: 'Zwei zahlende Kunden ab August 2026 \u2014 Validierung des Produkts im Maschinenbau-Umfeld mit echten Compliance-Anforderungen.',
en: 'Two paying customers from August 2026 \u2014 product validation in manufacturing with real compliance requirements.',
},
bullets: {
de: ['2 zahlende Kunden', 'Maschinenbau-Validierung', 'Erste Ums\u00e4tze'],
en: ['2 paying customers', 'Manufacturing validation', 'First revenue'],
},
tint: '#fbbf24', done: false,
},
{
id: 'beta', when: 'Q3 2026', tick: 'Q3 \u00b7 26',
title: { de: '\u00d6ffentliches Beta', en: 'Public Beta' },
short: { de: 'Beta-Launch mit ersten zahlenden Kunden.', en: 'Beta launch with first paying customers.' },
body: {
de: '\u00d6ffentliches Beta-Release der Plattform. Erste zahlende Kunden aus dem Pilotprogramm gehen live.',
en: 'Public beta release of the platform. First paying customers from the pilot program go live.',
},
bullets: {
de: ['Public Beta verf\u00fcgbar', 'Onboarding-Prozess live', 'Feedback-Loop etabliert'],
en: ['Public beta available', 'Onboarding process live', 'Feedback loop established'],
},
tint: '#f59e0b', done: false,
},
]
export const STATS: StatItem[] = [
{ k: { de: 'Gesetze & Dokumente im RAG', en: 'Laws & Docs in RAG' }, v: '385', tint: '#a78bfa' },
{ k: { de: 'Atomare Controls', en: 'Atomic Controls' }, v: '25.000+', tint: '#c084fc' },
{ k: { de: 'Compliance-Module', en: 'Compliance Modules' }, v: '12', tint: '#fbbf24' },
{ k: { de: 'Pilotkunden', en: 'Pilot Customers' }, v: '2', tint: '#f59e0b' },
{ k: { de: 'Lines of Code', en: 'Lines of Code' }, v: '500.000+', tint: '#8b5cf6' },
]

View File

@@ -0,0 +1,362 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { type Milestone, type StatItem, MILESTONES, TODAY_POSITION } from './MilestonesSlide.data'
import { type Theme } from './MilestonesSlide.themes'
const MONO: React.CSSProperties = {
fontFamily: '"JetBrains Mono","SF Mono",ui-monospace,monospace',
fontVariantNumeric: 'tabular-nums',
}
// ── Star Field ────────────────────────────────────────────────────────────────
export function StarField() {
const stars = useMemo(() => {
let s = 77
const r = () => { s = (s * 9301 + 49297) % 233280; return s / 233280 }
return Array.from({ length: 95 }, () => ({ x: r() * 100, y: r() * 100, size: r() * 1.4 + 0.3, op: r() * 0.5 + 0.15 }))
}, [])
return (
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
{stars.map((st, i) => (
<div key={i} style={{
position: 'absolute', left: `${st.x}%`, top: `${st.y}%`,
width: st.size, height: st.size, borderRadius: '50%',
background: '#fff', opacity: st.op,
boxShadow: `0 0 ${st.size * 3}px rgba(180,160,255,.7)`,
}} />
))}
</div>
)
}
export function SoftGrid({ t }: { t: Theme }) {
return (
<div style={{
position: 'absolute', inset: 0, pointerEvents: 'none',
backgroundImage: `radial-gradient(${t.accent20} 1px, transparent 1px)`,
backgroundSize: '28px 28px',
maskImage: 'radial-gradient(ellipse at center, #000 40%, transparent 85%)',
WebkitMaskImage: 'radial-gradient(ellipse at center, #000 40%, transparent 85%)',
opacity: 0.8,
}} />
)
}
// ── Timeline ──────────────────────────────────────────────────────────────────
interface MilestoneWithPos extends Milestone { x: number; row: 'top' | 'bottom' }
export function Timeline({ onSelect, selectedId, t, de }: {
onSelect: (m: Milestone) => void
selectedId: string | null
t: Theme
de: boolean
}) {
const trackW = 1160
const innerPad = 120
const usableW = trackW - innerPad * 2
const positions = MILESTONES.map((_, i) => innerPad + (usableW * i) / (MILESTONES.length - 1))
const todayX = innerPad + usableW * TODAY_POSITION
const layout: MilestoneWithPos[] = MILESTONES.map((m, i) => ({
...m, x: positions[i],
row: i % 2 === 0 ? 'top' : 'bottom',
}))
const railColor = t.key === 'dark' ? '#a78bfa' : '#7c3aed'
return (
<div style={{ position: 'relative', width: trackW, height: 360, margin: '0 auto' }}>
<svg viewBox={`0 0 ${trackW} 360`} preserveAspectRatio="none"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}>
<defs>
<linearGradient id="msTrackBg" x1="0" x2="1">
<stop offset="0" stopColor={railColor} stopOpacity={t.key === 'dark' ? .18 : .28} />
<stop offset=".5" stopColor={railColor} stopOpacity={t.key === 'dark' ? .28 : .38} />
<stop offset="1" stopColor={railColor} stopOpacity={t.key === 'dark' ? .18 : .28} />
</linearGradient>
<filter id="msGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
{/* rail background */}
<line x1={innerPad} y1={180} x2={trackW - innerPad} y2={180}
stroke="url(#msTrackBg)" strokeWidth="2.5" />
{/* past progress */}
<line x1={innerPad} y1={180} x2={todayX} y2={180}
stroke={t.done} strokeWidth="3" opacity={t.key === 'dark' ? .85 : .9} />
{/* future dashed */}
<line x1={todayX} y1={180} x2={trackW - innerPad} y2={180}
stroke="#f59e0b" strokeWidth="1.75" strokeDasharray="4 5"
opacity={t.key === 'dark' ? .6 : .75}
style={{ animation: 'msFlow 1.8s linear infinite' }} />
{/* connector stubs */}
{layout.map((m) => (
<line key={m.id}
x1={m.x} y1={180}
x2={m.x} y2={m.row === 'top' ? 154 : 200}
stroke={m.done ? t.done : m.tint}
strokeOpacity={t.key === 'dark' ? (m.done ? .6 : .55) : (m.done ? .7 : .65)}
strokeWidth="1"
strokeDasharray={m.done ? '0' : '3 3'} />
))}
{/* HEUTE marker -- circles only; pill is HTML below */}
<g transform={`translate(${todayX} 180)`}>
<circle r="14" fill={t.accent} opacity=".15" />
<circle r="9" fill={t.accent} opacity=".4">
<animate attributeName="r" values="9;14;9" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" values=".4;.05;.4" dur="2s" repeatCount="indefinite" />
</circle>
<circle r="6" fill={t.heuteCore} stroke={t.accent} strokeWidth="2" filter="url(#msGlow)" />
</g>
</svg>
{/* HEUTE pill -- HTML so it sits above milestone cards */}
<div style={{
position: 'absolute',
left: todayX - 30, top: 146,
width: 60, height: 18,
borderRadius: 9,
background: t.heutePillBg,
border: `1px solid ${t.accent}99`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10, pointerEvents: 'none',
...MONO, fontSize: 9.5, letterSpacing: 2.5, fontWeight: 700,
color: t.heuteText,
}}>HEUTE</div>
{layout.map((m) => (
<MilestoneNode key={m.id} m={m} t={t} de={de}
onClick={() => onSelect(m)}
active={selectedId === m.id} />
))}
</div>
)
}
function MilestoneNode({ m, onClick, active, t, de }: {
m: MilestoneWithPos; onClick: () => void; active: boolean; t: Theme; de: boolean
}) {
const [hover, setHover] = useState(false)
const lit = hover || active
const isTop = m.row === 'top'
const cardY = isTop ? 4 : 200
const nodeColor = m.done ? t.done : m.tint
const bgTopA = lit ? m.tint + t.cardTintTopH : m.tint + t.cardTintTop
const bgMidA = lit ? m.tint + t.cardTintMidH : m.tint + t.cardTintMid
const cardBg = `linear-gradient(180deg, ${bgTopA} 0%, ${bgMidA} 55%, ${t.cardBase}${lit ? t.cardBaseAH : t.cardBaseA})`
const badge = m.done ? (de ? 'erledigt' : 'done') : (m.next ? (de ? 'als nächstes' : 'next') : (de ? 'geplant' : 'plan'))
return (
<>
{/* dot */}
<div
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
position: 'absolute', left: m.x - 14, top: 180 - 14,
width: 28, height: 28, borderRadius: '50%',
background: m.done
? `radial-gradient(circle at 35% 30%, ${t.doneBright}, ${t.doneSolid} 60%, ${t.doneDeep})`
: `radial-gradient(circle at 35% 30%, ${m.tint}dd, ${m.tint}66 60%, ${t.dotTodoDeep})`,
border: `2px solid ${lit ? '#fff' : nodeColor}`,
boxShadow: lit
? `0 0 22px ${nodeColor}, 0 0 44px ${nodeColor}66, inset 0 1px 0 ${t.dotLitHi}`
: `0 0 10px ${nodeColor}88, inset 0 1px 0 ${t.dotSoftHi}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 11, fontWeight: 700,
cursor: 'pointer', zIndex: 5,
transition: 'all .25s',
transform: lit ? 'scale(1.15)' : 'scale(1)',
}}>
{m.done ? '✓' : (m.next ? '◉' : '○')}
</div>
{/* card */}
<div
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
position: 'absolute', left: m.x - 112, top: cardY,
width: 224, height: 150, padding: '12px 14px',
borderRadius: 12,
background: cardBg,
border: `1px solid ${lit ? m.tint : m.tint + '55'}`,
boxShadow: lit ? t.cardShadowLift(m.tint) : t.cardShadowSoft,
cursor: 'pointer', zIndex: 4,
transition: 'all .25s',
transform: lit ? `translateY(${isTop ? -2 : 2}px)` : 'translateY(0)',
display: 'flex', flexDirection: 'column', gap: 6,
backdropFilter: t.key === 'light' ? 'blur(6px)' : 'none',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
...MONO, fontSize: 10, letterSpacing: 1.5, fontWeight: 700,
color: m.done ? t.done : m.tint, textTransform: 'uppercase' as const,
}}>{m.tick}</span>
<span style={{ flex: 1, height: 1, background: `${m.tint}44` }} />
<span style={{
...MONO, fontSize: 9, letterSpacing: 2, fontWeight: 700,
color: m.done ? t.done : m.tint, textTransform: 'uppercase' as const, opacity: .85,
}}>{badge}</span>
</div>
<div style={{ fontSize: 13, fontWeight: 700, color: t.fg, letterSpacing: -0.2, lineHeight: 1.25 }}>
{de ? m.title.de : m.title.en}
</div>
<div style={{ fontSize: 10.5, lineHeight: 1.45, color: lit ? t.fgSoft : t.fgMuted, transition: 'color .25s' }}>
{de ? m.short.de : m.short.en}
</div>
<div style={{
marginTop: 'auto',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
paddingTop: 6, borderTop: `1px dashed ${m.tint}44`,
}}>
<span style={{ fontSize: 10, color: t.fgFaint }}>{m.when}</span>
<span style={{
fontSize: 10, color: m.tint, fontWeight: 700,
opacity: lit ? 1 : 0.55,
transform: `translateX(${lit ? 0 : -4}px)`,
transition: 'all .25s',
}}>{de ? 'Details →' : 'Details →'}</span>
</div>
</div>
</>
)
}
// ── Stat Card ─────────────────────────────────────────────────────────────────
export function StatCard({ item, t, de }: { item: StatItem; t: Theme; de: boolean }) {
const [hover, setHover] = useState(false)
const bgTop = hover ? item.tint + t.statTintTopH : item.tint + t.statTintTop
const bgMid = item.tint + t.statTintMid
return (
<div
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
position: 'relative', padding: '14px 18px', borderRadius: 12,
background: `linear-gradient(180deg, ${bgTop} 0%, ${bgMid} 60%, ${t.cardBase}${t.cardBaseA})`,
border: `1px solid ${hover ? item.tint : item.tint + '55'}`,
boxShadow: hover ? t.statShadowLift(item.tint) : t.statShadowSoft,
transform: hover ? 'translateY(-3px)' : 'translateY(0)',
transition: 'all .25s',
overflow: 'hidden',
backdropFilter: t.key === 'light' ? 'blur(6px)' : 'none',
}}>
<div style={{
position: 'absolute', right: 10, top: 10, width: 6, height: 6,
borderRadius: '50%', background: item.tint, opacity: .9,
boxShadow: `0 0 10px ${item.tint}`,
}} />
<div style={{ ...MONO, fontSize: 9.5, letterSpacing: 2, color: item.tint, textTransform: 'uppercase' as const, fontWeight: 700, marginBottom: 6 }}>
{de ? item.k.de : item.k.en}
</div>
<div style={{ fontSize: 32, fontWeight: 700, color: t.fg, letterSpacing: -0.8, lineHeight: 1 }}>
{item.v}
</div>
<svg viewBox="0 0 100 16" preserveAspectRatio="none"
style={{ width: '100%', height: 14, marginTop: 8, opacity: hover ? 1 : t.sparkOp, transition: 'opacity .25s' }}>
<defs>
<linearGradient id={`spark-${item.tint.replace('#', '')}`} x1="0" x2="1">
<stop offset="0" stopColor={item.tint} stopOpacity="0" />
<stop offset=".5" stopColor={item.tint} stopOpacity=".9" />
<stop offset="1" stopColor={item.tint} stopOpacity="0" />
</linearGradient>
</defs>
<path d="M 0 10 L 15 8 L 30 11 L 48 6 L 62 9 L 78 4 L 100 2"
stroke={`url(#spark-${item.tint.replace('#', '')})`} strokeWidth="1.5" fill="none" />
</svg>
</div>
)
}
// ── Detail modal ──────────────────────────────────────────────────────────────
export function DetailModal({ item, onClose, t, de }: {
item: Milestone | null; onClose: () => void; t: Theme; de: boolean
}) {
useEffect(() => {
if (!item) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [item, onClose])
if (!item) return null
const tint = item.tint
const badge = item.done
? (de ? 'ABGESCHLOSSEN' : 'COMPLETED')
: (item.next ? (de ? 'ALS NÄCHSTES' : 'NEXT UP') : (de ? 'GEPLANT' : 'PLANNED'))
const badgeColor = item.done ? t.done : tint
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 50,
background: t.modalScrim, backdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'msFadeIn .2s ease-out',
}}>
<div onClick={e => e.stopPropagation()} style={{
width: 580, maxWidth: '88%',
background: `linear-gradient(180deg, ${tint}22 0%, ${t.modalBgMid} 50%, ${t.modalBgLow} 100%)`,
border: `1px solid ${tint}77`,
borderRadius: 16,
boxShadow: t.modalShadow(tint),
padding: '24px 28px', color: t.fg,
animation: 'msScaleIn .22s ease-out',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div style={{
width: 42, height: 42, borderRadius: 11,
background: `linear-gradient(135deg, ${tint}66, ${tint}22)`,
border: `1px solid ${tint}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: t.key === 'light' ? tint : '#fff', fontSize: 17, fontWeight: 700,
boxShadow: `0 0 20px ${tint}66`,
}}>{item.done ? '✓' : (item.next ? '◉' : '○')}</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<span style={{
...MONO, fontSize: 9.5, letterSpacing: 2.5, color: badgeColor,
textTransform: 'uppercase' as const, fontWeight: 700,
padding: '2px 8px', borderRadius: 4,
background: `${badgeColor}22`, border: `1px solid ${badgeColor}66`,
}}>{badge}</span>
<span style={{ ...MONO, fontSize: 10, color: t.fgFaint }}>{item.when}</span>
</div>
<div style={{ fontSize: 20, fontWeight: 700, color: t.fg, letterSpacing: -0.3 }}>
{de ? item.title.de : item.title.en}
</div>
</div>
<button onClick={onClose} style={{
background: 'transparent', border: `1px solid ${tint}66`, color: t.fg,
width: 32, height: 32, borderRadius: 8, cursor: 'pointer', fontSize: 14,
}}></button>
</div>
<div style={{ fontSize: 13, lineHeight: 1.6, color: t.fgSoft, marginBottom: 16 }}>
{de ? item.body.de : item.body.en}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{(de ? item.bullets.de : item.bullets.en).map((b, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'flex-start', gap: 10,
padding: '9px 13px', borderRadius: 8,
background: t.bulletBg, border: `1px solid ${tint}44`,
}}>
<span style={{ color: item.done ? t.done : tint, fontSize: 12, marginTop: 1 }}>
{item.done ? '✓' : '▸'}
</span>
<span style={{ fontSize: 12, lineHeight: 1.5, color: t.fgSoft }}>{b}</span>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,106 @@
// MilestonesSlide themes — extracted from MilestonesSlide.tsx
export const THEMES = {
dark: {
key: 'dark' as const,
bg: 'radial-gradient(ellipse at 50% 25%, #1a0f34 0%, #0e0720 55%, #050210 100%)',
ambient: 'radial-gradient(ellipse, rgba(167,139,250,.18), transparent 65%)',
stars: true,
fg: '#f7f5fc',
fgSoft: 'rgba(236,233,247,.82)',
fgMid: 'rgba(236,233,247,.72)',
fgMuted: 'rgba(236,233,247,.62)',
fgFaint: 'rgba(236,233,247,.55)',
fgGhost: 'rgba(236,233,247,.45)',
fgWhisper: 'rgba(236,233,247,.4)',
accent: '#a78bfa',
accent80: 'rgba(167,139,250,.8)',
accent70: 'rgba(167,139,250,.7)',
accent50: 'rgba(167,139,250,.5)',
accent40: 'rgba(167,139,250,.4)',
accent20: 'rgba(167,139,250,.2)',
headingGrad: 'linear-gradient(90deg, #e9e2ff, #a78bfa 50%, #e9e2ff)',
headingAnim: 'msHeadingDark 4s ease-in-out infinite',
heuteText: '#e4d4ff',
heutePillBg: 'rgba(14,8,28,.95)',
heuteCore: '#f0e9ff',
done: '#4ade80',
doneBright: '#86efac',
doneDeep: '#166534',
doneSolid: '#22c55e',
cardBase: 'rgba(14,8,28,',
cardBaseA: '.9',
cardBaseAH: '.95',
cardTintTop: '18', cardTintTopH: '2e',
cardTintMid: '08', cardTintMidH: '14',
cardShadowSoft: '0 10px 24px rgba(0,0,0,.45)',
cardShadowLift: (t: string) => `0 20px 44px ${t}33, 0 0 0 1px ${t}66, inset 0 1px 0 ${t}66`,
statTintTop: '18', statTintTopH: '2a',
statTintMid: '06',
statShadowSoft: '0 10px 24px rgba(0,0,0,.45)',
statShadowLift: (t: string) => `0 18px 40px ${t}33, 0 0 0 1px ${t}55, inset 0 1px 0 ${t}55`,
modalScrim: 'rgba(5,2,16,.75)',
modalBgMid: 'rgba(20,10,40,.97)',
modalBgLow: 'rgba(14,8,28,.98)',
modalShadow: (t: string) => `0 30px 80px rgba(0,0,0,.65), 0 0 60px ${t}33, inset 0 1px 0 ${t}55`,
bulletBg: 'rgba(0,0,0,.3)',
progressTrackBg: 'rgba(255,255,255,.08)',
progressTrackBorder: 'rgba(167,139,250,.2)',
dotTodoDeep: '#1a0f34',
dotLitHi: 'rgba(255,255,255,.5)',
dotSoftHi: 'rgba(255,255,255,.3)',
sparkOp: 0.45,
},
light: {
key: 'light' as const,
bg: 'radial-gradient(ellipse at 50% 12%, #ffffff 0%, #f5efff 55%, #ebdfff 100%)',
ambient: 'radial-gradient(ellipse, rgba(124,58,237,.14), transparent 65%)',
stars: false,
fg: '#1a0f34',
fgSoft: 'rgba(26,15,52,.85)',
fgMid: 'rgba(26,15,52,.72)',
fgMuted: 'rgba(26,15,52,.62)',
fgFaint: 'rgba(26,15,52,.50)',
fgGhost: 'rgba(26,15,52,.40)',
fgWhisper: 'rgba(26,15,52,.32)',
accent: '#7c3aed',
accent80: 'rgba(124,58,237,.8)',
accent70: 'rgba(124,58,237,.75)',
accent50: 'rgba(124,58,237,.55)',
accent40: 'rgba(124,58,237,.4)',
accent20: 'rgba(124,58,237,.18)',
headingGrad: 'linear-gradient(90deg, #3b0e7a, #7c3aed 50%, #3b0e7a)',
headingAnim: 'msHeadingLight 4s ease-in-out infinite',
heuteText: '#4c1d95',
heutePillBg: 'rgba(255,255,255,.98)',
heuteCore: '#7c3aed',
done: '#16a34a',
doneBright: '#4ade80',
doneDeep: '#14532d',
doneSolid: '#22c55e',
cardBase: 'rgba(255,255,255,',
cardBaseA: '.92',
cardBaseAH: '.98',
cardTintTop: '22', cardTintTopH: '3a',
cardTintMid: '10', cardTintMidH: '1c',
cardShadowSoft: '0 10px 24px rgba(59,26,122,.10), 0 2px 6px rgba(59,26,122,.06)',
cardShadowLift: (t: string) => `0 20px 44px ${t}38, 0 0 0 1px ${t}77, inset 0 1px 0 rgba(255,255,255,.9)`,
statTintTop: '1e', statTintTopH: '34',
statTintMid: '08',
statShadowSoft: '0 10px 24px rgba(59,26,122,.10), 0 2px 6px rgba(59,26,122,.06)',
statShadowLift: (t: string) => `0 18px 40px ${t}38, 0 0 0 1px ${t}77, inset 0 1px 0 rgba(255,255,255,.9)`,
modalScrim: 'rgba(40,20,80,.28)',
modalBgMid: 'rgba(255,255,255,.98)',
modalBgLow: 'rgba(250,247,255,.98)',
modalShadow: (t: string) => `0 30px 80px rgba(59,26,122,.25), 0 0 60px ${t}33, inset 0 1px 0 rgba(255,255,255,.9)`,
bulletBg: 'rgba(124,58,237,.06)',
progressTrackBg: 'rgba(124,58,237,.12)',
progressTrackBorder: 'rgba(124,58,237,.25)',
dotTodoDeep: '#faf5ff',
dotLitHi: 'rgba(255,255,255,.85)',
dotSoftHi: 'rgba(255,255,255,.55)',
sparkOp: 0.55,
},
}
export type Theme = typeof THEMES.dark

View File

@@ -1,10 +1,14 @@
'use client'
import { useState, useEffect, useRef, useMemo, useCallback, Fragment } from 'react'
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import { Language } from '@/lib/types'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import { type Milestone, MILESTONES, STATS } from './MilestonesSlide.data'
import { THEMES } from './MilestonesSlide.themes'
import { StarField, SoftGrid, Timeline, StatCard, DetailModal } from './MilestonesSlide.parts'
interface MilestonesSlideProps { lang: Language }
const MONO: React.CSSProperties = {
@@ -43,631 +47,9 @@ function useIsLight() {
return isLight
}
// ── Themes ────────────────────────────────────────────────────────────────────
const THEMES = {
dark: {
key: 'dark' as const,
bg: 'radial-gradient(ellipse at 50% 25%, #1a0f34 0%, #0e0720 55%, #050210 100%)',
ambient: 'radial-gradient(ellipse, rgba(167,139,250,.18), transparent 65%)',
stars: true,
fg: '#f7f5fc',
fgSoft: 'rgba(236,233,247,.82)',
fgMid: 'rgba(236,233,247,.72)',
fgMuted: 'rgba(236,233,247,.62)',
fgFaint: 'rgba(236,233,247,.55)',
fgGhost: 'rgba(236,233,247,.45)',
fgWhisper: 'rgba(236,233,247,.4)',
accent: '#a78bfa',
accent80: 'rgba(167,139,250,.8)',
accent70: 'rgba(167,139,250,.7)',
accent50: 'rgba(167,139,250,.5)',
accent40: 'rgba(167,139,250,.4)',
accent20: 'rgba(167,139,250,.2)',
headingGrad: 'linear-gradient(90deg, #e9e2ff, #a78bfa 50%, #e9e2ff)',
headingAnim: 'msHeadingDark 4s ease-in-out infinite',
heuteText: '#e4d4ff',
heutePillBg: 'rgba(14,8,28,.95)',
heuteCore: '#f0e9ff',
done: '#4ade80',
doneBright: '#86efac',
doneDeep: '#166534',
doneSolid: '#22c55e',
cardBase: 'rgba(14,8,28,',
cardBaseA: '.9',
cardBaseAH: '.95',
cardTintTop: '18', cardTintTopH: '2e',
cardTintMid: '08', cardTintMidH: '14',
cardShadowSoft: '0 10px 24px rgba(0,0,0,.45)',
cardShadowLift: (t: string) => `0 20px 44px ${t}33, 0 0 0 1px ${t}66, inset 0 1px 0 ${t}66`,
statTintTop: '18', statTintTopH: '2a',
statTintMid: '06',
statShadowSoft: '0 10px 24px rgba(0,0,0,.45)',
statShadowLift: (t: string) => `0 18px 40px ${t}33, 0 0 0 1px ${t}55, inset 0 1px 0 ${t}55`,
modalScrim: 'rgba(5,2,16,.75)',
modalBgMid: 'rgba(20,10,40,.97)',
modalBgLow: 'rgba(14,8,28,.98)',
modalShadow: (t: string) => `0 30px 80px rgba(0,0,0,.65), 0 0 60px ${t}33, inset 0 1px 0 ${t}55`,
bulletBg: 'rgba(0,0,0,.3)',
progressTrackBg: 'rgba(255,255,255,.08)',
progressTrackBorder: 'rgba(167,139,250,.2)',
dotTodoDeep: '#1a0f34',
dotLitHi: 'rgba(255,255,255,.5)',
dotSoftHi: 'rgba(255,255,255,.3)',
sparkOp: 0.45,
},
light: {
key: 'light' as const,
bg: 'radial-gradient(ellipse at 50% 12%, #ffffff 0%, #f5efff 55%, #ebdfff 100%)',
ambient: 'radial-gradient(ellipse, rgba(124,58,237,.14), transparent 65%)',
stars: false,
fg: '#1a0f34',
fgSoft: 'rgba(26,15,52,.85)',
fgMid: 'rgba(26,15,52,.72)',
fgMuted: 'rgba(26,15,52,.62)',
fgFaint: 'rgba(26,15,52,.50)',
fgGhost: 'rgba(26,15,52,.40)',
fgWhisper: 'rgba(26,15,52,.32)',
accent: '#7c3aed',
accent80: 'rgba(124,58,237,.8)',
accent70: 'rgba(124,58,237,.75)',
accent50: 'rgba(124,58,237,.55)',
accent40: 'rgba(124,58,237,.4)',
accent20: 'rgba(124,58,237,.18)',
headingGrad: 'linear-gradient(90deg, #3b0e7a, #7c3aed 50%, #3b0e7a)',
headingAnim: 'msHeadingLight 4s ease-in-out infinite',
heuteText: '#4c1d95',
heutePillBg: 'rgba(255,255,255,.98)',
heuteCore: '#7c3aed',
done: '#16a34a',
doneBright: '#4ade80',
doneDeep: '#14532d',
doneSolid: '#22c55e',
cardBase: 'rgba(255,255,255,',
cardBaseA: '.92',
cardBaseAH: '.98',
cardTintTop: '22', cardTintTopH: '3a',
cardTintMid: '10', cardTintMidH: '1c',
cardShadowSoft: '0 10px 24px rgba(59,26,122,.10), 0 2px 6px rgba(59,26,122,.06)',
cardShadowLift: (t: string) => `0 20px 44px ${t}38, 0 0 0 1px ${t}77, inset 0 1px 0 rgba(255,255,255,.9)`,
statTintTop: '1e', statTintTopH: '34',
statTintMid: '08',
statShadowSoft: '0 10px 24px rgba(59,26,122,.10), 0 2px 6px rgba(59,26,122,.06)',
statShadowLift: (t: string) => `0 18px 40px ${t}38, 0 0 0 1px ${t}77, inset 0 1px 0 rgba(255,255,255,.9)`,
modalScrim: 'rgba(40,20,80,.28)',
modalBgMid: 'rgba(255,255,255,.98)',
modalBgLow: 'rgba(250,247,255,.98)',
modalShadow: (t: string) => `0 30px 80px rgba(59,26,122,.25), 0 0 60px ${t}33, inset 0 1px 0 rgba(255,255,255,.9)`,
bulletBg: 'rgba(124,58,237,.06)',
progressTrackBg: 'rgba(124,58,237,.12)',
progressTrackBorder: 'rgba(124,58,237,.25)',
dotTodoDeep: '#faf5ff',
dotLitHi: 'rgba(255,255,255,.85)',
dotSoftHi: 'rgba(255,255,255,.55)',
sparkOp: 0.55,
},
}
type Theme = typeof THEMES.dark
// ── Data ──────────────────────────────────────────────────────────────────────
const TODAY_POSITION = 0.56
interface Milestone {
id: string
when: string
tick: string
title: { de: string; en: string }
short: { de: string; en: string }
body: { de: string; en: string }
bullets: { de: string[]; en: string[] }
tint: string
done: boolean
next?: boolean
}
const MILESTONES: Milestone[] = [
{
id: 'ihk',
when: 'Okt. 2025', tick: '10 · 25',
title: { de: 'Gründerzuschuss & IHK', en: 'Founder Grant & IHK' },
short: { de: 'Abstimmung mit Agentur für Arbeit und IHK Konstanz.', en: 'Coordination with Employment Agency and IHK Konstanz.' },
body: {
de: 'Seit Oktober 2025 Gründerzuschussantrag in Abstimmung mit der Agentur für Arbeit und der IHK Konstanz. Grundlage für die Unternehmensgründung.',
en: 'Since October 2025, founder grant application in coordination with the Employment Agency and IHK Konstanz. Foundation for company formation.',
},
bullets: {
de: ['Gründerzuschuss beantragt', 'Beratung IHK Konstanz', 'Businessplan finalisiert'],
en: ['Founder grant applied', 'IHK Konstanz advisory', 'Business plan finalized'],
},
tint: '#a78bfa', done: true,
},
{
id: 'brand',
when: '11. Nov. 2025', tick: '11 · 25',
title: { de: 'Markenanmeldung & Domains', en: 'Trademark Filing & Domains' },
short: { de: 'DPMA-Anmeldung BreakPilot + Domain-Portfolio.', en: 'DPMA filing BreakPilot + domain portfolio.' },
body: {
de: 'Markenanmeldung BreakPilot beim DPMA am 11.11.2025. Domain-Kauf breakpilot.com, .de, .ai und brakepilot.com, .de, .ai am 21.11.2025.',
en: 'BreakPilot trademark filed with DPMA on 11.11.2025. Domain purchase breakpilot.com, .de, .ai and brakepilot.com, .de, .ai on 21.11.2025.',
},
bullets: {
de: ['DPMA-Markenanmeldung 11.11.2025', 'Domains .com .de .ai gesichert', 'Typo-Domains (.brakepilot) gesichert'],
en: ['DPMA trademark filed 11.11.2025', 'Domains .com .de .ai secured', 'Typo domains (.brakepilot) secured'],
},
tint: '#a78bfa', done: true,
},
{
id: 'dev',
when: 'Jan. 2026', tick: '01 · 26',
title: { de: 'Plattform-Entwicklung gestartet', en: 'Platform Development Started' },
short: { de: '500.000+ Lines of Code, vollständige Architektur.', en: '500,000+ lines of code, full architecture.' },
body: {
de: 'Start der Plattform-Entwicklung mit 500.000+ Lines of Code. Vollständige Microservice-Architektur mit Go, Python und TypeScript.',
en: 'Platform development started with 500,000+ lines of code. Full microservice architecture with Go, Python and TypeScript.',
},
bullets: {
de: ['500K+ Lines of Code', 'Go + Python + TypeScript', 'Vollständige Architektur'],
en: ['500K+ lines of code', 'Go + Python + TypeScript', 'Full architecture'],
},
tint: '#c084fc', done: true,
},
{
id: 'dpma',
when: '27. Mär. 2026', tick: '03 · 26',
title: { de: 'Markeneintragung DPMA', en: 'DPMA Trademark Registration' },
short: { de: 'BreakPilot offiziell eingetragen.', en: 'BreakPilot officially registered.' },
body: {
de: 'Markeneintragung BreakPilot beim Deutschen Patent- und Markenamt (DPMA) am 27.03.2026.',
en: 'BreakPilot trademark registration at the German Patent and Trademark Office (DPMA) on 27.03.2026.',
},
bullets: {
de: ['DPMA-Eintragung 27.03.2026', 'Markenschutz Deutschland'],
en: ['DPMA registration 27.03.2026', 'Trademark protection Germany'],
},
tint: '#c084fc', done: true,
},
{
id: 'rag',
when: 'Apr. 2026', tick: '04 · 26',
title: { de: 'RAG mit 375+ Dokumenten', en: 'RAG with 375+ Documents' },
short: { de: 'EU + DACH Regularien indexiert.', en: 'EU + DACH regulations indexed.' },
body: {
de: '375+ Gesetze, Verordnungen, Leitlinien und Urteile in die RAG-Pipeline ingestiert. 25.000+ Prüfaspekte generiert.',
en: '375+ laws, regulations, guidelines and rulings ingested into the RAG pipeline. 25,000+ audit controls generated.',
},
bullets: {
de: ['375+ Dokumente im RAG', '25.000+ Prüfaspekte', 'EU + DACH Abdeckung'],
en: ['375+ documents in RAG', '25,000+ audit controls', 'EU + DACH coverage'],
},
tint: '#c084fc', done: true,
},
{
id: 'euipo',
when: '1. Mai 2026', tick: '05 · 26',
title: { de: 'Markenanmeldung EUIPO', en: 'EUIPO Trademark Filing' },
short: { de: 'EU-weiter Markenschutz beantragt.', en: 'EU-wide trademark protection filed.' },
body: {
de: 'Markenanmeldung BreakPilot beim EUIPO (Amt der Europäischen Union für geistiges Eigentum) am 01.05.2026 für EU-weiten Markenschutz.',
en: 'BreakPilot trademark filing with EUIPO (European Union Intellectual Property Office) on 01.05.2026 for EU-wide trademark protection.',
},
bullets: {
de: ['EUIPO-Anmeldung 01.05.2026', 'EU-weiter Markenschutz'],
en: ['EUIPO filing 01.05.2026', 'EU-wide trademark protection'],
},
tint: '#fbbf24', done: false, next: true,
},
{
id: 'gmbh',
when: 'Aug. 2026', tick: '08 · 26',
title: { de: 'GmbH-Gründung', en: 'GmbH Incorporation' },
short: { de: 'Breakpilot COMPLAI GmbH gegründet.', en: 'Breakpilot COMPLAI GmbH incorporated.' },
body: {
de: 'Gründung der Breakpilot COMPLAI GmbH im August 2026. Notartermin, Handelsregistereintrag, operative Aufnahme.',
en: 'Incorporation of Breakpilot COMPLAI GmbH in August 2026. Notary appointment, commercial register entry, start of operations.',
},
bullets: {
de: ['GmbH-Gründung August 2026', 'Handelsregistereintrag', 'Operativer Start'],
en: ['GmbH incorporation August 2026', 'Commercial register entry', 'Start of operations'],
},
tint: '#fbbf24', done: false,
},
{
id: 'customers',
when: 'Aug. 2026', tick: '08 · 26',
title: { de: '2 zahlende Kunden', en: '2 Paying Customers' },
short: { de: 'Erste Umsätze ab Gründung.', en: 'First revenue from incorporation.' },
body: {
de: 'Zwei zahlende Kunden ab August 2026 — Validierung des Produkts im Maschinenbau-Umfeld mit echten Compliance-Anforderungen.',
en: 'Two paying customers from August 2026 — product validation in manufacturing with real compliance requirements.',
},
bullets: {
de: ['2 zahlende Kunden', 'Maschinenbau-Validierung', 'Erste Umsätze'],
en: ['2 paying customers', 'Manufacturing validation', 'First revenue'],
},
tint: '#fbbf24', done: false,
},
{
id: 'beta',
when: 'Q3 2026', tick: 'Q3 · 26',
title: { de: 'Öffentliches Beta', en: 'Public Beta' },
short: { de: 'Beta-Launch mit ersten zahlenden Kunden.', en: 'Beta launch with first paying customers.' },
body: {
de: 'Öffentliches Beta-Release der Plattform. Erste zahlende Kunden aus dem Pilotprogramm gehen live.',
en: 'Public beta release of the platform. First paying customers from the pilot program go live.',
},
bullets: {
de: ['Public Beta verfügbar', 'Onboarding-Prozess live', 'Feedback-Loop etabliert'],
en: ['Public beta available', 'Onboarding process live', 'Feedback loop established'],
},
tint: '#f59e0b', done: false,
},
]
interface StatItem { k: { de: string; en: string }; v: string; tint: string }
const STATS: StatItem[] = [
{ k: { de: 'Gesetze & Dokumente im RAG', en: 'Laws & Docs in RAG' }, v: '385', tint: '#a78bfa' },
{ k: { de: 'Atomare Controls', en: 'Atomic Controls' }, v: '25.000+', tint: '#c084fc' },
{ k: { de: 'Compliance-Module', en: 'Compliance Modules' }, v: '12', tint: '#fbbf24' },
{ k: { de: 'Pilotkunden', en: 'Pilot Customers' }, v: '2', tint: '#f59e0b' },
{ k: { de: 'Lines of Code', en: 'Lines of Code' }, v: '500.000+', tint: '#8b5cf6' },
]
// ── Star Field ────────────────────────────────────────────────────────────────
function StarField() {
const stars = useMemo(() => {
let s = 77
const r = () => { s = (s * 9301 + 49297) % 233280; return s / 233280 }
return Array.from({ length: 95 }, () => ({ x: r() * 100, y: r() * 100, size: r() * 1.4 + 0.3, op: r() * 0.5 + 0.15 }))
}, [])
return (
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
{stars.map((st, i) => (
<div key={i} style={{
position: 'absolute', left: `${st.x}%`, top: `${st.y}%`,
width: st.size, height: st.size, borderRadius: '50%',
background: '#fff', opacity: st.op,
boxShadow: `0 0 ${st.size * 3}px rgba(180,160,255,.7)`,
}} />
))}
</div>
)
}
function SoftGrid({ t }: { t: Theme }) {
return (
<div style={{
position: 'absolute', inset: 0, pointerEvents: 'none',
backgroundImage: `radial-gradient(${t.accent20} 1px, transparent 1px)`,
backgroundSize: '28px 28px',
maskImage: 'radial-gradient(ellipse at center, #000 40%, transparent 85%)',
WebkitMaskImage: 'radial-gradient(ellipse at center, #000 40%, transparent 85%)',
opacity: 0.8,
}} />
)
}
// ── Timeline ──────────────────────────────────────────────────────────────────
interface MilestoneWithPos extends Milestone { x: number; row: 'top' | 'bottom' }
function Timeline({ onSelect, selectedId, t, de }: {
onSelect: (m: Milestone) => void
selectedId: string | null
t: Theme
de: boolean
}) {
const trackW = 1160
const innerPad = 120
const usableW = trackW - innerPad * 2
const positions = MILESTONES.map((_, i) => innerPad + (usableW * i) / (MILESTONES.length - 1))
const todayX = innerPad + usableW * TODAY_POSITION
const layout: MilestoneWithPos[] = MILESTONES.map((m, i) => ({
...m, x: positions[i],
row: i % 2 === 0 ? 'top' : 'bottom',
}))
const railColor = t.key === 'dark' ? '#a78bfa' : '#7c3aed'
return (
<div style={{ position: 'relative', width: trackW, height: 360, margin: '0 auto' }}>
<svg viewBox={`0 0 ${trackW} 360`} preserveAspectRatio="none"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}>
<defs>
<linearGradient id="msTrackBg" x1="0" x2="1">
<stop offset="0" stopColor={railColor} stopOpacity={t.key === 'dark' ? .18 : .28} />
<stop offset=".5" stopColor={railColor} stopOpacity={t.key === 'dark' ? .28 : .38} />
<stop offset="1" stopColor={railColor} stopOpacity={t.key === 'dark' ? .18 : .28} />
</linearGradient>
<filter id="msGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
{/* rail background */}
<line x1={innerPad} y1={180} x2={trackW - innerPad} y2={180}
stroke="url(#msTrackBg)" strokeWidth="2.5" />
{/* past progress */}
<line x1={innerPad} y1={180} x2={todayX} y2={180}
stroke={t.done} strokeWidth="3" opacity={t.key === 'dark' ? .85 : .9} />
{/* future dashed */}
<line x1={todayX} y1={180} x2={trackW - innerPad} y2={180}
stroke="#f59e0b" strokeWidth="1.75" strokeDasharray="4 5"
opacity={t.key === 'dark' ? .6 : .75}
style={{ animation: 'msFlow 1.8s linear infinite' }} />
{/* connector stubs */}
{layout.map((m) => (
<line key={m.id}
x1={m.x} y1={180}
x2={m.x} y2={m.row === 'top' ? 154 : 200}
stroke={m.done ? t.done : m.tint}
strokeOpacity={t.key === 'dark' ? (m.done ? .6 : .55) : (m.done ? .7 : .65)}
strokeWidth="1"
strokeDasharray={m.done ? '0' : '3 3'} />
))}
{/* HEUTE marker — circles only; pill is HTML below */}
<g transform={`translate(${todayX} 180)`}>
<circle r="14" fill={t.accent} opacity=".15" />
<circle r="9" fill={t.accent} opacity=".4">
<animate attributeName="r" values="9;14;9" dur="2s" repeatCount="indefinite" />
<animate attributeName="opacity" values=".4;.05;.4" dur="2s" repeatCount="indefinite" />
</circle>
<circle r="6" fill={t.heuteCore} stroke={t.accent} strokeWidth="2" filter="url(#msGlow)" />
</g>
</svg>
{/* HEUTE pill — HTML so it sits above milestone cards */}
<div style={{
position: 'absolute',
left: todayX - 30, top: 146,
width: 60, height: 18,
borderRadius: 9,
background: t.heutePillBg,
border: `1px solid ${t.accent}99`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10, pointerEvents: 'none',
...MONO, fontSize: 9.5, letterSpacing: 2.5, fontWeight: 700,
color: t.heuteText,
}}>HEUTE</div>
{layout.map((m) => (
<MilestoneNode key={m.id} m={m} t={t} de={de}
onClick={() => onSelect(m)}
active={selectedId === m.id} />
))}
</div>
)
}
function MilestoneNode({ m, onClick, active, t, de }: {
m: MilestoneWithPos; onClick: () => void; active: boolean; t: Theme; de: boolean
}) {
const [hover, setHover] = useState(false)
const lit = hover || active
const isTop = m.row === 'top'
const cardY = isTop ? 4 : 200
const nodeColor = m.done ? t.done : m.tint
const bgTopA = lit ? m.tint + t.cardTintTopH : m.tint + t.cardTintTop
const bgMidA = lit ? m.tint + t.cardTintMidH : m.tint + t.cardTintMid
const cardBg = `linear-gradient(180deg, ${bgTopA} 0%, ${bgMidA} 55%, ${t.cardBase}${lit ? t.cardBaseAH : t.cardBaseA})`
const badge = m.done ? (de ? 'erledigt' : 'done') : (m.next ? (de ? 'als nächstes' : 'next') : (de ? 'geplant' : 'plan'))
return (
<>
{/* dot */}
<div
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
position: 'absolute', left: m.x - 14, top: 180 - 14,
width: 28, height: 28, borderRadius: '50%',
background: m.done
? `radial-gradient(circle at 35% 30%, ${t.doneBright}, ${t.doneSolid} 60%, ${t.doneDeep})`
: `radial-gradient(circle at 35% 30%, ${m.tint}dd, ${m.tint}66 60%, ${t.dotTodoDeep})`,
border: `2px solid ${lit ? '#fff' : nodeColor}`,
boxShadow: lit
? `0 0 22px ${nodeColor}, 0 0 44px ${nodeColor}66, inset 0 1px 0 ${t.dotLitHi}`
: `0 0 10px ${nodeColor}88, inset 0 1px 0 ${t.dotSoftHi}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 11, fontWeight: 700,
cursor: 'pointer', zIndex: 5,
transition: 'all .25s',
transform: lit ? 'scale(1.15)' : 'scale(1)',
}}>
{m.done ? '✓' : (m.next ? '◉' : '○')}
</div>
{/* card */}
<div
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
position: 'absolute', left: m.x - 112, top: cardY,
width: 224, height: 150, padding: '12px 14px',
borderRadius: 12,
background: cardBg,
border: `1px solid ${lit ? m.tint : m.tint + '55'}`,
boxShadow: lit ? t.cardShadowLift(m.tint) : t.cardShadowSoft,
cursor: 'pointer', zIndex: 4,
transition: 'all .25s',
transform: lit ? `translateY(${isTop ? -2 : 2}px)` : 'translateY(0)',
display: 'flex', flexDirection: 'column', gap: 6,
backdropFilter: t.key === 'light' ? 'blur(6px)' : 'none',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
...MONO, fontSize: 10, letterSpacing: 1.5, fontWeight: 700,
color: m.done ? t.done : m.tint, textTransform: 'uppercase' as const,
}}>{m.tick}</span>
<span style={{ flex: 1, height: 1, background: `${m.tint}44` }} />
<span style={{
...MONO, fontSize: 9, letterSpacing: 2, fontWeight: 700,
color: m.done ? t.done : m.tint, textTransform: 'uppercase' as const, opacity: .85,
}}>{badge}</span>
</div>
<div style={{ fontSize: 13, fontWeight: 700, color: t.fg, letterSpacing: -0.2, lineHeight: 1.25 }}>
{de ? m.title.de : m.title.en}
</div>
<div style={{ fontSize: 10.5, lineHeight: 1.45, color: lit ? t.fgSoft : t.fgMuted, transition: 'color .25s' }}>
{de ? m.short.de : m.short.en}
</div>
<div style={{
marginTop: 'auto',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
paddingTop: 6, borderTop: `1px dashed ${m.tint}44`,
}}>
<span style={{ fontSize: 10, color: t.fgFaint }}>{m.when}</span>
<span style={{
fontSize: 10, color: m.tint, fontWeight: 700,
opacity: lit ? 1 : 0.55,
transform: `translateX(${lit ? 0 : -4}px)`,
transition: 'all .25s',
}}>{de ? 'Details →' : 'Details →'}</span>
</div>
</div>
</>
)
}
// ── Stat Card ─────────────────────────────────────────────────────────────────
function StatCard({ item, t, de }: { item: StatItem; t: Theme; de: boolean }) {
const [hover, setHover] = useState(false)
const bgTop = hover ? item.tint + t.statTintTopH : item.tint + t.statTintTop
const bgMid = item.tint + t.statTintMid
return (
<div
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
position: 'relative', padding: '14px 18px', borderRadius: 12,
background: `linear-gradient(180deg, ${bgTop} 0%, ${bgMid} 60%, ${t.cardBase}${t.cardBaseA})`,
border: `1px solid ${hover ? item.tint : item.tint + '55'}`,
boxShadow: hover ? t.statShadowLift(item.tint) : t.statShadowSoft,
transform: hover ? 'translateY(-3px)' : 'translateY(0)',
transition: 'all .25s',
overflow: 'hidden',
backdropFilter: t.key === 'light' ? 'blur(6px)' : 'none',
}}>
<div style={{
position: 'absolute', right: 10, top: 10, width: 6, height: 6,
borderRadius: '50%', background: item.tint, opacity: .9,
boxShadow: `0 0 10px ${item.tint}`,
}} />
<div style={{ ...MONO, fontSize: 9.5, letterSpacing: 2, color: item.tint, textTransform: 'uppercase' as const, fontWeight: 700, marginBottom: 6 }}>
{de ? item.k.de : item.k.en}
</div>
<div style={{ fontSize: 32, fontWeight: 700, color: t.fg, letterSpacing: -0.8, lineHeight: 1 }}>
{item.v}
</div>
<svg viewBox="0 0 100 16" preserveAspectRatio="none"
style={{ width: '100%', height: 14, marginTop: 8, opacity: hover ? 1 : t.sparkOp, transition: 'opacity .25s' }}>
<defs>
<linearGradient id={`spark-${item.tint.replace('#', '')}`} x1="0" x2="1">
<stop offset="0" stopColor={item.tint} stopOpacity="0" />
<stop offset=".5" stopColor={item.tint} stopOpacity=".9" />
<stop offset="1" stopColor={item.tint} stopOpacity="0" />
</linearGradient>
</defs>
<path d="M 0 10 L 15 8 L 30 11 L 48 6 L 62 9 L 78 4 L 100 2"
stroke={`url(#spark-${item.tint.replace('#', '')})`} strokeWidth="1.5" fill="none" />
</svg>
</div>
)
}
// ── Detail modal ──────────────────────────────────────────────────────────────
function DetailModal({ item, onClose, t, de }: {
item: Milestone | null; onClose: () => void; t: Theme; de: boolean
}) {
useEffect(() => {
if (!item) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [item, onClose])
if (!item) return null
const tint = item.tint
const badge = item.done
? (de ? 'ABGESCHLOSSEN' : 'COMPLETED')
: (item.next ? (de ? 'ALS NÄCHSTES' : 'NEXT UP') : (de ? 'GEPLANT' : 'PLANNED'))
const badgeColor = item.done ? t.done : tint
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 50,
background: t.modalScrim, backdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'msFadeIn .2s ease-out',
}}>
<div onClick={e => e.stopPropagation()} style={{
width: 580, maxWidth: '88%',
background: `linear-gradient(180deg, ${tint}22 0%, ${t.modalBgMid} 50%, ${t.modalBgLow} 100%)`,
border: `1px solid ${tint}77`,
borderRadius: 16,
boxShadow: t.modalShadow(tint),
padding: '24px 28px', color: t.fg,
animation: 'msScaleIn .22s ease-out',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div style={{
width: 42, height: 42, borderRadius: 11,
background: `linear-gradient(135deg, ${tint}66, ${tint}22)`,
border: `1px solid ${tint}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: t.key === 'light' ? tint : '#fff', fontSize: 17, fontWeight: 700,
boxShadow: `0 0 20px ${tint}66`,
}}>{item.done ? '✓' : (item.next ? '◉' : '○')}</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<span style={{
...MONO, fontSize: 9.5, letterSpacing: 2.5, color: badgeColor,
textTransform: 'uppercase' as const, fontWeight: 700,
padding: '2px 8px', borderRadius: 4,
background: `${badgeColor}22`, border: `1px solid ${badgeColor}66`,
}}>{badge}</span>
<span style={{ ...MONO, fontSize: 10, color: t.fgFaint }}>{item.when}</span>
</div>
<div style={{ fontSize: 20, fontWeight: 700, color: t.fg, letterSpacing: -0.3 }}>
{de ? item.title.de : item.title.en}
</div>
</div>
<button onClick={onClose} style={{
background: 'transparent', border: `1px solid ${tint}66`, color: t.fg,
width: 32, height: 32, borderRadius: 8, cursor: 'pointer', fontSize: 14,
}}></button>
</div>
<div style={{ fontSize: 13, lineHeight: 1.6, color: t.fgSoft, marginBottom: 16 }}>
{de ? item.body.de : item.body.en}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{(de ? item.bullets.de : item.bullets.en).map((b, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'flex-start', gap: 10,
padding: '9px 13px', borderRadius: 8,
background: t.bulletBg, border: `1px solid ${tint}44`,
}}>
<span style={{ color: item.done ? t.done : tint, fontSize: 12, marginTop: 1 }}>
{item.done ? '✓' : '▸'}
</span>
<span style={{ fontSize: 12, lineHeight: 1.5, color: t.fgSoft }}>{b}</span>
</div>
))}
</div>
</div>
</div>
)
}
// ── Inner slide (fixed 1280×680) ──────────────────────────────────────────────
function MilestonesInner({ t, de, sel, setSel }: {
t: Theme; de: boolean
t: typeof THEMES.dark; de: boolean
sel: Milestone | null
setSel: (m: Milestone | null) => void
}) {

View File

@@ -0,0 +1,119 @@
// USPSlide data — extracted from USPSlide.tsx
export interface DetailItem {
tint: string
icon: string
kicker: string
title: string
body: string
bullets?: string[]
stat?: { k: string; v: string }
}
export function getDetails(de: boolean): Record<string, DetailItem> {
return {
rfq: {
tint: '#a78bfa', icon: '\u21C4',
kicker: de ? 'S\u00e4ule \u00b7 Compliance' : 'Pillar \u00b7 Compliance',
title: de ? 'RFQ-Pr\u00fcfung' : 'RFQ Verification',
body: de
? 'Kunden-Anforderungsdokumente werden automatisch gegen den aktuellen Source-Code gepr\u00fcft. Abweichungen werden erkannt, \u00c4nderungen vorgeschlagen und auf Wunsch direkt im Code umgesetzt \u2014 ohne manuelles Nacharbeiten.'
: 'Customer requirement documents are automatically verified against current source code. Deviations are detected, changes proposed and implemented directly in code on request \u2014 no manual rework needed.',
bullets: de
? ['Klauseln automatisch gegen SBOM, SAST-Findings und Policy-Docs abgeglichen', 'L\u00fccken mit konkreten Implementierungsvorschl\u00e4gen markiert', 'RFQ-Antworten in Stunden statt Wochen']
: ['Auto-match clauses against SBOM, SAST findings and policy docs', 'Flag gaps with concrete implementation proposals', 'Win-ready RFQ replies in hours, not weeks'],
stat: { k: de ? '\u00d8 Antwortzeit' : 'avg response time', v: de ? '4,2h (war 12 Tage)' : '4.2h (was 12 days)' },
},
process: {
tint: '#c084fc', icon: '\u27F2',
kicker: de ? 'S\u00e4ule \u00b7 Compliance' : 'Pillar \u00b7 Compliance',
title: de ? 'Prozess-Compliance' : 'Process Compliance',
body: de
? 'Vom Audit-Finding \u00fcber das Ticket bis zur Code-\u00c4nderung l\u00e4uft der gesamte Prozess automatisiert durch. Rollen, Fristen und Eskalation werden End-to-End verwaltet. Nachweise werden automatisch generiert und archiviert.'
: 'From audit finding to ticket to code change, the entire process runs automatically. Roles, deadlines and escalation are managed end-to-end. Evidence is automatically generated and archived.',
bullets: de
? ['Finding \u2192 Ticket \u2192 PR \u2192 Nachweis in einem Thread', 'SLA-Tracking pro Control mit Auto-Eskalation', 'Unver\u00e4nderliches Audit-Log, pro \u00c4nderung signiert']
: ['Finding \u2192 ticket \u2192 PR \u2192 evidence in one thread', 'SLA tracking per control with auto-escalation', 'Immutable audit log signed per change'],
stat: { k: de ? 'automatisierte Prozessschritte' : 'process steps automated', v: '87%' },
},
bidir: {
tint: '#fbbf24', icon: '\u27F7',
kicker: de ? 'S\u00e4ule \u00b7 Code' : 'Pillar \u00b7 Code',
title: de ? 'Bidirektional' : 'Bidirectional Sync',
body: de
? 'Compliance-Anforderungen fliessen direkt in den Code. Umgekehrt aktualisieren Code-\u00c4nderungen automatisch die Compliance-Dokumentation. Beide Seiten sind immer synchron \u2014 kein Informationsverlust zwischen Audit und Entwicklung.'
: 'Compliance requirements flow directly into code. Conversely, code changes automatically update compliance documentation. Both sides always stay in sync \u2014 no information loss between audit and development.',
bullets: de
? ['Policy \u2194 Code-Mapping via semantischem Diff', 'Git-nativ: jede \u00c4nderung als PR', 'Zero Drift zwischen Audit-Artefakten und Realit\u00e4t']
: ['Policy \u2194 code mapping via semantic diff', 'Git-native: every change shipped as a PR', 'Zero drift between audit artefacts and reality'],
stat: { k: de ? 'Drift-Vorf\u00e4lle' : 'drift incidents', v: de ? '0 seit M\u00e4rz 2024' : '0 since Mar-2024' },
},
cont: {
tint: '#f59e0b', icon: '\u25CE',
kicker: de ? 'S\u00e4ule \u00b7 Code' : 'Pillar \u00b7 Code',
title: de ? 'Kontinuierlich' : 'Continuous, Not Yearly',
body: de
? 'Klassische Compliance pr\u00fcft einmal im Jahr und hofft auf das Beste. Unsere Plattform pr\u00fcft bei jeder Code-\u00c4nderung. Findings werden sofort zu Tickets mit konkreten Implementierungsvorschl\u00e4gen im Issue-Tracker der Wahl.'
: 'Traditional compliance checks once a year and hopes for the best. Our platform checks on every code change. Findings immediately become tickets with concrete implementation proposals in the issue tracker of choice.',
bullets: de
? ['CI-integrierte Validierung bei jedem Push', 'Fix-Vorschl\u00e4ge generiert, nicht nur gemeldet', 'Compliance-Frische: Minuten statt Monate']
: ['CI-integrated validation on each push', 'Fix suggestions generated, not just reported', 'Compliance freshness: minutes, not months'],
stat: { k: de ? 'Validierungen / Tag' : 'validations / day', v: '~2.400 / repo' },
},
trace: {
tint: '#a78bfa', icon: '\u21C4',
kicker: de ? 'Under the Hood' : 'Under the Hood',
title: de ? 'End-to-End R\u00fcckverfolgbarkeit' : 'End-to-End Traceability',
body: de
? 'Regulatorische Anforderungen (Gesetz \u2192 Obligation \u2192 Control) deterministisch mit realem Systemzustand und Code verkn\u00fcpft \u2014 inklusive revisionssicherem Evidence-Layer.'
: 'Regulatory requirements (law \u2192 obligation \u2192 control) deterministically linked to real system state and code \u2014 including audit-proof evidence layer.',
bullets: de
? ['Versionierter Evidence-Chain, unver\u00e4nderlich gespeichert', 'Ein Klick von Klausel bis Codezeile', 'Signierte Attestierungen pro Build']
: ['Versioned evidence chain stored immutably', 'One-click drill from clause to line of code', 'Signed attestations per build'],
},
engine: {
tint: '#c084fc', icon: '\u25C9',
kicker: de ? 'Under the Hood' : 'Under the Hood',
title: de ? 'Continuous Compliance Engine' : 'Continuous Compliance Engine',
body: de
? 'Statt punktueller Audits: Validierung bei jeder \u00c4nderung (Code, Infrastruktur, Prozesse) mit auditierbaren Nachweisen in Echtzeit.'
: 'Instead of point-in-time audits: validation on every change (code, infrastructure, processes) with auditable evidence in real time.',
bullets: de
? ['Rule-Packs pro Framework (NIS-2, DORA, \u2026)', 'Verarbeitet Code, IaC und Prozess-Events', 'Findings automatisch ans richtige Team geroutet']
: ['Rule packs per framework (NIS-2, DORA, \u2026)', 'Handles code, infra-as-code, and process events', 'Findings routed to the right team automatically'],
},
opt: {
tint: '#fbbf24', icon: '\u2726',
kicker: de ? 'Under the Hood' : 'Under the Hood',
title: de ? 'Compliance Optimizer' : 'Compliance Optimizer',
body: de
? 'Nicht nur \u201eerlaubt/verboten\u201c, sondern die maximal zul\u00e4ssige Ausgestaltung jedes KI-Use-Cases. Deterministische Constraint-Optimierung zeigt den Sweet Spot zwischen Regulierung und Innovation \u2014 ersetzt 20\u2013200k EUR Anwaltskosten.'
: 'Not just "allowed/forbidden" but the maximum permissible configuration of every AI use case. Deterministic constraint optimization shows the sweet spot between regulation and innovation \u2014 replaces EUR 20\u2013200k in legal fees.',
bullets: de
? ['ROI-Ranking jedes offenen Findings', 'Abw\u00e4gung zwischen Liefergeschwindigkeit und Restrisiko', 'Low-Hanging-Wins zuerst']
: ['ROI-ranks every open finding', 'Balances speed of delivery with residual risk', 'Highlights low-hanging wins first'],
},
stack: {
tint: '#f59e0b', icon: '\u25CE',
kicker: de ? 'Under the Hood' : 'Under the Hood',
title: de ? 'EU-Trust & Governance Stack' : 'EU Trust & Governance Stack',
body: de
? 'Souver\u00e4ne, DSGVO-/AI-Act-konforme Architektur (EU-Hosting, Isolation, Betriebsrat-F\u00e4higkeit) \u2014 Marktzugang, den US-L\u00f6sungen strukturell nicht erreichen.'
: 'Sovereign, GDPR/AI Act compliant architecture (EU hosting, isolation, works council capability) \u2014 market access that US solutions structurally cannot achieve.',
bullets: de
? ['DSGVO \u00b7 NIS-2 \u00b7 DORA \u00b7 EU AI Act \u00b7 ISO 27001 \u00b7 BSI C5', 'EU-souver\u00e4nes Hosting und Key-Management', 'Eine Plattform, ein Audit, eine Rechnung']
: ['DSGVO \u00b7 NIS-2 \u00b7 DORA \u00b7 EU AI Act \u00b7 ISO 27001 \u00b7 BSI C5', 'EU-sovereign hosting and key-management', 'One platform, one audit, one bill'],
},
hub: {
tint: '#a78bfa', icon: '\u221E',
kicker: de ? 'Die Schleife' : 'The Loop',
title: de ? 'Compliance \u2194 Code \u00b7 Immer in Sync' : 'Compliance \u2194 Code \u00b7 Always in sync',
body: de
? 'Die Plattform ist eine einzige geschlossene Schleife. Jede Policy-\u00c4nderung fliesst in den Code; jede Code-\u00c4nderung fliesst in die Policy zur\u00fcck.'
: 'The platform is a single closed loop. Every policy change ripples into code; every code change ripples back into policy. That\'s the USP in one diagram.',
bullets: de
? ['Single Source of Truth, zwei Oberfl\u00e4chen', 'Echtzeit-Sync, kein Batch-Abgleich', 'Auditoren, Entwickler und Sales fragen denselben Graphen ab']
: ['Single source of truth, two surfaces', 'Real-time sync, not batch reconciliation', 'Auditors, engineers and sales all query the same graph'],
},
}
}

View File

@@ -0,0 +1,370 @@
'use client'
import { useState, useEffect, useRef, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { X } from 'lucide-react'
import { type DetailItem } from './USPSlide.data'
const MONO: React.CSSProperties = {
fontFamily: '"JetBrains Mono","SF Mono",ui-monospace,monospace',
fontVariantNumeric: 'tabular-nums',
}
// ── Light mode hook
export function useIsLight() {
const [isLight, setIsLight] = useState(false)
useEffect(() => {
const check = () => setIsLight(document.documentElement.classList.contains('theme-light'))
check()
const obs = new MutationObserver(check)
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => obs.disconnect()
}, [])
return isLight
}
// ── Ticker
function useTicker(fn: () => void, min = 180, max = 420, skip = 0.1) {
const ref = useRef(fn)
ref.current = fn
useEffect(() => {
let t: ReturnType<typeof setTimeout>
const loop = () => {
if (Math.random() > skip) ref.current()
t = setTimeout(loop, min + Math.random() * (max - min))
}
loop()
return () => clearTimeout(t)
}, [min, max, skip])
}
function TickerShell({ tint, isLight, children }: { tint: string; isLight: boolean; children: React.ReactNode }) {
return (
<div style={{
...MONO, marginTop: 10, padding: '5px 9px',
background: isLight ? '#f1f5f9' : 'rgba(0,0,0,.38)',
border: `1px solid ${tint}55`,
borderRadius: 6, fontSize: 10.5,
color: isLight ? '#475569' : 'rgba(236,233,247,.88)',
display: 'flex', alignItems: 'center', gap: 7,
whiteSpace: 'nowrap', overflow: 'hidden', height: 22,
}}>{children}</div>
)
}
export function TickTrace({ tint, isLight }: { tint: string; isLight: boolean }) {
const [n, setN] = useState(12748)
useTicker(() => setN(v => v + 1 + Math.floor(Math.random() * 3)), 250, 500)
return (
<TickerShell tint={tint} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color: tint, opacity: .85 }}>trace</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{n.toLocaleString()}</span>
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.45)' }}>evidence-chain</span>
</TickerShell>
)
}
export function TickEngine({ tint, isLight }: { tint: string; isLight: boolean }) {
const [v, setV] = useState(428)
const [rate, setRate] = useState(99.4)
useTicker(() => {
setV(x => x + 1 + Math.floor(Math.random() * 4))
setRate(r => Math.max(97, Math.min(99.9, r + (Math.random() - 0.5) * 0.3)))
}, 220, 420)
return (
<TickerShell tint={tint} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color: tint, opacity: .85 }}>validate</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{v.toLocaleString()}</span>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}>{rate.toFixed(1)}%</span>
</TickerShell>
)
}
export function TickOptimizer({ tint, isLight }: { tint: string; isLight: boolean }) {
const ops = ['ROI: 2.418 € / dev', 'gap → policy §4.2', 'dedup 128 tickets', 'sweet-spot: 22 KLOC', 'tradeoff: speed↔risk']
const [i, setI] = useState(0)
useTicker(() => setI(x => (x + 1) % ops.length), 900, 1600, 0.05)
return (
<TickerShell tint={tint} isLight={isLight}>
<span style={{ color: '#fbbf24' }}></span>
<span style={{ color: tint, opacity: .85 }}>optimize</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>{ops[i]}</span>
</TickerShell>
)
}
export function TickStack({ tint, isLight }: { tint: string; isLight: boolean }) {
const regs = ['DSGVO', 'NIS-2', 'DORA', 'EU AI Act', 'ISO 27001', 'BSI C5']
const [i, setI] = useState(0)
const [c, setC] = useState(1208)
useTicker(() => { setI(x => (x + 1) % regs.length); setC(v => v + Math.floor(Math.random() * 3)) }, 800, 1400, 0.05)
return (
<TickerShell tint={tint} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color: tint, opacity: .85 }}>check</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{regs[i]}</span>
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.4)' }}>·</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc' }}>{c.toLocaleString()}</span>
</TickerShell>
)
}
// ── Pillar row
export function PillarRow({ side, title, body, tint, onClick, active, isLight }: {
side: 'left' | 'right'; title: string; body: string; tint: string
onClick: () => void; active: boolean; isLight: boolean
}) {
const [hover, setHover] = useState(false)
const lit = hover || active
const isLeft = side === 'left'
return (
<div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
style={{
display: 'flex', alignItems: 'flex-start', gap: 12,
flexDirection: isLeft ? 'row-reverse' : 'row',
textAlign: isLeft ? 'right' : 'left',
padding: '10px 14px', borderRadius: 10, cursor: 'pointer',
transition: 'transform .25s, background .25s, box-shadow .25s',
background: lit ? `linear-gradient(${isLeft ? '270deg' : '90deg'}, ${tint}24 0%, ${tint}0a 70%, transparent 100%)` : 'transparent',
boxShadow: lit ? `0 10px 30px ${tint}26, inset 0 0 0 1px ${tint}44` : 'inset 0 0 0 1px transparent',
transform: lit ? (isLeft ? 'translateX(-3px)' : 'translateX(3px)') : 'translateX(0)',
}}>
<div style={{
flex: '0 0 30px', width: 30, height: 30, borderRadius: 9,
background: lit ? `${tint}3a` : `${tint}22`, border: `1px solid ${lit ? tint : tint + '66'}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: lit ? (isLight ? tint : '#fff') : tint, fontSize: 13, fontWeight: 700, marginTop: 2,
boxShadow: lit ? `0 0 14px ${tint}88, inset 0 1px 0 ${tint}80` : `inset 0 1px 0 ${tint}50`,
transition: 'all .25s',
}}></div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 700, color: isLight ? '#1a1a2e' : '#f7f5fc',
letterSpacing: -0.15, marginBottom: 3,
display: 'flex', alignItems: 'center', gap: 6,
justifyContent: isLeft ? 'flex-end' : 'flex-start',
}}>
<span>{title}</span>
<span style={{ fontSize: 10, color: tint, opacity: lit ? 1 : 0, transform: `translateX(${lit ? 0 : (isLeft ? 4 : -4)}px)`, transition: 'all .25s' }}>{isLeft ? '\u2039' : '\u203A'}</span>
</div>
<div style={{
fontSize: 11, lineHeight: 1.55,
color: isLight ? `rgba(71,85,105,${lit ? 1 : .78})` : `rgba(236,233,247,${lit ? .82 : .62})`,
transition: 'color .25s',
}}>{body}</div>
</div>
</div>
)
}
// ── Column header
export function ColHeader({ side, label, color, icon, sub, isLight }: {
side: 'left' | 'right'; label: string; color: string; icon: string; sub: string; isLight: boolean
}) {
const isLeft = side === 'left'
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
flexDirection: isLeft ? 'row-reverse' : 'row',
paddingBottom: 10, borderBottom: `1px solid ${color}35`,
}}>
<div style={{
width: 34, height: 34, borderRadius: 9,
background: `linear-gradient(135deg, ${color}55, ${color}20)`,
border: `1px solid ${color}88`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: isLight ? color : '#fff', fontSize: 15, fontWeight: 700,
boxShadow: `0 0 18px ${color}55, inset 0 1px 0 ${color}aa`,
}}>{icon}</div>
<div>
<div style={{ fontSize: 18, fontWeight: 700, color: isLight ? '#1a1a2e' : '#f7f5fc', letterSpacing: -0.3, lineHeight: 1 }}>{label}</div>
<div style={{ ...MONO, fontSize: 9.5, letterSpacing: 2, color, opacity: .75, marginTop: 3, textTransform: 'uppercase' as const }}>{sub}</div>
</div>
</div>
)
}
// ── Central hub
export function CentralHub({ caption, isLight }: { caption: string; isLight: boolean }) {
return (
<div style={{ position: 'relative', width: 260, height: 320, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{
position: 'relative', width: 120, height: 120, borderRadius: '50%',
background: 'radial-gradient(circle at 32% 28%, #f0e9ff 0%, #c4aaff 26%, #7b5cd6 58%, #2a1560 100%)',
border: '1.5px solid rgba(216,202,255,.7)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: isLight
? '0 0 30px rgba(167,139,250,.4), 0 0 60px rgba(167,139,250,.15), inset 0 3px 0 rgba(255,255,255,.5), inset 0 -8px 14px rgba(0,0,0,.2)'
: '0 0 50px rgba(167,139,250,.65), 0 0 100px rgba(167,139,250,.25), inset 0 3px 0 rgba(255,255,255,.35), inset 0 -8px 14px rgba(0,0,0,.35)',
animation: isLight ? 'uspPulseLight 2.6s ease-in-out infinite' : 'uspPulse 2.6s ease-in-out infinite',
zIndex: 3,
}}>
<div style={{ position: 'absolute', inset: -14, borderRadius: '50%', border: `1px dashed ${isLight ? 'rgba(167,139,250,.5)' : 'rgba(216,202,255,.42)'}`, animation: 'uspSpin 14s linear infinite' }} />
<div style={{ position: 'absolute', inset: -30, borderRadius: '50%', border: `1px dashed ${isLight ? 'rgba(167,139,250,.3)' : 'rgba(216,202,255,.2)'}`, animation: 'uspSpin 22s linear infinite reverse' }} />
<svg width="54" height="26" viewBox="0 0 54 26" fill="none" stroke="#fff" strokeWidth="2.8" strokeLinecap="round" strokeLinejoin="round" style={{ filter: 'drop-shadow(0 1px 3px rgba(0,0,0,.5))' }}>
<path d="M 10 13 C 10 5, 22 5, 27 13 C 32 21, 44 21, 44 13 C 44 5, 32 5, 27 13 C 22 21, 10 21, 10 13 Z" />
</svg>
</div>
<div style={{
position: 'absolute', left: 0, right: 0, bottom: 24, textAlign: 'center',
...MONO, fontSize: 9.5, letterSpacing: 2.5,
color: isLight ? 'rgba(109,77,194,.75)' : 'rgba(216,202,255,.75)',
textTransform: 'uppercase' as const, fontWeight: 600,
}}>{caption}</div>
</div>
)
}
// ─<><E29480> Bridge SVG connectors
export function BridgeConnectors({ isLight }: { isLight: boolean }) {
const rfpY = 130; const sub2Y = 250; const hubCx = 500; const hubR = 72
return (
<svg viewBox="0 0 1000 400" preserveAspectRatio="none" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 1 }}>
<defs>
<linearGradient id="uspFromL" x1="0" x2="1"><stop offset="0" stopColor="#a78bfa" stopOpacity="0" /><stop offset=".3" stopColor="#a78bfa" stopOpacity={isLight ? '.6' : '.85'} /><stop offset="1" stopColor="#c084fc" stopOpacity={isLight ? '.2' : '.3'} /></linearGradient>
<linearGradient id="uspToR" x1="0" x2="1"><stop offset="0" stopColor="#c084fc" stopOpacity={isLight ? '.2' : '.3'} /><stop offset=".7" stopColor="#fbbf24" stopOpacity={isLight ? '.6' : '.85'} /><stop offset="1" stopColor="#fbbf24" stopOpacity="0" /></linearGradient>
</defs>
<line x1="40" y1={rfpY} x2={hubCx - hubR} y2={rfpY} stroke="url(#uspFromL)" strokeWidth="2" strokeDasharray="4 5" style={{ animation: 'uspFlowR 1.6s linear infinite' }} />
<line x1={hubCx + hubR} y1={rfpY} x2="960" y2={rfpY} stroke="url(#uspToR)" strokeWidth="2" strokeDasharray="4 5" style={{ animation: 'uspFlowR 1.6s linear infinite' }} />
<line x1="40" y1={sub2Y} x2={hubCx - hubR} y2={sub2Y} stroke="url(#uspFromL)" strokeWidth="2" strokeDasharray="4 5" style={{ animation: 'uspFlowR 1.6s linear infinite' }} />
<line x1={hubCx + hubR} y1={sub2Y} x2="960" y2={sub2Y} stroke="url(#uspToR)" strokeWidth="2" strokeDasharray="4 5" style={{ animation: 'uspFlowR 1.6s linear infinite' }} />
{([rfpY, sub2Y] as number[]).map(y => (
<g key={y}>
<circle cx={hubCx - hubR} cy={y} r="4" fill={isLight ? '#eef2ff' : '#1a0f34'} stroke="#a78bfa" strokeWidth="1.2" />
<circle cx={hubCx - hubR} cy={y} r="1.5" fill="#a78bfa" />
<circle cx={hubCx + hubR} cy={y} r="4" fill={isLight ? '#eef2ff' : '#1a0f34'} stroke="#fbbf24" strokeWidth="1.2" />
<circle cx={hubCx + hubR} cy={y} r="1.5" fill="#fbbf24" />
</g>
))}
<circle r="3" fill="#c4aaff" style={{ filter: 'drop-shadow(0 0 6px #a78bfa)' }}>
<animate attributeName="cx" from="40" to="960" dur="3.5s" repeatCount="indefinite" />
<animate attributeName="cy" values={`${rfpY};${rfpY}`} dur="3.5s" repeatCount="indefinite" />
</circle>
<circle r="3" fill="#fde68a" style={{ filter: 'drop-shadow(0 0 6px #fbbf24)' }}>
<animate attributeName="cx" from="960" to="40" dur="3.5s" repeatCount="indefinite" />
<animate attributeName="cy" values={`${sub2Y};${sub2Y}`} dur="3.5s" repeatCount="indefinite" />
</circle>
</svg>
)
}
// ── Feature card
export function FeatureCard({ icon, title, body, tint, Ticker, onClick, active, isLight }: {
icon: string; title: string; body: string; tint: string
Ticker: React.ComponentType<{ tint: string; isLight: boolean }>
onClick: () => void; active: boolean; isLight: boolean
}) {
const [hover, setHover] = useState(false)
const lit = hover || active
return (
<div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
style={{
position: 'relative', padding: '13px 15px',
background: isLight
? lit ? `linear-gradient(180deg, ${tint}18 0%, ${tint}08 55%, rgba(248,250,252,.95) 100%)` : 'linear-gradient(180deg, #ffffff, #f8fafc)'
: `linear-gradient(180deg, ${tint}${lit ? '2a' : '1a'} 0%, ${tint}07 55%, rgba(14,8,28,.85) 100%)`,
border: `1px solid ${lit ? tint : isLight ? 'rgba(0,0,0,.1)' : tint + '4a'}`,
borderRadius: 12,
boxShadow: lit ? `0 18px 40px ${tint}33, 0 0 0 1px ${tint}66, inset 0 1px 0 ${tint}60` : isLight ? '0 2px 8px rgba(0,0,0,.08), inset 0 1px 0 rgba(255,255,255,.8)' : `0 10px 24px rgba(0,0,0,.4), inset 0 1px 0 ${tint}35`,
minWidth: 0, cursor: 'pointer',
transform: lit ? 'translateY(-3px)' : 'translateY(0)',
transition: 'transform .25s, box-shadow .25s, background .25s, border-color .25s',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{
width: 22, height: 22, borderRadius: 6,
background: lit ? `${tint}44` : `${tint}22`, border: `1px solid ${lit ? tint : tint + '66'}`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: lit ? (isLight ? tint : '#fff') : tint, fontSize: 12,
boxShadow: lit ? `0 0 12px ${tint}88` : 'none', transition: 'all .25s',
}}>{icon}</span>
<span style={{ fontSize: 12.5, fontWeight: 700, color: isLight ? '#1a1a2e' : '#f7f5fc', letterSpacing: -0.15, flex: 1 }}>{title}</span>
<span style={{ fontSize: 10, color: tint, opacity: lit ? 1 : 0.5, transform: `translateX(${lit ? 0 : -3}px)`, transition: 'all .25s' }}></span>
</div>
<div style={{
fontSize: 11, lineHeight: 1.45,
color: isLight ? `rgba(71,85,105,${lit ? 1 : .78})` : `rgba(236,233,247,${lit ? .82 : .65})`,
transition: 'color .25s',
}}>{body}</div>
<Ticker tint={tint} isLight={isLight} />
</div>
)
}
// ── Detail modal
export function DetailModal({ item, onClose, isLight }: { item: DetailItem | null; onClose: () => void; isLight: boolean }) {
useEffect(() => {
if (!item) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [item, onClose])
return (
<AnimatePresence>
{item && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}
onClick={onClose}
style={{ position: 'absolute', inset: 0, zIndex: 50, background: isLight ? 'rgba(240,244,255,.72)' : 'rgba(5,2,16,.72)', backdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<motion.div initial={{ opacity: 0, scale: 0.94 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.94 }} transition={{ duration: 0.22 }}
onClick={e => e.stopPropagation()}
style={{
width: 560, maxWidth: '88%',
background: isLight ? `linear-gradient(180deg, ${item.tint}10 0%, rgba(255,255,255,.98) 50%, rgba(248,250,252,.99) 100%)` : `linear-gradient(180deg, ${item.tint}18 0%, rgba(20,10,40,.96) 50%, rgba(14,8,28,.98) 100%)`,
border: `1px solid ${item.tint}${isLight ? '44' : '66'}`, borderRadius: 16,
boxShadow: isLight ? `0 20px 60px rgba(0,0,0,.12), 0 0 40px ${item.tint}18, inset 0 1px 0 rgba(255,255,255,.9)` : `0 30px 80px rgba(0,0,0,.6), 0 0 60px ${item.tint}33, inset 0 1px 0 ${item.tint}55`,
padding: '22px 26px', color: isLight ? '#1a1a2e' : '#ece9f7',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
<div style={{ width: 38, height: 38, borderRadius: 10, background: `linear-gradient(135deg, ${item.tint}66, ${item.tint}22)`, border: `1px solid ${item.tint}`, display: 'flex', alignItems: 'center', justifyContent: 'center', color: isLight ? item.tint : '#fff', fontSize: 16, fontWeight: 700, boxShadow: `0 0 18px ${item.tint}66` }}>{item.icon}</div>
<div style={{ flex: 1 }}>
<div style={{ ...MONO, fontSize: 9.5, letterSpacing: 2.5, color: item.tint, textTransform: 'uppercase' as const, fontWeight: 600, marginBottom: 2 }}>{item.kicker}</div>
<div style={{ fontSize: 19, fontWeight: 700, color: isLight ? '#1a1a2e' : '#f7f5fc', letterSpacing: -0.3 }}>{item.title}</div>
</div>
<button onClick={onClose} style={{ background: 'transparent', border: `1px solid ${item.tint}55`, borderRadius: 8, cursor: 'pointer', width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center', color: isLight ? '#64748b' : 'rgba(236,233,247,.6)' }}>
<X style={{ width: 14, height: 14 }} />
</button>
</div>
<div style={{ fontSize: 13, lineHeight: 1.6, color: isLight ? '#475569' : 'rgba(236,233,247,.82)', marginBottom: 16 }}>{item.body}</div>
{item.bullets && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
{item.bullets.map((b, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: '8px 12px', borderRadius: 8, background: isLight ? 'rgba(0,0,0,.04)' : 'rgba(0,0,0,.3)', border: `1px solid ${item.tint}${isLight ? '22' : '33'}` }}>
<span style={{ color: item.tint, fontSize: 12, marginTop: 1 }}></span>
<span style={{ fontSize: 12, lineHeight: 1.5, color: isLight ? '#475569' : 'rgba(236,233,247,.78)' }}>{b}</span>
</div>
))}
</div>
)}
{item.stat && (
<div style={{ ...MONO, padding: '10px 14px', borderRadius: 8, background: isLight ? 'rgba(0,0,0,.04)' : 'rgba(0,0,0,.45)', border: `1px solid ${item.tint}${isLight ? '33' : '55'}`, fontSize: 12, color: isLight ? '#475569' : 'rgba(236,233,247,.9)', display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color: item.tint }}>{item.stat.k}</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{item.stat.v}</span>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
// ── Star field
export function StarField({ isLight }: { isLight: boolean }) {
const stars = useMemo(() => {
let s = 41
const r = () => { s = (s * 9301 + 49297) % 233280; return s / 233280 }
return Array.from({ length: 90 }, () => ({ x: r() * 100, y: r() * 100, size: r() * 1.4 + 0.3, op: r() * 0.5 + 0.15 }))
}, [])
if (isLight) return null
return (
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
{stars.map((st, i) => (
<div key={i} style={{ position: 'absolute', left: `${st.x}%`, top: `${st.y}%`, width: st.size, height: st.size, borderRadius: '50%', background: '#fff', opacity: st.op, boxShadow: `0 0 ${st.size * 3}px rgba(180,160,255,.7)` }} />
))}
</div>
)
}

View File

@@ -1,11 +1,9 @@
'use client'
import { useState, useEffect, useRef, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useState } from 'react'
import { Language } from '@/lib/types'
import GradientText from '../ui/GradientText'
import FadeInView from '../ui/FadeInView'
import { X } from 'lucide-react'
interface USPSlideProps { lang: Language }
@@ -32,592 +30,6 @@ const CSS_KF = `
`
// ── Light mode hook ───────────────────────────────────────────────────────────
function useIsLight() {
const [isLight, setIsLight] = useState(false)
useEffect(() => {
const check = () => setIsLight(document.documentElement.classList.contains('theme-light'))
check()
const obs = new MutationObserver(check)
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => obs.disconnect()
}, [])
return isLight
}
// ── Ticker ────────────────────────────────────────────────────────────────────
function useTicker(fn: () => void, min = 180, max = 420, skip = 0.1) {
const ref = useRef(fn)
ref.current = fn
useEffect(() => {
let t: ReturnType<typeof setTimeout>
const loop = () => {
if (Math.random() > skip) ref.current()
t = setTimeout(loop, min + Math.random() * (max - min))
}
loop()
return () => clearTimeout(t)
}, [min, max, skip])
}
function TickerShell({ tint, isLight, children }: { tint: string; isLight: boolean; children: React.ReactNode }) {
return (
<div style={{
...MONO, marginTop: 10, padding: '5px 9px',
background: isLight ? '#f1f5f9' : 'rgba(0,0,0,.38)',
border: `1px solid ${tint}55`,
borderRadius: 6, fontSize: 10.5,
color: isLight ? '#475569' : 'rgba(236,233,247,.88)',
display: 'flex', alignItems: 'center', gap: 7,
whiteSpace: 'nowrap', overflow: 'hidden', height: 22,
}}>{children}</div>
)
}
function TickTrace({ tint, isLight }: { tint: string; isLight: boolean }) {
const [n, setN] = useState(12748)
useTicker(() => setN(v => v + 1 + Math.floor(Math.random() * 3)), 250, 500)
return (
<TickerShell tint={tint} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color: tint, opacity: .85 }}>trace</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{n.toLocaleString()}</span>
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.45)' }}>evidence-chain</span>
</TickerShell>
)
}
function TickEngine({ tint, isLight }: { tint: string; isLight: boolean }) {
const [v, setV] = useState(428)
const [rate, setRate] = useState(99.4)
useTicker(() => {
setV(x => x + 1 + Math.floor(Math.random() * 4))
setRate(r => Math.max(97, Math.min(99.9, r + (Math.random() - 0.5) * 0.3)))
}, 220, 420)
return (
<TickerShell tint={tint} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color: tint, opacity: .85 }}>validate</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{v.toLocaleString()}</span>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}>{rate.toFixed(1)}%</span>
</TickerShell>
)
}
function TickOptimizer({ tint, isLight }: { tint: string; isLight: boolean }) {
const ops = ['ROI: 2.418 € / dev', 'gap → policy §4.2', 'dedup 128 tickets', 'sweet-spot: 22 KLOC', 'tradeoff: speed↔risk']
const [i, setI] = useState(0)
useTicker(() => setI(x => (x + 1) % ops.length), 900, 1600, 0.05)
return (
<TickerShell tint={tint} isLight={isLight}>
<span style={{ color: '#fbbf24' }}></span>
<span style={{ color: tint, opacity: .85 }}>optimize</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>{ops[i]}</span>
</TickerShell>
)
}
function TickStack({ tint, isLight }: { tint: string; isLight: boolean }) {
const regs = ['DSGVO', 'NIS-2', 'DORA', 'EU AI Act', 'ISO 27001', 'BSI C5']
const [i, setI] = useState(0)
const [c, setC] = useState(1208)
useTicker(() => { setI(x => (x + 1) % regs.length); setC(v => v + Math.floor(Math.random() * 3)) }, 800, 1400, 0.05)
return (
<TickerShell tint={tint} isLight={isLight}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color: tint, opacity: .85 }}>check</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{regs[i]}</span>
<span style={{ color: isLight ? '#94a3b8' : 'rgba(236,233,247,.4)' }}>·</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc' }}>{c.toLocaleString()}</span>
</TickerShell>
)
}
// ── Data ──────────────────────────────────────────────────────────────────────
interface DetailItem {
tint: string
icon: string
kicker: string
title: string
body: string
bullets?: string[]
stat?: { k: string; v: string }
}
function getDetails(de: boolean): Record<string, DetailItem> {
return {
rfq: {
tint: '#a78bfa', icon: '⇄',
kicker: de ? 'Säule · Compliance' : 'Pillar · Compliance',
title: de ? 'RFQ-Prüfung' : 'RFQ Verification',
body: de
? 'Kunden-Anforderungsdokumente werden automatisch gegen den aktuellen Source-Code geprüft. Abweichungen werden erkannt, Änderungen vorgeschlagen und auf Wunsch direkt im Code umgesetzt — ohne manuelles Nacharbeiten.'
: 'Customer requirement documents are automatically verified against current source code. Deviations are detected, changes proposed and implemented directly in code on request — no manual rework needed.',
bullets: de
? ['Klauseln automatisch gegen SBOM, SAST-Findings und Policy-Docs abgeglichen', 'Lücken mit konkreten Implementierungsvorschlägen markiert', 'RFQ-Antworten in Stunden statt Wochen']
: ['Auto-match clauses against SBOM, SAST findings and policy docs', 'Flag gaps with concrete implementation proposals', 'Win-ready RFQ replies in hours, not weeks'],
stat: { k: de ? 'Ø Antwortzeit' : 'avg response time', v: de ? '4,2h (war 12 Tage)' : '4.2h (was 12 days)' },
},
process: {
tint: '#c084fc', icon: '⟲',
kicker: de ? 'Säule · Compliance' : 'Pillar · Compliance',
title: de ? 'Prozess-Compliance' : 'Process Compliance',
body: de
? 'Vom Audit-Finding über das Ticket bis zur Code-Änderung läuft der gesamte Prozess automatisiert durch. Rollen, Fristen und Eskalation werden End-to-End verwaltet. Nachweise werden automatisch generiert und archiviert.'
: 'From audit finding to ticket to code change, the entire process runs automatically. Roles, deadlines and escalation are managed end-to-end. Evidence is automatically generated and archived.',
bullets: de
? ['Finding → Ticket → PR → Nachweis in einem Thread', 'SLA-Tracking pro Control mit Auto-Eskalation', 'Unveränderliches Audit-Log, pro Änderung signiert']
: ['Finding → ticket → PR → evidence in one thread', 'SLA tracking per control with auto-escalation', 'Immutable audit log signed per change'],
stat: { k: de ? 'automatisierte Prozessschritte' : 'process steps automated', v: '87%' },
},
bidir: {
tint: '#fbbf24', icon: '⟷',
kicker: de ? 'Säule · Code' : 'Pillar · Code',
title: de ? 'Bidirektional' : 'Bidirectional Sync',
body: de
? 'Compliance-Anforderungen fliessen direkt in den Code. Umgekehrt aktualisieren Code-Änderungen automatisch die Compliance-Dokumentation. Beide Seiten sind immer synchron — kein Informationsverlust zwischen Audit und Entwicklung.'
: 'Compliance requirements flow directly into code. Conversely, code changes automatically update compliance documentation. Both sides always stay in sync — no information loss between audit and development.',
bullets: de
? ['Policy ↔ Code-Mapping via semantischem Diff', 'Git-nativ: jede Änderung als PR', 'Zero Drift zwischen Audit-Artefakten und Realität']
: ['Policy ↔ code mapping via semantic diff', 'Git-native: every change shipped as a PR', 'Zero drift between audit artefacts and reality'],
stat: { k: de ? 'Drift-Vorfälle' : 'drift incidents', v: de ? '0 seit März 2024' : '0 since Mar-2024' },
},
cont: {
tint: '#f59e0b', icon: '◎',
kicker: de ? 'Säule · Code' : 'Pillar · Code',
title: de ? 'Kontinuierlich' : 'Continuous, Not Yearly',
body: de
? 'Klassische Compliance prüft einmal im Jahr und hofft auf das Beste. Unsere Plattform prüft bei jeder Code-Änderung. Findings werden sofort zu Tickets mit konkreten Implementierungsvorschlägen im Issue-Tracker der Wahl.'
: 'Traditional compliance checks once a year and hopes for the best. Our platform checks on every code change. Findings immediately become tickets with concrete implementation proposals in the issue tracker of choice.',
bullets: de
? ['CI-integrierte Validierung bei jedem Push', 'Fix-Vorschläge generiert, nicht nur gemeldet', 'Compliance-Frische: Minuten statt Monate']
: ['CI-integrated validation on each push', 'Fix suggestions generated, not just reported', 'Compliance freshness: minutes, not months'],
stat: { k: de ? 'Validierungen / Tag' : 'validations / day', v: '~2.400 / repo' },
},
trace: {
tint: '#a78bfa', icon: '⇄',
kicker: de ? 'Under the Hood' : 'Under the Hood',
title: de ? 'End-to-End Rückverfolgbarkeit' : 'End-to-End Traceability',
body: de
? 'Regulatorische Anforderungen (Gesetz → Obligation → Control) deterministisch mit realem Systemzustand und Code verknüpft — inklusive revisionssicherem Evidence-Layer.'
: 'Regulatory requirements (law → obligation → control) deterministically linked to real system state and code — including audit-proof evidence layer.',
bullets: de
? ['Versionierter Evidence-Chain, unveränderlich gespeichert', 'Ein Klick von Klausel bis Codezeile', 'Signierte Attestierungen pro Build']
: ['Versioned evidence chain stored immutably', 'One-click drill from clause to line of code', 'Signed attestations per build'],
},
engine: {
tint: '#c084fc', icon: '◉',
kicker: de ? 'Under the Hood' : 'Under the Hood',
title: de ? 'Continuous Compliance Engine' : 'Continuous Compliance Engine',
body: de
? 'Statt punktueller Audits: Validierung bei jeder Änderung (Code, Infrastruktur, Prozesse) mit auditierbaren Nachweisen in Echtzeit.'
: 'Instead of point-in-time audits: validation on every change (code, infrastructure, processes) with auditable evidence in real time.',
bullets: de
? ['Rule-Packs pro Framework (NIS-2, DORA, …)', 'Verarbeitet Code, IaC und Prozess-Events', 'Findings automatisch ans richtige Team geroutet']
: ['Rule packs per framework (NIS-2, DORA, …)', 'Handles code, infra-as-code, and process events', 'Findings routed to the right team automatically'],
},
opt: {
tint: '#fbbf24', icon: '✦',
kicker: de ? 'Under the Hood' : 'Under the Hood',
title: de ? 'Compliance Optimizer' : 'Compliance Optimizer',
body: de
? 'Nicht nur „erlaubt/verboten", sondern die maximal zulässige Ausgestaltung jedes KI-Use-Cases. Deterministische Constraint-Optimierung zeigt den Sweet Spot zwischen Regulierung und Innovation — ersetzt 20200k EUR Anwaltskosten.'
: 'Not just "allowed/forbidden" but the maximum permissible configuration of every AI use case. Deterministic constraint optimization shows the sweet spot between regulation and innovation — replaces EUR 20200k in legal fees.',
bullets: de
? ['ROI-Ranking jedes offenen Findings', 'Abwägung zwischen Liefergeschwindigkeit und Restrisiko', 'Low-Hanging-Wins zuerst']
: ['ROI-ranks every open finding', 'Balances speed of delivery with residual risk', 'Highlights low-hanging wins first'],
},
stack: {
tint: '#f59e0b', icon: '◎',
kicker: de ? 'Under the Hood' : 'Under the Hood',
title: de ? 'EU-Trust & Governance Stack' : 'EU Trust & Governance Stack',
body: de
? 'Souveräne, DSGVO-/AI-Act-konforme Architektur (EU-Hosting, Isolation, Betriebsrat-Fähigkeit) — Marktzugang, den US-Lösungen strukturell nicht erreichen.'
: 'Sovereign, GDPR/AI Act compliant architecture (EU hosting, isolation, works council capability) — market access that US solutions structurally cannot achieve.',
bullets: de
? ['DSGVO · NIS-2 · DORA · EU AI Act · ISO 27001 · BSI C5', 'EU-souveränes Hosting und Key-Management', 'Eine Plattform, ein Audit, eine Rechnung']
: ['DSGVO · NIS-2 · DORA · EU AI Act · ISO 27001 · BSI C5', 'EU-sovereign hosting and key-management', 'One platform, one audit, one bill'],
},
hub: {
tint: '#a78bfa', icon: '∞',
kicker: de ? 'Die Schleife' : 'The Loop',
title: de ? 'Compliance ↔ Code · Immer in Sync' : 'Compliance ↔ Code · Always in sync',
body: de
? 'Die Plattform ist eine einzige geschlossene Schleife. Jede Policy-Änderung fliesst in den Code; jede Code-Änderung fliesst in die Policy zurück.'
: 'The platform is a single closed loop. Every policy change ripples into code; every code change ripples back into policy. That\'s the USP in one diagram.',
bullets: de
? ['Single Source of Truth, zwei Oberflächen', 'Echtzeit-Sync, kein Batch-Abgleich', 'Auditoren, Entwickler und Sales fragen denselben Graphen ab']
: ['Single source of truth, two surfaces', 'Real-time sync, not batch reconciliation', 'Auditors, engineers and sales all query the same graph'],
},
}
}
// ── Pillar row ────────────────────────────────────────────────────────────────
function PillarRow({ side, title, body, tint, onClick, active, isLight }: {
side: 'left' | 'right'
title: string; body: string; tint: string
onClick: () => void; active: boolean; isLight: boolean
}) {
const [hover, setHover] = useState(false)
const lit = hover || active
const isLeft = side === 'left'
return (
<div
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
display: 'flex', alignItems: 'flex-start', gap: 12,
flexDirection: isLeft ? 'row-reverse' : 'row',
textAlign: isLeft ? 'right' : 'left',
padding: '10px 14px', borderRadius: 10, cursor: 'pointer',
transition: 'transform .25s, background .25s, box-shadow .25s',
background: lit
? `linear-gradient(${isLeft ? '270deg' : '90deg'}, ${tint}24 0%, ${tint}0a 70%, transparent 100%)`
: 'transparent',
boxShadow: lit
? `0 10px 30px ${tint}26, inset 0 0 0 1px ${tint}44`
: 'inset 0 0 0 1px transparent',
transform: lit ? (isLeft ? 'translateX(-3px)' : 'translateX(3px)') : 'translateX(0)',
}}
>
<div style={{
flex: '0 0 30px', width: 30, height: 30, borderRadius: 9,
background: lit ? `${tint}3a` : `${tint}22`,
border: `1px solid ${lit ? tint : tint + '66'}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: lit ? (isLight ? tint : '#fff') : tint, fontSize: 13, fontWeight: 700, marginTop: 2,
boxShadow: lit ? `0 0 14px ${tint}88, inset 0 1px 0 ${tint}80` : `inset 0 1px 0 ${tint}50`,
transition: 'all .25s',
}}></div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 700,
color: isLight ? '#1a1a2e' : '#f7f5fc',
letterSpacing: -0.15, marginBottom: 3,
display: 'flex', alignItems: 'center', gap: 6,
justifyContent: isLeft ? 'flex-end' : 'flex-start',
}}>
<span>{title}</span>
<span style={{
fontSize: 10, color: tint, opacity: lit ? 1 : 0,
transform: `translateX(${lit ? 0 : (isLeft ? 4 : -4)}px)`,
transition: 'all .25s',
}}>{isLeft ? '' : ''}</span>
</div>
<div style={{
fontSize: 11, lineHeight: 1.55,
color: isLight
? `rgba(71,85,105,${lit ? 1 : .78})`
: `rgba(236,233,247,${lit ? .82 : .62})`,
transition: 'color .25s',
}}>{body}</div>
</div>
</div>
)
}
// ── Column header ─────────────────────────────────────────────────────────────
function ColHeader({ side, label, color, icon, sub, isLight }: {
side: 'left' | 'right'; label: string; color: string; icon: string; sub: string; isLight: boolean
}) {
const isLeft = side === 'left'
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
flexDirection: isLeft ? 'row-reverse' : 'row',
paddingBottom: 10, borderBottom: `1px solid ${color}35`,
}}>
<div style={{
width: 34, height: 34, borderRadius: 9,
background: `linear-gradient(135deg, ${color}55, ${color}20)`,
border: `1px solid ${color}88`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: isLight ? color : '#fff', fontSize: 15, fontWeight: 700,
boxShadow: `0 0 18px ${color}55, inset 0 1px 0 ${color}aa`,
}}>{icon}</div>
<div>
<div style={{ fontSize: 18, fontWeight: 700, color: isLight ? '#1a1a2e' : '#f7f5fc', letterSpacing: -0.3, lineHeight: 1 }}>{label}</div>
<div style={{ ...MONO, fontSize: 9.5, letterSpacing: 2, color, opacity: .75, marginTop: 3, textTransform: 'uppercase' as const }}>{sub}</div>
</div>
</div>
)
}
// ── Central hub ───────────────────────────────────────────────────────────────
function CentralHub({ caption, isLight }: { caption: string; isLight: boolean }) {
return (
<div style={{ position: 'relative', width: 260, height: 320, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{
position: 'relative', width: 120, height: 120, borderRadius: '50%',
background: 'radial-gradient(circle at 32% 28%, #f0e9ff 0%, #c4aaff 26%, #7b5cd6 58%, #2a1560 100%)',
border: '1.5px solid rgba(216,202,255,.7)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: isLight
? '0 0 30px rgba(167,139,250,.4), 0 0 60px rgba(167,139,250,.15), inset 0 3px 0 rgba(255,255,255,.5), inset 0 -8px 14px rgba(0,0,0,.2)'
: '0 0 50px rgba(167,139,250,.65), 0 0 100px rgba(167,139,250,.25), inset 0 3px 0 rgba(255,255,255,.35), inset 0 -8px 14px rgba(0,0,0,.35)',
animation: isLight ? 'uspPulseLight 2.6s ease-in-out infinite' : 'uspPulse 2.6s ease-in-out infinite',
zIndex: 3,
}}>
<div style={{ position: 'absolute', inset: -14, borderRadius: '50%', border: `1px dashed ${isLight ? 'rgba(167,139,250,.5)' : 'rgba(216,202,255,.42)'}`, animation: 'uspSpin 14s linear infinite' }} />
<div style={{ position: 'absolute', inset: -30, borderRadius: '50%', border: `1px dashed ${isLight ? 'rgba(167,139,250,.3)' : 'rgba(216,202,255,.2)'}`, animation: 'uspSpin 22s linear infinite reverse' }} />
<svg width="54" height="26" viewBox="0 0 54 26" fill="none" stroke="#fff" strokeWidth="2.8" strokeLinecap="round" strokeLinejoin="round"
style={{ filter: 'drop-shadow(0 1px 3px rgba(0,0,0,.5))' }}>
<path d="M 10 13 C 10 5, 22 5, 27 13 C 32 21, 44 21, 44 13 C 44 5, 32 5, 27 13 C 22 21, 10 21, 10 13 Z" />
</svg>
</div>
<div style={{
position: 'absolute', left: 0, right: 0, bottom: 24, textAlign: 'center',
...MONO, fontSize: 9.5, letterSpacing: 2.5,
color: isLight ? 'rgba(109,77,194,.75)' : 'rgba(216,202,255,.75)',
textTransform: 'uppercase' as const, fontWeight: 600,
}}>{caption}</div>
</div>
)
}
// ── Bridge SVG connectors ─────────────────────────────────────────────────────
function BridgeConnectors({ isLight }: { isLight: boolean }) {
const rfpY = 130
const sub2Y = 250
const hubCx = 500
const hubR = 72
return (
<svg viewBox="0 0 1000 400" preserveAspectRatio="none"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 1 }}>
<defs>
<linearGradient id="uspFromL" x1="0" x2="1">
<stop offset="0" stopColor="#a78bfa" stopOpacity="0" />
<stop offset=".3" stopColor="#a78bfa" stopOpacity={isLight ? '.6' : '.85'} />
<stop offset="1" stopColor="#c084fc" stopOpacity={isLight ? '.2' : '.3'} />
</linearGradient>
<linearGradient id="uspToR" x1="0" x2="1">
<stop offset="0" stopColor="#c084fc" stopOpacity={isLight ? '.2' : '.3'} />
<stop offset=".7" stopColor="#fbbf24" stopOpacity={isLight ? '.6' : '.85'} />
<stop offset="1" stopColor="#fbbf24" stopOpacity="0" />
</linearGradient>
</defs>
<line x1="40" y1={rfpY} x2={hubCx - hubR} y2={rfpY}
stroke="url(#uspFromL)" strokeWidth="2" strokeDasharray="4 5"
style={{ animation: 'uspFlowR 1.6s linear infinite' }} />
<line x1={hubCx + hubR} y1={rfpY} x2="960" y2={rfpY}
stroke="url(#uspToR)" strokeWidth="2" strokeDasharray="4 5"
style={{ animation: 'uspFlowR 1.6s linear infinite' }} />
<line x1="40" y1={sub2Y} x2={hubCx - hubR} y2={sub2Y}
stroke="url(#uspFromL)" strokeWidth="2" strokeDasharray="4 5"
style={{ animation: 'uspFlowR 1.6s linear infinite' }} />
<line x1={hubCx + hubR} y1={sub2Y} x2="960" y2={sub2Y}
stroke="url(#uspToR)" strokeWidth="2" strokeDasharray="4 5"
style={{ animation: 'uspFlowR 1.6s linear infinite' }} />
{([rfpY, sub2Y] as number[]).map(y => (
<g key={y}>
<circle cx={hubCx - hubR} cy={y} r="4" fill={isLight ? '#eef2ff' : '#1a0f34'} stroke="#a78bfa" strokeWidth="1.2" />
<circle cx={hubCx - hubR} cy={y} r="1.5" fill="#a78bfa" />
<circle cx={hubCx + hubR} cy={y} r="4" fill={isLight ? '#eef2ff' : '#1a0f34'} stroke="#fbbf24" strokeWidth="1.2" />
<circle cx={hubCx + hubR} cy={y} r="1.5" fill="#fbbf24" />
</g>
))}
<circle r="3" fill="#c4aaff" style={{ filter: 'drop-shadow(0 0 6px #a78bfa)' }}>
<animate attributeName="cx" from="40" to="960" dur="3.5s" repeatCount="indefinite" />
<animate attributeName="cy" values={`${rfpY};${rfpY}`} dur="3.5s" repeatCount="indefinite" />
</circle>
<circle r="3" fill="#fde68a" style={{ filter: 'drop-shadow(0 0 6px #fbbf24)' }}>
<animate attributeName="cx" from="960" to="40" dur="3.5s" repeatCount="indefinite" />
<animate attributeName="cy" values={`${sub2Y};${sub2Y}`} dur="3.5s" repeatCount="indefinite" />
</circle>
</svg>
)
}
// ── Under-the-hood feature card ───────────────────────────────────────────────
function FeatureCard({ icon, title, body, tint, Ticker, onClick, active, isLight }: {
icon: string; title: string; body: string; tint: string
Ticker: React.ComponentType<{ tint: string; isLight: boolean }>
onClick: () => void; active: boolean; isLight: boolean
}) {
const [hover, setHover] = useState(false)
const lit = hover || active
return (
<div
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
position: 'relative', padding: '13px 15px',
background: isLight
? lit
? `linear-gradient(180deg, ${tint}18 0%, ${tint}08 55%, rgba(248,250,252,.95) 100%)`
: 'linear-gradient(180deg, #ffffff, #f8fafc)'
: `linear-gradient(180deg, ${tint}${lit ? '2a' : '1a'} 0%, ${tint}07 55%, rgba(14,8,28,.85) 100%)`,
border: `1px solid ${lit ? tint : isLight ? 'rgba(0,0,0,.1)' : tint + '4a'}`,
borderRadius: 12,
boxShadow: lit
? `0 18px 40px ${tint}33, 0 0 0 1px ${tint}66, inset 0 1px 0 ${tint}60`
: isLight
? '0 2px 8px rgba(0,0,0,.08), inset 0 1px 0 rgba(255,255,255,.8)'
: `0 10px 24px rgba(0,0,0,.4), inset 0 1px 0 ${tint}35`,
minWidth: 0, cursor: 'pointer',
transform: lit ? 'translateY(-3px)' : 'translateY(0)',
transition: 'transform .25s, box-shadow .25s, background .25s, border-color .25s',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{
width: 22, height: 22, borderRadius: 6,
background: lit ? `${tint}44` : `${tint}22`,
border: `1px solid ${lit ? tint : tint + '66'}`,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
color: lit ? (isLight ? tint : '#fff') : tint, fontSize: 12,
boxShadow: lit ? `0 0 12px ${tint}88` : 'none',
transition: 'all .25s',
}}>{icon}</span>
<span style={{ fontSize: 12.5, fontWeight: 700, color: isLight ? '#1a1a2e' : '#f7f5fc', letterSpacing: -0.15, flex: 1 }}>{title}</span>
<span style={{ fontSize: 10, color: tint, opacity: lit ? 1 : 0.5, transform: `translateX(${lit ? 0 : -3}px)`, transition: 'all .25s' }}></span>
</div>
<div style={{
fontSize: 11, lineHeight: 1.45,
color: isLight
? `rgba(71,85,105,${lit ? 1 : .78})`
: `rgba(236,233,247,${lit ? .82 : .65})`,
transition: 'color .25s',
}}>{body}</div>
<Ticker tint={tint} isLight={isLight} />
</div>
)
}
// ── Detail modal ──────────────────────────────────────────────────────────────
function DetailModal({ item, onClose, isLight }: { item: DetailItem | null; onClose: () => void; isLight: boolean }) {
useEffect(() => {
if (!item) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [item, onClose])
return (
<AnimatePresence>
{item && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={onClose}
style={{
position: 'absolute', inset: 0, zIndex: 50,
background: isLight ? 'rgba(240,244,255,.72)' : 'rgba(5,2,16,.72)',
backdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.94 }}
transition={{ duration: 0.22 }}
onClick={e => e.stopPropagation()}
style={{
width: 560, maxWidth: '88%',
background: isLight
? `linear-gradient(180deg, ${item.tint}10 0%, rgba(255,255,255,.98) 50%, rgba(248,250,252,.99) 100%)`
: `linear-gradient(180deg, ${item.tint}18 0%, rgba(20,10,40,.96) 50%, rgba(14,8,28,.98) 100%)`,
border: `1px solid ${item.tint}${isLight ? '44' : '66'}`,
borderRadius: 16,
boxShadow: isLight
? `0 20px 60px rgba(0,0,0,.12), 0 0 40px ${item.tint}18, inset 0 1px 0 rgba(255,255,255,.9)`
: `0 30px 80px rgba(0,0,0,.6), 0 0 60px ${item.tint}33, inset 0 1px 0 ${item.tint}55`,
padding: '22px 26px',
color: isLight ? '#1a1a2e' : '#ece9f7',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
<div style={{
width: 38, height: 38, borderRadius: 10,
background: `linear-gradient(135deg, ${item.tint}66, ${item.tint}22)`,
border: `1px solid ${item.tint}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: isLight ? item.tint : '#fff', fontSize: 16, fontWeight: 700,
boxShadow: `0 0 18px ${item.tint}66`,
}}>{item.icon}</div>
<div style={{ flex: 1 }}>
<div style={{ ...MONO, fontSize: 9.5, letterSpacing: 2.5, color: item.tint, textTransform: 'uppercase' as const, fontWeight: 600, marginBottom: 2 }}>
{item.kicker}
</div>
<div style={{ fontSize: 19, fontWeight: 700, color: isLight ? '#1a1a2e' : '#f7f5fc', letterSpacing: -0.3 }}>{item.title}</div>
</div>
<button onClick={onClose} style={{
background: 'transparent', border: `1px solid ${item.tint}55`,
borderRadius: 8, cursor: 'pointer', width: 30, height: 30,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: isLight ? '#64748b' : 'rgba(236,233,247,.6)',
}}>
<X style={{ width: 14, height: 14 }} />
</button>
</div>
<div style={{ fontSize: 13, lineHeight: 1.6, color: isLight ? '#475569' : 'rgba(236,233,247,.82)', marginBottom: 16 }}>
{item.body}
</div>
{item.bullets && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
{item.bullets.map((b, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'flex-start', gap: 10,
padding: '8px 12px', borderRadius: 8,
background: isLight ? 'rgba(0,0,0,.04)' : 'rgba(0,0,0,.3)',
border: `1px solid ${item.tint}${isLight ? '22' : '33'}`,
}}>
<span style={{ color: item.tint, fontSize: 12, marginTop: 1 }}></span>
<span style={{ fontSize: 12, lineHeight: 1.5, color: isLight ? '#475569' : 'rgba(236,233,247,.78)' }}>{b}</span>
</div>
))}
</div>
)}
{item.stat && (
<div style={{
...MONO, padding: '10px 14px', borderRadius: 8,
background: isLight ? 'rgba(0,0,0,.04)' : 'rgba(0,0,0,.45)',
border: `1px solid ${item.tint}${isLight ? '33' : '55'}`,
fontSize: 12, color: isLight ? '#475569' : 'rgba(236,233,247,.9)',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{ color: isLight ? '#16a34a' : '#4ade80' }}></span>
<span style={{ color: item.tint }}>{item.stat.k}</span>
<span style={{ color: isLight ? '#1a1a2e' : '#f5f3fc', fontWeight: 600 }}>{item.stat.v}</span>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
// ── Star field ────────────────────────────────────────────────────────────────
function StarField({ isLight }: { isLight: boolean }) {
const stars = useMemo(() => {
let s = 41
const r = () => { s = (s * 9301 + 49297) % 233280; return s / 233280 }
return Array.from({ length: 90 }, () => ({ x: r() * 100, y: r() * 100, size: r() * 1.4 + 0.3, op: r() * 0.5 + 0.15 }))
}, [])
if (isLight) return null
return (
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
{stars.map((st, i) => (
<div key={i} style={{
position: 'absolute', left: `${st.x}%`, top: `${st.y}%`,
width: st.size, height: st.size, borderRadius: '50%',
background: '#fff', opacity: st.op,
boxShadow: `0 0 ${st.size * 3}px rgba(180,160,255,.7)`,
}} />
))}
</div>
)
}
// ── Main slide ────────────────────────────────────────────────────────────────
export default function USPSlide({ lang }: USPSlideProps) {
const de = lang === 'de'
const isLight = useIsLight()

View File

@@ -0,0 +1,259 @@
'use client'
import { useEffect } from 'react'
import { ChevronDown, ChevronRight, Minimize2 } from 'lucide-react'
import {
AnnualRow,
MonthlyRow,
AccountingStandard,
fmt,
fmtMonth,
getLineItems,
MONTH_NAMES_DE,
MONTH_NAMES_EN,
} from './AnnualPLTable.types'
interface AnnualTableProps {
rows: AnnualRow[]
lang: 'de' | 'en'
expandedYear: number | null
onToggleYear: (year: number) => void
monthlyData: Map<number, MonthlyRow[]>
isFullscreen: boolean
standard: AccountingStandard
}
export function AnnualTable({
rows,
lang,
expandedYear,
onToggleYear,
monthlyData,
isFullscreen,
standard,
}: AnnualTableProps) {
const de = lang === 'de'
const monthNames = de ? MONTH_NAMES_DE : MONTH_NAMES_EN
const lineItems = getLineItems(lang, standard)
const monthlyExtraItems: { label: string; key: keyof MonthlyRow; isBold?: boolean }[] = isFullscreen ? [
{ label: 'MRR', key: 'mrr', isBold: true },
{ label: de ? 'Cash-Bestand' : 'Cash Balance', key: 'cashBalance', isBold: true },
] : []
const textSize = isFullscreen ? 'text-xs' : 'text-[11px]'
const minColWidth = isFullscreen ? 'min-w-[70px]' : 'min-w-[80px]'
return (
<table className={`w-full ${textSize}`}>
<thead>
<tr className="border-b border-white/10">
<th className={`text-left py-2 pr-4 text-white/40 font-medium ${isFullscreen ? 'min-w-[220px]' : 'min-w-[180px]'}`}>
{standard === 'hgb'
? (de ? 'GuV-Position (HGB)' : 'P&L Line Item (HGB)')
: (de ? 'GuV-Position (US GAAP)' : 'P&L Line Item (US GAAP)')
}
</th>
{rows.map(r => (
<th
key={r.year}
className={`text-right py-2 px-2 text-white/50 font-semibold ${minColWidth} cursor-pointer hover:text-indigo-400 transition-colors`}
onClick={() => onToggleYear(r.year)}
title={de ? 'Klicken fuer Monatsdetails' : 'Click for monthly details'}
>
<span className="inline-flex items-center gap-1">
{expandedYear === r.year ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3 opacity-30" />
)}
{r.year}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{lineItems.map((item) => (
<tr
key={item.key}
className={item.isSeparator ? 'border-t border-white/10' : ''}
>
<td className={`py-1.5 pr-4 ${item.isBold ? 'text-white font-semibold' : 'text-white/50'} ${item.isPercent ? 'italic text-white/30' : ''}`}>
{item.label}
</td>
{rows.map(r => {
const val = r[item.key] as number
return (
<td
key={r.year}
className={`text-right py-1.5 px-2 font-mono
${item.isBold ? 'font-semibold' : ''}
${item.isPercent ? 'text-white/30 italic' : ''}
${!item.isPercent && val < 0 ? 'text-red-400/80' : ''}
${!item.isPercent && val > 0 && item.key === 'ebitda' ? 'text-emerald-400' : ''}
${!item.isPercent && !item.isBold ? 'text-white/60' : 'text-white'}
`}
>
{item.isPercent
? `${val.toFixed(1)}%`
: (item.isNegative && val > 0 ? '-' : '') + fmt(Math.abs(val))
}
</td>
)
})}
</tr>
))}
</tbody>
{/* Monthly Drill-Down */}
{expandedYear && monthlyData.has(expandedYear) && (
<tbody>
<tr>
<td colSpan={rows.length + 1} className="pt-4 pb-1">
<div className="border-t border-indigo-500/30 pt-3">
<p className="text-xs font-semibold text-indigo-400 mb-2">
{de ? `Monatsdetails ${expandedYear}` : `Monthly Details ${expandedYear}`}
</p>
</div>
</td>
</tr>
<tr className="border-b border-white/10">
<td className="text-left py-1 pr-4 text-white/40 font-medium text-[10px]">
{de ? 'Monat' : 'Month'}
</td>
{monthlyData.get(expandedYear)!.map(m => (
<td key={m.monthInYear} className="text-right py-1 px-1 text-white/40 font-medium text-[10px]">
{monthNames[m.monthInYear - 1]}
</td>
))}
</tr>
{lineItems.map((item) => {
const mKey = item.monthKey
if (!mKey) return null
return (
<tr key={`monthly-${item.key}`} className={item.isSeparator ? 'border-t border-white/5' : ''}>
<td className={`py-1 pr-4 text-[10px] ${item.isBold ? 'text-white/70 font-medium' : 'text-white/30'} ${item.isPercent ? 'italic' : ''}`}>
{item.label}
</td>
{monthlyData.get(expandedYear)!.map(m => {
const val = m[mKey] as number
return (
<td
key={m.monthInYear}
className={`text-right py-1 px-1 font-mono text-[10px]
${item.isPercent ? 'text-white/20 italic' : ''}
${!item.isPercent && val < 0 ? 'text-red-400/60' : ''}
${!item.isPercent && val > 0 && item.key === 'ebitda' ? 'text-emerald-400/70' : ''}
${!item.isPercent ? 'text-white/40' : ''}
`}
>
{item.isPercent
? `${val.toFixed(0)}%`
: (item.isNegative && val > 0 ? '-' : '') + fmtMonth(Math.abs(val))
}
</td>
)
})}
</tr>
)
})}
{monthlyExtraItems.map((item) => (
<tr key={`monthly-extra-${item.key}`} className="border-t border-white/5">
<td className="py-1 pr-4 text-[10px] text-indigo-300/70 font-medium">{item.label}</td>
{monthlyData.get(expandedYear)!.map(m => {
const val = m[item.key] as number
return (
<td key={m.monthInYear} className="text-right py-1 px-1 font-mono text-[10px] text-indigo-300/50">
{fmtMonth(Math.round(val))}
</td>
)
})}
</tr>
))}
</tbody>
)}
</table>
)
}
interface FullscreenOverlayProps {
children: React.ReactNode
onClose: () => void
lang: 'de' | 'en'
standard: AccountingStandard
onStandardChange: (s: AccountingStandard) => void
}
export function FullscreenOverlay({
children,
onClose,
lang,
standard,
onStandardChange,
}: FullscreenOverlayProps) {
const de = lang === 'de'
// ESC to close
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onClose])
return (
<div className="fixed inset-0 z-[9999] bg-slate-950/98 backdrop-blur-xl overflow-auto p-6">
<div className="max-w-[1400px] mx-auto">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-bold text-white">
{de ? 'Gewinn- und Verlustrechnung' : 'Profit & Loss Statement'}
</h2>
<p className="text-xs text-white/40">
{de
? 'Klicke auf ein Jahr um die Monatsdetails zu sehen · ESC zum Schliessen'
: 'Click on a year to see monthly details · ESC to close'}
</p>
</div>
<div className="flex items-center gap-3">
{/* HGB / US GAAP Toggle */}
<div className="flex items-center bg-white/[0.06] border border-white/10 rounded-xl overflow-hidden">
<button
onClick={() => onStandardChange('hgb')}
className={`px-3 py-1.5 text-xs font-medium transition-all ${
standard === 'hgb'
? 'bg-indigo-500/20 text-indigo-300'
: 'text-white/40 hover:text-white/60'
}`}
>
HGB
</button>
<button
onClick={() => onStandardChange('usgaap')}
className={`px-3 py-1.5 text-xs font-medium transition-all ${
standard === 'usgaap'
? 'bg-indigo-500/20 text-indigo-300'
: 'text-white/40 hover:text-white/60'
}`}
>
US GAAP
</button>
</div>
<button
onClick={onClose}
className="flex items-center gap-2 px-3 py-2 bg-white/[0.08] hover:bg-white/[0.12] border border-white/10 rounded-xl text-sm text-white/70 hover:text-white transition-all"
>
<Minimize2 className="w-4 h-4" />
{de ? 'Schliessen' : 'Close'}
</button>
</div>
</div>
<div className="bg-white/[0.03] border border-white/10 rounded-2xl p-6 overflow-x-auto">
{children}
</div>
</div>
</div>
)
}

View File

@@ -3,410 +3,15 @@
import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { motion } from 'framer-motion'
import { Maximize2 } from 'lucide-react'
import { FMResult } from '@/lib/types'
import { Maximize2, Minimize2, ChevronDown, ChevronRight } from 'lucide-react'
interface AnnualPLTableProps {
results: FMResult[]
lang: 'de' | 'en'
}
type AccountingStandard = 'hgb' | 'usgaap'
interface AnnualRow {
year: number
revenue: number
cogs: number
grossProfit: number
grossMarginPct: number
personnel: number
marketing: number
infra: number
totalOpex: number
ebitda: number
ebitdaMarginPct: number
customers: number
employees: number
}
interface MonthlyRow {
month: number
monthInYear: number
revenue: number
cogs: number
grossProfit: number
grossMarginPct: number
personnel: number
marketing: number
infra: number
totalCosts: number
ebitda: number
ebitdaMarginPct: number
customers: number
employees: number
mrr: number
cashBalance: number
}
function fmt(v: number): string {
if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(0)}k`
return Math.round(v).toLocaleString('de-DE')
}
function fmtMonth(v: number): string {
return Math.round(v).toLocaleString('de-DE')
}
const MONTH_NAMES_DE = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
const MONTH_NAMES_EN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
function getLineItems(lang: 'de' | 'en', standard: AccountingStandard) {
const de = lang === 'de'
const hgb = standard === 'hgb'
return [
{
label: hgb
? (de ? 'Umsatzerloese' : 'Revenue (Umsatzerloese)')
: (de ? 'Revenue' : 'Revenue'),
key: 'revenue' as keyof AnnualRow,
monthKey: 'revenue' as keyof MonthlyRow,
isBold: true,
},
{
label: hgb
? (de ? '- Herstellungskosten' : '- Cost of Production')
: (de ? '- COGS' : '- Cost of Goods Sold'),
key: 'cogs' as keyof AnnualRow,
monthKey: 'cogs' as keyof MonthlyRow,
isNegative: true,
},
{
label: hgb
? (de ? '= Rohertrag' : '= Gross Profit')
: (de ? '= Gross Profit' : '= Gross Profit'),
key: 'grossProfit' as keyof AnnualRow,
monthKey: 'grossProfit' as keyof MonthlyRow,
isBold: true,
isSeparator: true,
},
{
label: hgb
? (de ? ' Rohertragsmarge' : ' Gross Margin')
: (de ? ' Gross Margin' : ' Gross Margin'),
key: 'grossMarginPct' as keyof AnnualRow,
monthKey: 'grossMarginPct' as keyof MonthlyRow,
isPercent: true,
},
{
label: hgb
? (de ? '- Personalaufwand' : '- Personnel Expenses')
: (de ? '- Personnel' : '- Personnel'),
key: 'personnel' as keyof AnnualRow,
monthKey: 'personnel' as keyof MonthlyRow,
isNegative: true,
},
{
label: hgb
? (de ? '- Vertrieb & Marketing' : '- Sales & Marketing')
: (de ? '- Sales & Marketing' : '- Sales & Marketing'),
key: 'marketing' as keyof AnnualRow,
monthKey: 'marketing' as keyof MonthlyRow,
isNegative: true,
},
{
label: hgb
? (de ? '- sonstige betriebl. Aufwendungen' : '- Other Operating Expenses')
: (de ? '- Infrastructure' : '- Infrastructure'),
key: 'infra' as keyof AnnualRow,
monthKey: 'infra' as keyof MonthlyRow,
isNegative: true,
},
{
label: hgb
? (de ? '= Betriebsaufwand gesamt' : '= Total Operating Expenses')
: (de ? '= Total OpEx' : '= Total OpEx'),
key: 'totalOpex' as keyof AnnualRow,
monthKey: 'totalCosts' as keyof MonthlyRow,
isBold: true,
isSeparator: true,
isNegative: true,
},
{
label: hgb
? (de ? 'Betriebsergebnis (EBITDA)' : 'Operating Result (EBITDA)')
: 'EBITDA',
key: 'ebitda' as keyof AnnualRow,
monthKey: 'ebitda' as keyof MonthlyRow,
isBold: true,
isSeparator: true,
},
{
label: hgb
? (de ? ' EBITDA-Marge' : ' EBITDA Margin')
: (de ? ' EBITDA Margin' : ' EBITDA Margin'),
key: 'ebitdaMarginPct' as keyof AnnualRow,
monthKey: 'ebitdaMarginPct' as keyof MonthlyRow,
isPercent: true,
},
{
label: hgb
? (de ? 'Kunden (Stichtag)' : 'Customers (Reporting Date)')
: (de ? 'Kunden (Jahresende)' : 'Customers (Year End)'),
key: 'customers' as keyof AnnualRow,
monthKey: 'customers' as keyof MonthlyRow,
},
{
label: hgb
? (de ? 'Mitarbeiter (VZAe)' : 'Employees (FTE)')
: (de ? 'Mitarbeiter' : 'Employees'),
key: 'employees' as keyof AnnualRow,
monthKey: 'employees' as keyof MonthlyRow,
},
]
}
function AnnualTable({
rows,
lang,
expandedYear,
onToggleYear,
monthlyData,
isFullscreen,
standard,
}: {
rows: AnnualRow[]
lang: 'de' | 'en'
expandedYear: number | null
onToggleYear: (year: number) => void
monthlyData: Map<number, MonthlyRow[]>
isFullscreen: boolean
standard: AccountingStandard
}) {
const de = lang === 'de'
const monthNames = de ? MONTH_NAMES_DE : MONTH_NAMES_EN
const lineItems = getLineItems(lang, standard)
const monthlyExtraItems: { label: string; key: keyof MonthlyRow; isBold?: boolean }[] = isFullscreen ? [
{ label: 'MRR', key: 'mrr', isBold: true },
{ label: de ? 'Cash-Bestand' : 'Cash Balance', key: 'cashBalance', isBold: true },
] : []
const textSize = isFullscreen ? 'text-xs' : 'text-[11px]'
const minColWidth = isFullscreen ? 'min-w-[70px]' : 'min-w-[80px]'
return (
<table className={`w-full ${textSize}`}>
<thead>
<tr className="border-b border-white/10">
<th className={`text-left py-2 pr-4 text-white/40 font-medium ${isFullscreen ? 'min-w-[220px]' : 'min-w-[180px]'}`}>
{standard === 'hgb'
? (de ? 'GuV-Position (HGB)' : 'P&L Line Item (HGB)')
: (de ? 'GuV-Position (US GAAP)' : 'P&L Line Item (US GAAP)')
}
</th>
{rows.map(r => (
<th
key={r.year}
className={`text-right py-2 px-2 text-white/50 font-semibold ${minColWidth} cursor-pointer hover:text-indigo-400 transition-colors`}
onClick={() => onToggleYear(r.year)}
title={de ? 'Klicken fuer Monatsdetails' : 'Click for monthly details'}
>
<span className="inline-flex items-center gap-1">
{expandedYear === r.year ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3 opacity-30" />
)}
{r.year}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{lineItems.map((item) => (
<tr
key={item.key}
className={item.isSeparator ? 'border-t border-white/10' : ''}
>
<td className={`py-1.5 pr-4 ${item.isBold ? 'text-white font-semibold' : 'text-white/50'} ${item.isPercent ? 'italic text-white/30' : ''}`}>
{item.label}
</td>
{rows.map(r => {
const val = r[item.key] as number
return (
<td
key={r.year}
className={`text-right py-1.5 px-2 font-mono
${item.isBold ? 'font-semibold' : ''}
${item.isPercent ? 'text-white/30 italic' : ''}
${!item.isPercent && val < 0 ? 'text-red-400/80' : ''}
${!item.isPercent && val > 0 && item.key === 'ebitda' ? 'text-emerald-400' : ''}
${!item.isPercent && !item.isBold ? 'text-white/60' : 'text-white'}
`}
>
{item.isPercent
? `${val.toFixed(1)}%`
: (item.isNegative && val > 0 ? '-' : '') + fmt(Math.abs(val))
}
</td>
)
})}
</tr>
))}
</tbody>
{/* Monthly Drill-Down */}
{expandedYear && monthlyData.has(expandedYear) && (
<tbody>
<tr>
<td colSpan={rows.length + 1} className="pt-4 pb-1">
<div className="border-t border-indigo-500/30 pt-3">
<p className="text-xs font-semibold text-indigo-400 mb-2">
{de ? `Monatsdetails ${expandedYear}` : `Monthly Details ${expandedYear}`}
</p>
</div>
</td>
</tr>
<tr className="border-b border-white/10">
<td className="text-left py-1 pr-4 text-white/40 font-medium text-[10px]">
{de ? 'Monat' : 'Month'}
</td>
{monthlyData.get(expandedYear)!.map(m => (
<td key={m.monthInYear} className="text-right py-1 px-1 text-white/40 font-medium text-[10px]">
{monthNames[m.monthInYear - 1]}
</td>
))}
</tr>
{lineItems.map((item) => {
const mKey = item.monthKey
if (!mKey) return null
return (
<tr key={`monthly-${item.key}`} className={item.isSeparator ? 'border-t border-white/5' : ''}>
<td className={`py-1 pr-4 text-[10px] ${item.isBold ? 'text-white/70 font-medium' : 'text-white/30'} ${item.isPercent ? 'italic' : ''}`}>
{item.label}
</td>
{monthlyData.get(expandedYear)!.map(m => {
const val = m[mKey] as number
return (
<td
key={m.monthInYear}
className={`text-right py-1 px-1 font-mono text-[10px]
${item.isPercent ? 'text-white/20 italic' : ''}
${!item.isPercent && val < 0 ? 'text-red-400/60' : ''}
${!item.isPercent && val > 0 && item.key === 'ebitda' ? 'text-emerald-400/70' : ''}
${!item.isPercent ? 'text-white/40' : ''}
`}
>
{item.isPercent
? `${val.toFixed(0)}%`
: (item.isNegative && val > 0 ? '-' : '') + fmtMonth(Math.abs(val))
}
</td>
)
})}
</tr>
)
})}
{monthlyExtraItems.map((item) => (
<tr key={`monthly-extra-${item.key}`} className="border-t border-white/5">
<td className="py-1 pr-4 text-[10px] text-indigo-300/70 font-medium">{item.label}</td>
{monthlyData.get(expandedYear)!.map(m => {
const val = m[item.key] as number
return (
<td key={m.monthInYear} className="text-right py-1 px-1 font-mono text-[10px] text-indigo-300/50">
{fmtMonth(Math.round(val))}
</td>
)
})}
</tr>
))}
</tbody>
)}
</table>
)
}
function FullscreenOverlay({
children,
onClose,
lang,
standard,
onStandardChange,
}: {
children: React.ReactNode
onClose: () => void
lang: 'de' | 'en'
standard: AccountingStandard
onStandardChange: (s: AccountingStandard) => void
}) {
const de = lang === 'de'
// ESC to close
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onClose])
return (
<div className="fixed inset-0 z-[9999] bg-slate-950/98 backdrop-blur-xl overflow-auto p-6">
<div className="max-w-[1400px] mx-auto">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-bold text-white">
{de ? 'Gewinn- und Verlustrechnung' : 'Profit & Loss Statement'}
</h2>
<p className="text-xs text-white/40">
{de
? 'Klicke auf ein Jahr um die Monatsdetails zu sehen · ESC zum Schliessen'
: 'Click on a year to see monthly details · ESC to close'}
</p>
</div>
<div className="flex items-center gap-3">
{/* HGB / US GAAP Toggle */}
<div className="flex items-center bg-white/[0.06] border border-white/10 rounded-xl overflow-hidden">
<button
onClick={() => onStandardChange('hgb')}
className={`px-3 py-1.5 text-xs font-medium transition-all ${
standard === 'hgb'
? 'bg-indigo-500/20 text-indigo-300'
: 'text-white/40 hover:text-white/60'
}`}
>
HGB
</button>
<button
onClick={() => onStandardChange('usgaap')}
className={`px-3 py-1.5 text-xs font-medium transition-all ${
standard === 'usgaap'
? 'bg-indigo-500/20 text-indigo-300'
: 'text-white/40 hover:text-white/60'
}`}
>
US GAAP
</button>
</div>
<button
onClick={onClose}
className="flex items-center gap-2 px-3 py-2 bg-white/[0.08] hover:bg-white/[0.12] border border-white/10 rounded-xl text-sm text-white/70 hover:text-white transition-all"
>
<Minimize2 className="w-4 h-4" />
{de ? 'Schliessen' : 'Close'}
</button>
</div>
</div>
<div className="bg-white/[0.03] border border-white/10 rounded-2xl p-6 overflow-x-auto">
{children}
</div>
</div>
</div>
)
}
import {
AnnualPLTableProps,
AnnualRow,
MonthlyRow,
AccountingStandard,
} from './AnnualPLTable.types'
import { AnnualTable, FullscreenOverlay } from './AnnualPLTable.parts'
export default function AnnualPLTable({ results, lang }: AnnualPLTableProps) {
const [isFullscreen, setIsFullscreen] = useState(false)

View File

@@ -0,0 +1,172 @@
import { FMResult } from '@/lib/types'
export interface AnnualPLTableProps {
results: FMResult[]
lang: 'de' | 'en'
}
export type AccountingStandard = 'hgb' | 'usgaap'
export interface AnnualRow {
year: number
revenue: number
cogs: number
grossProfit: number
grossMarginPct: number
personnel: number
marketing: number
infra: number
totalOpex: number
ebitda: number
ebitdaMarginPct: number
customers: number
employees: number
}
export interface MonthlyRow {
month: number
monthInYear: number
revenue: number
cogs: number
grossProfit: number
grossMarginPct: number
personnel: number
marketing: number
infra: number
totalCosts: number
ebitda: number
ebitdaMarginPct: number
customers: number
employees: number
mrr: number
cashBalance: number
}
export interface LineItem {
label: string
key: keyof AnnualRow
monthKey: keyof MonthlyRow
isBold?: boolean
isNegative?: boolean
isSeparator?: boolean
isPercent?: boolean
}
export function fmt(v: number): string {
if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
if (Math.abs(v) >= 1_000) return `${(v / 1_000).toFixed(0)}k`
return Math.round(v).toLocaleString('de-DE')
}
export function fmtMonth(v: number): string {
return Math.round(v).toLocaleString('de-DE')
}
export const MONTH_NAMES_DE = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
export const MONTH_NAMES_EN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
export function getLineItems(lang: 'de' | 'en', standard: AccountingStandard): LineItem[] {
const de = lang === 'de'
const hgb = standard === 'hgb'
return [
{
label: hgb
? (de ? 'Umsatzerloese' : 'Revenue (Umsatzerloese)')
: (de ? 'Revenue' : 'Revenue'),
key: 'revenue' as keyof AnnualRow,
monthKey: 'revenue' as keyof MonthlyRow,
isBold: true,
},
{
label: hgb
? (de ? '- Herstellungskosten' : '- Cost of Production')
: (de ? '- COGS' : '- Cost of Goods Sold'),
key: 'cogs' as keyof AnnualRow,
monthKey: 'cogs' as keyof MonthlyRow,
isNegative: true,
},
{
label: hgb
? (de ? '= Rohertrag' : '= Gross Profit')
: (de ? '= Gross Profit' : '= Gross Profit'),
key: 'grossProfit' as keyof AnnualRow,
monthKey: 'grossProfit' as keyof MonthlyRow,
isBold: true,
isSeparator: true,
},
{
label: hgb
? (de ? ' Rohertragsmarge' : ' Gross Margin')
: (de ? ' Gross Margin' : ' Gross Margin'),
key: 'grossMarginPct' as keyof AnnualRow,
monthKey: 'grossMarginPct' as keyof MonthlyRow,
isPercent: true,
},
{
label: hgb
? (de ? '- Personalaufwand' : '- Personnel Expenses')
: (de ? '- Personnel' : '- Personnel'),
key: 'personnel' as keyof AnnualRow,
monthKey: 'personnel' as keyof MonthlyRow,
isNegative: true,
},
{
label: hgb
? (de ? '- Vertrieb & Marketing' : '- Sales & Marketing')
: (de ? '- Sales & Marketing' : '- Sales & Marketing'),
key: 'marketing' as keyof AnnualRow,
monthKey: 'marketing' as keyof MonthlyRow,
isNegative: true,
},
{
label: hgb
? (de ? '- sonstige betriebl. Aufwendungen' : '- Other Operating Expenses')
: (de ? '- Infrastructure' : '- Infrastructure'),
key: 'infra' as keyof AnnualRow,
monthKey: 'infra' as keyof MonthlyRow,
isNegative: true,
},
{
label: hgb
? (de ? '= Betriebsaufwand gesamt' : '= Total Operating Expenses')
: (de ? '= Total OpEx' : '= Total OpEx'),
key: 'totalOpex' as keyof AnnualRow,
monthKey: 'totalCosts' as keyof MonthlyRow,
isBold: true,
isSeparator: true,
isNegative: true,
},
{
label: hgb
? (de ? 'Betriebsergebnis (EBITDA)' : 'Operating Result (EBITDA)')
: 'EBITDA',
key: 'ebitda' as keyof AnnualRow,
monthKey: 'ebitda' as keyof MonthlyRow,
isBold: true,
isSeparator: true,
},
{
label: hgb
? (de ? ' EBITDA-Marge' : ' EBITDA Margin')
: (de ? ' EBITDA Margin' : ' EBITDA Margin'),
key: 'ebitdaMarginPct' as keyof AnnualRow,
monthKey: 'ebitdaMarginPct' as keyof MonthlyRow,
isPercent: true,
},
{
label: hgb
? (de ? 'Kunden (Stichtag)' : 'Customers (Reporting Date)')
: (de ? 'Kunden (Jahresende)' : 'Customers (Year End)'),
key: 'customers' as keyof AnnualRow,
monthKey: 'customers' as keyof MonthlyRow,
},
{
label: hgb
? (de ? 'Mitarbeiter (VZAe)' : 'Employees (FTE)')
: (de ? 'Mitarbeiter' : 'Employees'),
key: 'employees' as keyof AnnualRow,
monthKey: 'employees' as keyof MonthlyRow,
},
]
}

View File

@@ -0,0 +1,160 @@
/**
* Betriebliche Aufwendungen — formula-based rows + category sums
*
* Computes formula-driven operating expenses (Fortbildung, Reisekosten, etc.),
* Gewerbesteuer, category sums, and Gesamtkosten.
*/
import { Pool } from 'pg'
import {
MonthlyValues, MONTHS, FOUNDING_MONTH,
emptyMonthly,
FPBetrieblicheAufwendungen,
} from './types'
import { sumRows } from './engine-sheets'
export interface BetriebContext {
totalBrutto: MonthlyValues
totalPersonal: MonthlyValues
totalAfa: MonthlyValues
totalRevenue: MonthlyValues
totalMaterial: MonthlyValues
headcount: MonthlyValues
totalBestandskunden: MonthlyValues
}
/**
* Compute all formula-based betriebliche aufwendungen rows and sums.
* Writes computed values back to DB.
*/
export async function computeBetrieblicheAufwendungen(
pool: Pool,
betrieb: FPBetrieblicheAufwendungen[],
ctx: BetriebContext,
): Promise<{ totalSonstige: MonthlyValues; totalGesamt: MonthlyValues }> {
const NUM_FOUNDERS = 2
const hcWithoutFounders = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) {
hcWithoutFounders[`m${m}`] = Math.max(0, ctx.headcount[`m${m}`] - NUM_FOUNDERS)
}
// Formula-based rows: derive from headcount (excl. founders) or customers
const formulaRows: { label: string; perUnit: number; source: MonthlyValues }[] = [
{ label: 'Fort-/Weiterbildungskosten (F)', perUnit: 300, source: hcWithoutFounders },
{ label: 'Reisekosten (F)', perUnit: 75, source: ctx.headcount },
{ label: 'Bewirtungskosten (F)', perUnit: 50, source: ctx.totalBestandskunden },
{ label: 'Internet/Mobilfunk (F)', perUnit: 50, source: ctx.headcount },
]
for (const fr of formulaRows) {
const row = betrieb.find(r => r.row_label === fr.label)
if (row) {
const computed = emptyMonthly()
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
computed[`m${m}`] = Math.round((fr.source[`m${m}`] || 0) * fr.perUnit)
}
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(computed), row.id])
row.values = computed
}
}
// Berufsgenossenschaft (VBG IT/Büro): ~0.5% of total brutto payroll
const bgRow = betrieb.find(r => r.row_label.includes('Berufsgenossenschaft'))
if (bgRow) {
const computed = emptyMonthly()
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
computed[`m${m}`] = Math.round((ctx.totalBrutto[`m${m}`] || 0) * 0.005)
}
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(computed), bgRow.id])
bgRow.values = computed
}
// Allgemeine Marketingkosten: 8% of revenue (2026-2028), 10% from 2029
const marketingRow = betrieb.find(r => r.row_label.includes('Allgemeine Marketingkosten'))
if (marketingRow) {
const computed = emptyMonthly()
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
const rate = m <= 36 ? 0.08 : 0.10 // m36 = Dec 2028
computed[`m${m}`] = Math.round((ctx.totalRevenue[`m${m}`] || 0) * rate)
}
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(computed), marketingRow.id])
marketingRow.values = computed
}
// Update Personalkosten row
const persBetrieb = betrieb.find(r => r.row_label === 'Personalkosten')
if (persBetrieb) {
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(ctx.totalPersonal), persBetrieb.id])
persBetrieb.values = ctx.totalPersonal
}
// Update Abschreibungen row
const abrBetrieb = betrieb.find(r => r.row_label === 'Abschreibungen')
if (abrBetrieb) {
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(ctx.totalAfa), abrBetrieb.id])
abrBetrieb.values = ctx.totalAfa
}
// Gewerbesteuer (F): 12.25% of monthly profit (only when positive)
const gewStRow = betrieb.find(r => r.row_label.includes('Gewerbesteuer'))
if (gewStRow) {
const nonTaxOpex = betrieb.filter(r =>
r.category !== 'steuern' && r.category !== 'personal' && r.category !== 'abschreibungen' &&
!r.is_sum_row && !r.row_label.includes('Summe') && !r.row_label.includes('SUMME')
)
const computed = emptyMonthly()
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
const rev = ctx.totalRevenue[`m${m}`] || 0
const mat = ctx.totalMaterial[`m${m}`] || 0
const pers = ctx.totalPersonal[`m${m}`] || 0
const afa = ctx.totalAfa[`m${m}`] || 0
let opex = 0
for (const r of nonTaxOpex) { opex += r.values[`m${m}`] || 0 }
const profit = rev - mat - pers - afa - opex
computed[`m${m}`] = profit > 0 ? Math.round(profit * 0.1225) : 0
}
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(computed), gewStRow.id])
gewStRow.values = computed
}
// Compute category sums
const categories = ['steuern', 'versicherungen', 'besondere', 'marketing', 'sonstige']
for (const cat of categories) {
const sumRow = betrieb.find(r => r.category === cat && r.is_sum_row)
const detailRows = betrieb.filter(r => r.category === cat && !r.is_sum_row)
if (sumRow && detailRows.length > 0) {
const s = sumRows(detailRows)
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(s), sumRow.id])
sumRow.values = s
}
}
// Summe sonstige (ohne Personal, Abschreibungen)
const sonstSumme = betrieb.find(r => r.row_label.includes('Summe sonstige'))
if (sonstSumme) {
const nonPersonNonAbr = betrieb.filter(r =>
r.row_label !== 'Personalkosten' && r.row_label !== 'Abschreibungen' &&
!r.row_label.includes('Summe sonstige') && !r.row_label.includes('Gesamtkosten') &&
!r.is_sum_row && r.category !== 'personal' && r.category !== 'abschreibungen'
)
const s = sumRows(nonPersonNonAbr)
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(s), sonstSumme.id])
sonstSumme.values = s
}
// Gesamtkosten
const gesamtBetrieb = betrieb.find(r => r.row_label.includes('Gesamtkosten') || r.row_label.includes('SUMME Betriebliche'))
const totalSonstige = sonstSumme?.values || emptyMonthly()
if (gesamtBetrieb) {
const g = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) {
g[`m${m}`] = (ctx.totalPersonal[`m${m}`] || 0) + (ctx.totalAfa[`m${m}`] || 0) + (totalSonstige[`m${m}`] || 0)
}
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(g), gesamtBetrieb.id])
gesamtBetrieb.values = g
}
return {
totalSonstige,
totalGesamt: gesamtBetrieb?.values || emptyMonthly(),
}
}

View File

@@ -0,0 +1,166 @@
/**
* GuV (Gewinn- und Verlustrechnung) — annual P&L computation
*
* Computes annual sums, EBIT, taxes (Gewerbesteuer, Körperschaftsteuer)
* with Verlustvortrag, and writes tax amounts back to Liquidität.
*/
import { Pool } from 'pg'
import {
MonthlyValues, AnnualValues, MONTHS,
emptyMonthly, annualSums,
FPLiquiditaet,
} from './types'
export interface GuvContext {
totalRevenue: MonthlyValues
totalMaterial: MonthlyValues
totalBrutto: MonthlyValues
totalSozial: MonthlyValues
totalPersonal: MonthlyValues
totalAfa: MonthlyValues
totalSonstige: MonthlyValues
}
/**
* Compute GuV annual values, taxes, and write tax values to Liquidität.
* Returns EBIT annual values.
*/
export async function computeGuV(
pool: Pool,
scenarioId: string,
liquid: FPLiquiditaet[],
ctx: GuvContext,
): Promise<AnnualValues[]> {
const findLiq = (label: string) => liquid.find(r => r.row_label === label)
const umsatzAnnual = annualSums(ctx.totalRevenue)
const materialAnnual = annualSums(ctx.totalMaterial)
const personalBruttoAnnual = annualSums(ctx.totalBrutto)
const personalSozialAnnual = annualSums(ctx.totalSozial)
const personalAnnual = annualSums(ctx.totalPersonal)
const afaAnnual = annualSums(ctx.totalAfa)
const sonstigeAnnual = annualSums(ctx.totalSonstige)
// Rohergebnis = Gesamtleistung - Materialaufwand
const rohergebnis: AnnualValues = {}
for (let y = 2026; y <= 2030; y++) {
const k = `y${y}`
rohergebnis[k] = Math.round((umsatzAnnual[k] || 0) - (materialAnnual[k] || 0))
}
const guvUpdates: { label: string; values: AnnualValues }[] = [
{ label: 'Umsatzerlöse', values: umsatzAnnual },
{ label: 'Gesamtleistung', values: umsatzAnnual },
{ label: 'Summe Materialaufwand', values: materialAnnual },
{ label: 'Rohergebnis', values: rohergebnis },
{ label: 'Löhne und Gehälter', values: personalBruttoAnnual },
{ label: 'Soziale Abgaben', values: personalSozialAnnual },
{ label: 'Summe Personalaufwand', values: personalAnnual },
{ label: 'Abschreibungen', values: afaAnnual },
{ label: 'Sonst. betriebl. Aufwendungen', values: sonstigeAnnual },
]
for (const { label, values } of guvUpdates) {
await pool.query(
'UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3',
[JSON.stringify(values), scenarioId, label]
)
}
// EBIT (Betriebsergebnis)
const ebit: AnnualValues = {}
for (let y = 2026; y <= 2030; y++) {
const k = `y${y}`
ebit[k] = Math.round((umsatzAnnual[k] || 0) - (materialAnnual[k] || 0) - (personalAnnual[k] || 0) - (afaAnnual[k] || 0) - (sonstigeAnnual[k] || 0))
}
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ebit), scenarioId, 'EBIT'])
// Tax computation with Verlustvortrag
const { gewerbesteuer, koerperschaftsteuer, steuernGesamt, ergebnisNachSteuern } = computeTaxes(ebit)
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(gewerbesteuer), scenarioId, 'Gewerbesteuer'])
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(koerperschaftsteuer), scenarioId, 'Körperschaftssteuer'])
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(steuernGesamt), scenarioId, 'Steuern gesamt'])
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ergebnisNachSteuern), scenarioId, 'Ergebnis nach Steuern'])
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ergebnisNachSteuern), scenarioId, 'Jahresüberschuss'])
// Write taxes to Liquidität (monthly = 1/12 of annual amount)
await writeTaxToLiquiditaet(pool, findLiq('Gewerbesteuer'), gewerbesteuer)
await writeTaxToLiquiditaet(pool, findLiq('Körperschaftsteuer'), koerperschaftsteuer)
return [ebit]
}
// --- Tax helpers ---
// Stockach 78333: Hebesatz 350%
// Gewerbesteuer = 3,5% × 3,5 = 12,25%
// Körperschaftsteuer = 15% + 5,5% Soli = 15,825%
const GEWERBESTEUER_RATE = 0.035 * 3.5 // 12,25%
const KOERPERSCHAFTSTEUER_RATE = 0.15 * 1.055 // 15,825% (inkl. Soli)
function computeTaxes(ebit: AnnualValues) {
const gewerbesteuer: AnnualValues = {}
const koerperschaftsteuer: AnnualValues = {}
const steuernGesamt: AnnualValues = {}
const ergebnisNachSteuern: AnnualValues = {}
let verlustvortrag = 0
for (let y = 2026; y <= 2030; y++) {
const k = `y${y}`
const gewinn = ebit[k] || 0
if (gewinn <= 0) {
verlustvortrag += Math.abs(gewinn)
gewerbesteuer[k] = 0
koerperschaftsteuer[k] = 0
steuernGesamt[k] = 0
ergebnisNachSteuern[k] = Math.round(gewinn)
} else {
// Bis 1 Mio EUR: 100% verrechenbar
// Über 1 Mio EUR: nur 60% verrechenbar (Mindestbesteuerung)
let verrechenbar = 0
if (verlustvortrag > 0) {
if (gewinn <= 1000000) {
verrechenbar = Math.min(verlustvortrag, gewinn)
} else {
verrechenbar = Math.min(verlustvortrag, 1000000 + (gewinn - 1000000) * 0.6)
}
verlustvortrag -= verrechenbar
}
const zuVersteuern = Math.max(0, gewinn - verrechenbar)
const gst = Math.round(zuVersteuern * GEWERBESTEUER_RATE)
const kst = Math.round(zuVersteuern * KOERPERSCHAFTSTEUER_RATE)
gewerbesteuer[k] = gst
koerperschaftsteuer[k] = kst
steuernGesamt[k] = gst + kst
ergebnisNachSteuern[k] = Math.round(gewinn - gst - kst)
}
}
return { gewerbesteuer, koerperschaftsteuer, steuernGesamt, ergebnisNachSteuern }
}
async function writeTaxToLiquiditaet(
pool: Pool,
liqRow: FPLiquiditaet | undefined,
annualTax: AnnualValues,
): Promise<void> {
if (!liqRow) return
const v = emptyMonthly()
for (let y = 2026; y <= 2030; y++) {
const jahresBetrag = annualTax[`y${y}`] || 0
if (jahresBetrag > 0) {
const monatlich = Math.round(jahresBetrag / 12)
const startM = (y - 2026) * 12 + 1
for (let m = startM; m <= startM + 11 && m <= MONTHS; m++) {
v[`m${m}`] = monatlich
}
}
}
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(v), liqRow.id])
liqRow.values = v
}

View File

@@ -0,0 +1,154 @@
/**
* Liquiditaet — rolling cash balance computation
*
* Computes operative Einzahlungen/Auszahlungen sums,
* Überschuss vor Investitionen/Entnahmen, and rolling Kontostand.
*/
import { Pool } from 'pg'
import {
MonthlyValues, MONTHS,
emptyMonthly,
FPLiquiditaet,
} from './types'
export interface LiquiditaetContext {
totalRevenue: MonthlyValues
totalMaterial: MonthlyValues
totalPersonal: MonthlyValues
totalSonstige: MonthlyValues
totalInvest: MonthlyValues
}
/**
* Compute all liquidity rows and rolling balance.
* Writes computed values back to DB.
*/
export async function computeLiquiditaet(
pool: Pool,
liquid: FPLiquiditaet[],
ctx: LiquiditaetContext,
): Promise<{ endstand: MonthlyValues }> {
const findLiq = (label: string) => liquid.find(r => r.row_label === label)
// Computed rows — link to computed totals
const liqUmsatz = findLiq('Umsatzerlöse')
if (liqUmsatz) {
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(ctx.totalRevenue), liqUmsatz.id])
liqUmsatz.values = ctx.totalRevenue
}
const liqMaterial = findLiq('Materialaufwand')
if (liqMaterial) {
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(ctx.totalMaterial), liqMaterial.id])
liqMaterial.values = ctx.totalMaterial
}
const liqPersonal = findLiq('Personalkosten')
if (liqPersonal) {
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(ctx.totalPersonal), liqPersonal.id])
liqPersonal.values = ctx.totalPersonal
}
const liqSonstige = findLiq('Sonstige Kosten')
if (liqSonstige) {
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(ctx.totalSonstige), liqSonstige.id])
liqSonstige.values = ctx.totalSonstige
}
const liqInvest = findLiq('Investitionen')
if (liqInvest) {
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(ctx.totalInvest), liqInvest.id])
liqInvest.values = ctx.totalInvest
}
// Compute sums and rolling balance
const sumEin = findLiq('Summe EINZAHLUNGEN')
const sumAus = findLiq('Summe AUSZAHLUNGEN')
const uebVorInv = findLiq('ÜBERSCHUSS VOR INVESTITIONEN')
const uebVorEnt = findLiq('ÜBERSCHUSS VOR ENTNAHMEN')
const ueberschuss = findLiq('ÜBERSCHUSS')
const kontostand = findLiq('Kontostand zu Beginn des Monats')
const liquiditaet = findLiq('LIQUIDITÄT')
// Dynamically categorize rows by row_type
const einzahlungenOperativ = ['Umsatzerlöse', 'Sonst. betriebl. Erträge', 'Anzahlungen']
const finanzierungRows = liquid.filter(r =>
r.row_type === 'einzahlung' &&
!einzahlungenOperativ.includes(r.row_label) &&
!r.row_label.includes('Summe')
)
const auszahlungenOperativ = ['Materialaufwand', 'Personalkosten', 'Sonstige Kosten', 'Umsatzsteuer', 'Gewerbesteuer', 'Körperschaftsteuer']
const finanzAuszahlungRows = liquid.filter(r =>
r.row_type === 'auszahlung' &&
!auszahlungenOperativ.includes(r.row_label) &&
!r.row_label.includes('Summe')
)
// Summe EINZAHLUNGEN = nur operativ
if (sumEin) {
const s = emptyMonthly()
for (const label of einzahlungenOperativ) {
const row = findLiq(label)
if (row) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
}
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), sumEin.id])
sumEin.values = s
}
// Summe AUSZAHLUNGEN = nur operativ
if (sumAus) {
const s = emptyMonthly()
for (const label of auszahlungenOperativ) {
const row = findLiq(label)
if (row) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
}
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), sumAus.id])
sumAus.values = s
}
// OPERATIVER ÜBERSCHUSS VOR INVESTITIONEN
if (uebVorInv && sumEin && sumAus) {
const s = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((sumEin.values[`m${m}`] || 0) - (sumAus.values[`m${m}`] || 0))
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), uebVorInv.id])
uebVorInv.values = s
}
// ÜBERSCHUSS VOR ENTNAHMEN = Operativer Überschuss - Investitionen
if (uebVorEnt && uebVorInv && liqInvest) {
const s = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((uebVorInv.values[`m${m}`] || 0) - (liqInvest.values[`m${m}`] || 0))
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), uebVorEnt.id])
uebVorEnt.values = s
}
// ÜBERSCHUSS = Überschuss vor Entnahmen - Entnahmen
const entnahmen = findLiq('Kapitalentnahmen/Ausschüttungen')
if (ueberschuss && uebVorEnt && entnahmen) {
const s = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((uebVorEnt.values[`m${m}`] || 0) - (entnahmen.values[`m${m}`] || 0))
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), ueberschuss.id])
ueberschuss.values = s
}
// Rolling Kontostand: Vormonat + Operativer Überschuss + Finanzierung
if (kontostand && liquiditaet && ueberschuss) {
const finCF = emptyMonthly()
for (const row of finanzierungRows) {
for (let m = 1; m <= MONTHS; m++) finCF[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
}
for (const row of finanzAuszahlungRows) {
for (let m = 1; m <= MONTHS; m++) finCF[`m${m}`] -= Math.round(row.values[`m${m}`] || 0)
}
const ks = emptyMonthly()
const lq = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) {
ks[`m${m}`] = m === 1 ? 0 : Math.round(lq[`m${m - 1}`])
lq[`m${m}`] = Math.round(ks[`m${m}`] + (ueberschuss.values[`m${m}`] || 0) + (finCF[`m${m}`] || 0))
}
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(ks), kontostand.id])
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(lq), liquiditaet.id])
kontostand.values = ks
liquiditaet.values = lq
}
return { endstand: liquiditaet?.values || emptyMonthly() }
}

View File

@@ -0,0 +1,106 @@
/**
* Sheet Calculators — pure computation functions (no DB dependency)
*
* Used by the main engine to compute Personalkosten, Investitionen,
* and aggregate monthly values.
*/
import {
MonthlyValues, MONTHS, FOUNDING_MONTH,
emptyMonthly, dateToMonth, monthToDate,
FPPersonalkosten, FPInvestitionen,
} from './types'
export function computePersonalkosten(positions: FPPersonalkosten[]): FPPersonalkosten[] {
return positions.map(p => {
const brutto = emptyMonthly()
const sozial = emptyMonthly()
const total = emptyMonthly()
if (!p.start_date || !p.brutto_monthly) return { ...p, values_brutto: brutto, values_sozial: sozial, values_total: total }
const startDate = new Date(p.start_date)
const startM = dateToMonth(startDate.getFullYear(), startDate.getMonth() + 1)
const endM = p.end_date
? dateToMonth(new Date(p.end_date).getFullYear(), new Date(p.end_date).getMonth() + 1)
: MONTHS
for (let m = Math.max(1, startM); m <= Math.min(MONTHS, endM); m++) {
const { year } = monthToDate(m)
const yearsFromStart = year - startDate.getFullYear()
const raise = Math.pow(1 + (p.annual_raise_pct || 0) / 100, yearsFromStart)
const monthlyBrutto = Math.round(p.brutto_monthly * raise)
brutto[`m${m}`] = monthlyBrutto
sozial[`m${m}`] = Math.round(monthlyBrutto * (p.ag_sozial_pct || 20.425) / 100)
total[`m${m}`] = brutto[`m${m}`] + sozial[`m${m}`]
}
return { ...p, values_brutto: brutto, values_sozial: sozial, values_total: total }
})
}
export function computeInvestitionen(items: FPInvestitionen[]): FPInvestitionen[] {
return items.map(item => {
const invest = emptyMonthly()
const afa = emptyMonthly()
if (!item.purchase_date || !item.purchase_amount) return { ...item, values_invest: invest, values_afa: afa }
const d = new Date(item.purchase_date)
const purchaseM = dateToMonth(d.getFullYear(), d.getMonth() + 1)
if (purchaseM >= 1 && purchaseM <= MONTHS) {
invest[`m${purchaseM}`] = item.purchase_amount
}
// AfA (linear depreciation)
if (item.afa_years && item.afa_years > 0) {
const afaMonths = item.afa_years * 12
const monthlyAfa = Math.round(item.purchase_amount / afaMonths)
for (let m = purchaseM; m < purchaseM + afaMonths && m <= MONTHS; m++) {
if (m >= 1) afa[`m${m}`] = monthlyAfa
}
} else {
// GWG: full depreciation in purchase month
if (purchaseM >= 1 && purchaseM <= MONTHS) {
afa[`m${purchaseM}`] = item.purchase_amount
}
}
return { ...item, values_invest: invest, values_afa: afa }
})
}
export function sumRows(rows: { values: MonthlyValues }[]): MonthlyValues {
const result = emptyMonthly()
for (const row of rows) {
for (let m = 1; m <= MONTHS; m++) {
result[`m${m}`] += row.values[`m${m}`] || 0
}
}
return result
}
export function sumField(rows: { [key: string]: MonthlyValues }[], field: string): MonthlyValues {
const result = emptyMonthly()
for (const row of rows) {
const v = row[field] as MonthlyValues
if (!v) continue
for (let m = 1; m <= MONTHS; m++) {
result[`m${m}`] += v[`m${m}`] || 0
}
}
return result
}
/**
* Compute headcount per month from personal positions.
*/
export function computeHeadcount(personal: FPPersonalkosten[]): MonthlyValues {
const headcount = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) {
headcount[`m${m}`] = personal.filter(p => (p.values_total[`m${m}`] || 0) > 0).length
}
return headcount
}

View File

@@ -1,5 +1,5 @@
/**
* Finanzplan Compute Engine
* Finanzplan Compute Engine — Orchestrator
*
* Dependency order:
* Personalkosten (independent inputs)
@@ -9,113 +9,43 @@
* Sonst. betr. Erträge (independent)
* Liquidität (aggregates all above)
* GuV (annual summary)
*
* Split into modules:
* engine-sheets.ts — pure calculators (no DB)
* engine-betrieb.ts — betriebliche aufwendungen
* engine-liquiditaet.ts — liquidity / cash flow
* engine-guv.ts — GuV / P&L + taxes
*/
import { Pool } from 'pg'
import {
MonthlyValues, AnnualValues, MONTHS, FOUNDING_MONTH,
emptyMonthly, sumMonthly, annualSums, dateToMonth, monthToDate,
MonthlyValues, MONTHS, FOUNDING_MONTH,
emptyMonthly,
FPPersonalkosten, FPInvestitionen, FPBetrieblicheAufwendungen,
FPLiquiditaet, FPComputeResult
FPLiquiditaet, FPComputeResult,
} from './types'
import {
computePersonalkosten, computeInvestitionen,
sumField, computeHeadcount,
} from './engine-sheets'
import { computeBetrieblicheAufwendungen } from './engine-betrieb'
import { computeLiquiditaet } from './engine-liquiditaet'
import { computeGuV } from './engine-guv'
// --- Sheet Calculators ---
// Re-export sheet calculators for direct consumers
export { computePersonalkosten, computeInvestitionen } from './engine-sheets'
export function computePersonalkosten(positions: FPPersonalkosten[]): FPPersonalkosten[] {
return positions.map(p => {
const brutto = emptyMonthly()
const sozial = emptyMonthly()
const total = emptyMonthly()
if (!p.start_date || !p.brutto_monthly) return { ...p, values_brutto: brutto, values_sozial: sozial, values_total: total }
const startDate = new Date(p.start_date)
const startM = dateToMonth(startDate.getFullYear(), startDate.getMonth() + 1)
const endM = p.end_date
? dateToMonth(new Date(p.end_date).getFullYear(), new Date(p.end_date).getMonth() + 1)
: MONTHS
for (let m = Math.max(1, startM); m <= Math.min(MONTHS, endM); m++) {
const { year } = monthToDate(m)
const yearsFromStart = year - startDate.getFullYear()
const raise = Math.pow(1 + (p.annual_raise_pct || 0) / 100, yearsFromStart)
const monthlyBrutto = Math.round(p.brutto_monthly * raise)
brutto[`m${m}`] = monthlyBrutto
sozial[`m${m}`] = Math.round(monthlyBrutto * (p.ag_sozial_pct || 20.425) / 100)
total[`m${m}`] = brutto[`m${m}`] + sozial[`m${m}`]
}
return { ...p, values_brutto: brutto, values_sozial: sozial, values_total: total }
})
}
export function computeInvestitionen(items: FPInvestitionen[]): FPInvestitionen[] {
return items.map(item => {
const invest = emptyMonthly()
const afa = emptyMonthly()
if (!item.purchase_date || !item.purchase_amount) return { ...item, values_invest: invest, values_afa: afa }
const d = new Date(item.purchase_date)
const purchaseM = dateToMonth(d.getFullYear(), d.getMonth() + 1)
if (purchaseM >= 1 && purchaseM <= MONTHS) {
invest[`m${purchaseM}`] = item.purchase_amount
}
// AfA (linear depreciation)
if (item.afa_years && item.afa_years > 0) {
const afaMonths = item.afa_years * 12
const monthlyAfa = Math.round(item.purchase_amount / afaMonths)
for (let m = purchaseM; m < purchaseM + afaMonths && m <= MONTHS; m++) {
if (m >= 1) afa[`m${m}`] = monthlyAfa
}
} else {
// GWG: full depreciation in purchase month
if (purchaseM >= 1 && purchaseM <= MONTHS) {
afa[`m${purchaseM}`] = item.purchase_amount
}
}
return { ...item, values_invest: invest, values_afa: afa }
})
}
function sumRows(rows: { values: MonthlyValues }[]): MonthlyValues {
const result = emptyMonthly()
for (const row of rows) {
for (let m = 1; m <= MONTHS; m++) {
result[`m${m}`] += row.values[`m${m}`] || 0
}
}
return result
}
function sumField(rows: { [key: string]: MonthlyValues }[], field: string): MonthlyValues {
const result = emptyMonthly()
for (const row of rows) {
const v = row[field] as MonthlyValues
if (!v) continue
for (let m = 1; m <= MONTHS; m++) {
result[`m${m}`] += v[`m${m}`] || 0
}
}
return result
}
// Import types used inline
type FPUmsatzerloese = import('./types').FPUmsatzerloese
type FPMaterialaufwand = import('./types').FPMaterialaufwand
// --- Main Engine ---
export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise<FPComputeResult> {
// 1. Load all editable data from DB
const [
personalRows,
investRows,
betriebRows,
liquidRows,
kundenSummary,
umsatzRows,
materialRows,
personalRows, investRows, betriebRows, liquidRows,
kundenSummary, umsatzRows, materialRows,
] = await Promise.all([
pool.query('SELECT * FROM fp_personalkosten WHERE scenario_id = $1 ORDER BY sort_order', [scenarioId]),
pool.query('SELECT * FROM fp_investitionen WHERE scenario_id = $1 ORDER BY sort_order', [scenarioId]),
@@ -131,12 +61,8 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise
const totalBrutto = sumField(personal as any, 'values_brutto')
const totalSozial = sumField(personal as any, 'values_sozial')
const totalPersonal = sumField(personal as any, 'values_total')
const headcount = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) {
headcount[`m${m}`] = personal.filter(p => (p.values_total[`m${m}`] || 0) > 0).length
}
const headcount = computeHeadcount(personal)
// Write computed values back to DB
for (const p of personal) {
await pool.query(
'UPDATE fp_personalkosten SET values_brutto = $1, values_sozial = $2, values_total = $3 WHERE id = $4',
@@ -156,16 +82,84 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise
)
}
// 4. Umsatzerlöse (quantity × price)
const prices = (umsatzRows.rows as FPUmsatzerloese[]).filter(r => r.section === 'price')
const quantities = (umsatzRows.rows as FPUmsatzerloese[]).filter(r => r.section === 'quantity')
const revenueRows = (umsatzRows.rows as FPUmsatzerloese[]).filter(r => r.section === 'revenue')
// 4. Umsatzerlöse (quantity × price) + Materialaufwand
const { totalRevenue, totalMaterial } = await computeRevenueAndMaterial(
pool, umsatzRows.rows as FPUmsatzerloese[], materialRows.rows as FPMaterialaufwand[]
)
// 5. Bestandskunden (for formula-based costs)
const kundenRows = await pool.query(
"SELECT segment_name, row_label, values FROM fp_kunden WHERE scenario_id = $1 AND row_label LIKE 'Bestandskunden%' ORDER BY sort_order",
[scenarioId]
)
const totalBestandskunden = emptyMonthly()
for (const row of kundenRows.rows) {
const rl = (row as { row_label?: string }).row_label || ''
if (rl.includes('Bestandskunden') && !rl.includes('gesamt')) {
for (let m = 1; m <= MONTHS; m++) {
totalBestandskunden[`m${m}`] += row.values?.[`m${m}`] || 0
}
}
}
// Cloud-Hosting in Materialaufwand
const matRows = materialRows.rows as FPMaterialaufwand[]
const cloudRow = matRows.find(r => r.row_label.includes('Cloud-Hosting'))
if (cloudRow) {
const computed = emptyMonthly()
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
const kunden = totalBestandskunden[`m${m}`] || 0
const extraKunden = Math.max(0, kunden - 10)
computed[`m${m}`] = Math.round(extraKunden * 100 + 1500)
}
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(computed), cloudRow.id])
cloudRow.values = computed
}
// 6. Betriebliche Aufwendungen
const betrieb = betriebRows.rows as FPBetrieblicheAufwendungen[]
const { totalSonstige, totalGesamt } = await computeBetrieblicheAufwendungen(pool, betrieb, {
totalBrutto, totalPersonal, totalAfa, totalRevenue, totalMaterial,
headcount, totalBestandskunden,
})
// 7. Liquidität
const liquid = liquidRows.rows as FPLiquiditaet[]
const { endstand } = await computeLiquiditaet(pool, liquid, {
totalRevenue, totalMaterial, totalPersonal, totalSonstige, totalInvest,
})
// 8. GuV
const guv = await computeGuV(pool, scenarioId, liquid, {
totalRevenue, totalMaterial, totalBrutto, totalSozial,
totalPersonal, totalAfa, totalSonstige,
})
return {
personalkosten: { total_brutto: totalBrutto, total_sozial: totalSozial, total: totalPersonal, positions: personal, headcount },
investitionen: { total_invest: totalInvest, total_afa: totalAfa, items: invest },
umsatzerloese: { total: totalRevenue },
materialaufwand: { total: totalMaterial },
betriebliche: { total_sonstige: totalSonstige, total_gesamt: totalGesamt },
liquiditaet: { rows: liquid, endstand },
guv,
}
}
// --- Revenue & Material helpers (kept here to avoid circular deps) ---
async function computeRevenueAndMaterial(
pool: Pool,
umsatzAllRows: FPUmsatzerloese[],
materialAllRows: FPMaterialaufwand[],
): Promise<{ totalRevenue: MonthlyValues; totalMaterial: MonthlyValues }> {
const prices = umsatzAllRows.filter(r => r.section === 'price')
const quantities = umsatzAllRows.filter(r => r.section === 'quantity')
const revenueRows = umsatzAllRows.filter(r => r.section === 'revenue')
const totalRevenue = emptyMonthly()
// Revenue = quantity × price for each module (if qty+price exist)
// Match by tier name extracted from parentheses, or exact label match
const extractTier = (label: string) => { const m = label.match(/\(([^)]+)\)/); return m ? m[1] : label }
// Revenue rows WITHOUT matching qty/price are kept as-is (e.g. Beratung & Service)
for (const rev of revenueRows) {
if (rev.row_label === 'GESAMTUMSATZ') continue
const tier = extractTier(rev.row_label)
@@ -178,20 +172,18 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise
}
await pool.query('UPDATE fp_umsatzerloese SET values = $1 WHERE id = $2', [JSON.stringify(rev.values), rev.id])
}
// Add ALL revenue rows to total (computed or manual)
for (let m = 1; m <= MONTHS; m++) {
totalRevenue[`m${m}`] += rev.values[`m${m}`] || 0
}
}
// Update GESAMTUMSATZ
const gesamtUmsatz = revenueRows.find(r => r.row_label === 'GESAMTUMSATZ')
if (gesamtUmsatz) {
await pool.query('UPDATE fp_umsatzerloese SET values = $1 WHERE id = $2', [JSON.stringify(totalRevenue), gesamtUmsatz.id])
}
// 5. Materialaufwand (quantity × unit_cost) — simplified
const matCosts = (materialRows.rows as FPMaterialaufwand[]).filter(r => r.section === 'cost')
const matUnitCosts = (materialRows.rows as FPMaterialaufwand[]).filter(r => r.section === 'unit_cost')
// Materialaufwand
const matCosts = materialAllRows.filter(r => r.section === 'cost')
const matUnitCosts = materialAllRows.filter(r => r.section === 'unit_cost')
const totalMaterial = emptyMonthly()
for (const cost of matCosts) {
@@ -205,7 +197,6 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise
}
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(cost.values), cost.id])
}
// Add ALL cost rows to total (computed or manual)
for (let m = 1; m <= MONTHS; m++) {
totalMaterial[`m${m}`] += cost.values[`m${m}`] || 0
}
@@ -215,450 +206,5 @@ export async function computeFinanzplan(pool: Pool, scenarioId: string): Promise
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(totalMaterial), matSumme.id])
}
// 5b. Headcount without founders (for formula-based costs)
const NUM_FOUNDERS = 2
const hcWithoutFounders = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) {
hcWithoutFounders[`m${m}`] = Math.max(0, headcount[`m${m}`] - NUM_FOUNDERS)
}
// 5c. Total Bestandskunden (for Bewirtungskosten — uses totalBestandskunden from Serverkosten above)
// Also load enterprise customers separately for legacy compatibility
const kundenRows = await pool.query(
"SELECT segment_name, row_label, values FROM fp_kunden WHERE scenario_id = $1 AND row_label LIKE 'Bestandskunden%' ORDER BY sort_order",
[scenarioId]
)
const enterpriseKunden = emptyMonthly()
for (const row of kundenRows.rows) {
if (row.segment_name?.toLowerCase().includes('enterprise')) {
for (let m = 1; m <= MONTHS; m++) {
enterpriseKunden[`m${m}`] = row.values?.[`m${m}`] || 0
}
}
}
// 6. Betriebliche Aufwendungen — compute formula-based rows + sum rows
const betrieb = betriebRows.rows as FPBetrieblicheAufwendungen[]
// Pre-compute total Bestandskunden (needed for Bewirtungskosten + Serverkosten)
const totalBestandskunden = emptyMonthly()
for (const row of kundenRows.rows) {
const rl = (row as { row_label?: string }).row_label || ''
if (rl.includes('Bestandskunden') && !rl.includes('gesamt')) {
for (let m = 1; m <= MONTHS; m++) {
totalBestandskunden[`m${m}`] += row.values?.[`m${m}`] || 0
}
}
}
// Formula-based rows: derive from headcount (excl. founders) or customers
const formulaRows: { label: string; perUnit: number; source: MonthlyValues }[] = [
{ label: 'Fort-/Weiterbildungskosten (F)', perUnit: 300, source: hcWithoutFounders },
// KFZ costs are manual (from Jan 2028), not formula-based
{ label: 'Reisekosten (F)', perUnit: 75, source: headcount },
{ label: 'Bewirtungskosten (F)', perUnit: 50, source: totalBestandskunden },
{ label: 'Internet/Mobilfunk (F)', perUnit: 50, source: headcount },
]
for (const fr of formulaRows) {
const row = betrieb.find(r => r.row_label === fr.label)
if (row) {
const computed = emptyMonthly()
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
computed[`m${m}`] = Math.round((fr.source[`m${m}`] || 0) * fr.perUnit)
}
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(computed), row.id])
row.values = computed
}
}
// Berufsgenossenschaft (VBG IT/Büro): ~0.5% of total brutto payroll
const bgRow = betrieb.find(r => r.row_label.includes('Berufsgenossenschaft'))
if (bgRow) {
const computed = emptyMonthly()
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
computed[`m${m}`] = Math.round((totalBrutto[`m${m}`] || 0) * 0.005)
}
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(computed), bgRow.id])
bgRow.values = computed
}
// Allgemeine Marketingkosten: 8% of revenue (2026-2028), 10% from 2029
const marketingRow = betrieb.find(r => r.row_label.includes('Allgemeine Marketingkosten'))
if (marketingRow) {
const computed = emptyMonthly()
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
const rate = m <= 36 ? 0.08 : 0.10 // m36 = Dec 2028
computed[`m${m}`] = Math.round((totalRevenue[`m${m}`] || 0) * rate)
}
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(computed), marketingRow.id])
marketingRow.values = computed
}
// Serverkosten now in Materialaufwand — compute Cloud-Hosting formula there
const matRows = materialRows.rows as FPMaterialaufwand[]
const cloudRow = matRows.find(r => r.row_label.includes('Cloud-Hosting'))
if (cloudRow) {
const computed = emptyMonthly()
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
const kunden = totalBestandskunden[`m${m}`] || 0
const extraKunden = Math.max(0, kunden - 10) // first 10 included in base
computed[`m${m}`] = Math.round(extraKunden * 100 + 1500)
}
await pool.query('UPDATE fp_materialaufwand SET values = $1 WHERE id = $2', [JSON.stringify(computed), cloudRow.id])
cloudRow.values = computed
}
// Update Personalkosten row
const persBetrieb = betrieb.find(r => r.row_label === 'Personalkosten')
if (persBetrieb) {
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(totalPersonal), persBetrieb.id])
persBetrieb.values = totalPersonal
}
// Update Abschreibungen row
const abrBetrieb = betrieb.find(r => r.row_label === 'Abschreibungen')
if (abrBetrieb) {
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(totalAfa), abrBetrieb.id])
abrBetrieb.values = totalAfa
}
// Gewerbesteuer (F): 12.25% of monthly profit (only when positive)
// Monthly profit = Revenue - Material - Personnel - AfA - other opex (excl. taxes)
const gewStRow = betrieb.find(r => r.row_label.includes('Gewerbesteuer'))
if (gewStRow) {
const nonTaxOpex = betrieb.filter(r =>
r.category !== 'steuern' && r.category !== 'personal' && r.category !== 'abschreibungen' &&
!r.is_sum_row && !r.row_label.includes('Summe') && !r.row_label.includes('SUMME')
)
const computed = emptyMonthly()
for (let m = FOUNDING_MONTH; m <= MONTHS; m++) {
const rev = totalRevenue[`m${m}`] || 0
const mat = totalMaterial[`m${m}`] || 0
const pers = totalPersonal[`m${m}`] || 0
const afa = totalAfa[`m${m}`] || 0
let opex = 0
for (const r of nonTaxOpex) { opex += r.values[`m${m}`] || 0 }
const profit = rev - mat - pers - afa - opex
computed[`m${m}`] = profit > 0 ? Math.round(profit * 0.1225) : 0
}
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(computed), gewStRow.id])
gewStRow.values = computed
}
// Compute category sums
const categories = ['steuern', 'versicherungen', 'besondere', 'marketing', 'sonstige']
for (const cat of categories) {
const sumRow = betrieb.find(r => r.category === cat && r.is_sum_row)
const detailRows = betrieb.filter(r => r.category === cat && !r.is_sum_row)
if (sumRow && detailRows.length > 0) {
const s = sumRows(detailRows)
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(s), sumRow.id])
sumRow.values = s
}
}
// Summe sonstige (ohne Personal, Abschreibungen)
const sonstSumme = betrieb.find(r => r.row_label.includes('Summe sonstige'))
if (sonstSumme) {
const nonPersonNonAbr = betrieb.filter(r =>
r.row_label !== 'Personalkosten' && r.row_label !== 'Abschreibungen' &&
!r.row_label.includes('Summe sonstige') && !r.row_label.includes('Gesamtkosten') &&
!r.is_sum_row && r.category !== 'personal' && r.category !== 'abschreibungen'
)
const s = sumRows(nonPersonNonAbr)
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(s), sonstSumme.id])
sonstSumme.values = s
}
// Gesamtkosten
const gesamtBetrieb = betrieb.find(r => r.row_label.includes('Gesamtkosten') || r.row_label.includes('SUMME Betriebliche'))
const totalSonstige = sonstSumme?.values || emptyMonthly()
if (gesamtBetrieb) {
const g = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) {
g[`m${m}`] = (totalPersonal[`m${m}`] || 0) + (totalAfa[`m${m}`] || 0) + (totalSonstige[`m${m}`] || 0)
}
await pool.query('UPDATE fp_betriebliche_aufwendungen SET values = $1 WHERE id = $2', [JSON.stringify(g), gesamtBetrieb.id])
gesamtBetrieb.values = g
}
// 7. Liquidität
const liquid = liquidRows.rows as FPLiquiditaet[]
const findLiq = (label: string) => liquid.find(r => r.row_label === label)
// Computed rows
const liqUmsatz = findLiq('Umsatzerlöse')
if (liqUmsatz) {
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(totalRevenue), liqUmsatz.id])
liqUmsatz.values = totalRevenue
}
const liqMaterial = findLiq('Materialaufwand')
if (liqMaterial) {
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(totalMaterial), liqMaterial.id])
liqMaterial.values = totalMaterial
}
const liqPersonal = findLiq('Personalkosten')
if (liqPersonal) {
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(totalPersonal), liqPersonal.id])
liqPersonal.values = totalPersonal
}
const liqSonstige = findLiq('Sonstige Kosten')
if (liqSonstige) {
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(totalSonstige), liqSonstige.id])
liqSonstige.values = totalSonstige
}
const liqInvest = findLiq('Investitionen')
if (liqInvest) {
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(totalInvest), liqInvest.id])
liqInvest.values = totalInvest
}
// Compute sums and rolling balance
// WICHTIG: Überschuss = nur operativer Cashflow (ohne Kapitaleinzahlungen)
const sumEin = findLiq('Summe EINZAHLUNGEN')
const sumAus = findLiq('Summe AUSZAHLUNGEN')
const uebVorInv = findLiq('ÜBERSCHUSS VOR INVESTITIONEN')
const uebVorEnt = findLiq('ÜBERSCHUSS VOR ENTNAHMEN')
const ueberschuss = findLiq('ÜBERSCHUSS')
const kontostand = findLiq('Kontostand zu Beginn des Monats')
const liquiditaet = findLiq('LIQUIDITÄT')
// Dynamically categorize rows by row_type instead of hardcoded labels
// Operative Einzahlungen (OHNE Eigenkapital, Fremdkapital, Stammkapital, Wandeldarlehen)
const einzahlungenOperativ = ['Umsatzerlöse', 'Sonst. betriebl. Erträge', 'Anzahlungen']
// Finanzierung: match any row with these keywords (handles renamed labels)
const finanzierungRows = liquid.filter(r =>
r.row_type === 'einzahlung' &&
!einzahlungenOperativ.includes(r.row_label) &&
!r.row_label.includes('Summe')
)
// Operative Auszahlungen
const auszahlungenOperativ = ['Materialaufwand', 'Personalkosten', 'Sonstige Kosten', 'Umsatzsteuer', 'Gewerbesteuer', 'Körperschaftsteuer']
// Finanz-Auszahlungen: any auszahlung not in operativ list
const finanzAuszahlungRows = liquid.filter(r =>
r.row_type === 'auszahlung' &&
!auszahlungenOperativ.includes(r.row_label) &&
!r.row_label.includes('Summe')
)
// Summe EINZAHLUNGEN = nur operativ (für die Zeile "Summe Einzahlungen")
if (sumEin) {
const s = emptyMonthly()
for (const label of einzahlungenOperativ) {
const row = findLiq(label)
if (row) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
}
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), sumEin.id])
sumEin.values = s
}
// Summe AUSZAHLUNGEN = nur operativ
if (sumAus) {
const s = emptyMonthly()
for (const label of auszahlungenOperativ) {
const row = findLiq(label)
if (row) for (let m = 1; m <= MONTHS; m++) s[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
}
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), sumAus.id])
sumAus.values = s
}
// OPERATIVER ÜBERSCHUSS VOR INVESTITIONEN = operative Einzahlungen - operative Auszahlungen
if (uebVorInv && sumEin && sumAus) {
const s = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((sumEin.values[`m${m}`] || 0) - (sumAus.values[`m${m}`] || 0))
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), uebVorInv.id])
uebVorInv.values = s
}
// ÜBERSCHUSS VOR ENTNAHMEN = Operativer Überschuss - Investitionen
if (uebVorEnt && uebVorInv && liqInvest) {
const s = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((uebVorInv.values[`m${m}`] || 0) - (liqInvest.values[`m${m}`] || 0))
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), uebVorEnt.id])
uebVorEnt.values = s
}
// ÜBERSCHUSS = Überschuss vor Entnahmen - Entnahmen (immer noch rein operativ)
const entnahmen = findLiq('Kapitalentnahmen/Ausschüttungen')
if (ueberschuss && uebVorEnt && entnahmen) {
const s = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) s[`m${m}`] = Math.round((uebVorEnt.values[`m${m}`] || 0) - (entnahmen.values[`m${m}`] || 0))
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(s), ueberschuss.id])
ueberschuss.values = s
}
// Rolling Kontostand: Vormonat + Operativer Überschuss + Finanzierung
// Finanzierung = Eigenkapital + Fremdkapital - Kreditrückzahlungen
if (kontostand && liquiditaet && ueberschuss) {
// Berechne monatliche Finanzierungs-Cashflows
const finCF = emptyMonthly()
for (const row of finanzierungRows) {
for (let m = 1; m <= MONTHS; m++) finCF[`m${m}`] += Math.round(row.values[`m${m}`] || 0)
}
for (const row of finanzAuszahlungRows) {
for (let m = 1; m <= MONTHS; m++) finCF[`m${m}`] -= Math.round(row.values[`m${m}`] || 0)
}
const ks = emptyMonthly()
const lq = emptyMonthly()
for (let m = 1; m <= MONTHS; m++) {
ks[`m${m}`] = m === 1 ? 0 : Math.round(lq[`m${m - 1}`])
// LIQUIDITÄT = Kontostand + Operativer Überschuss + Finanzierung
lq[`m${m}`] = Math.round(ks[`m${m}`] + (ueberschuss.values[`m${m}`] || 0) + (finCF[`m${m}`] || 0))
}
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(ks), kontostand.id])
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(lq), liquiditaet.id])
kontostand.values = ks
liquiditaet.values = lq
}
// 8. GuV — compute annual values
const guv: AnnualValues[] = []
const umsatzAnnual = annualSums(totalRevenue)
const materialAnnual = annualSums(totalMaterial)
const personalBruttoAnnual = annualSums(totalBrutto)
const personalSozialAnnual = annualSums(totalSozial)
const personalAnnual = annualSums(totalPersonal)
const afaAnnual = annualSums(totalAfa)
const sonstigeAnnual = annualSums(totalSonstige)
// Write GuV rows
// Rohergebnis = Gesamtleistung - Materialaufwand
const rohergebnis: AnnualValues = {}
for (let y = 2026; y <= 2030; y++) {
const k = `y${y}`
rohergebnis[k] = Math.round((umsatzAnnual[k] || 0) - (materialAnnual[k] || 0))
}
const guvUpdates: { label: string; values: AnnualValues }[] = [
{ label: 'Umsatzerlöse', values: umsatzAnnual },
{ label: 'Gesamtleistung', values: umsatzAnnual },
{ label: 'Summe Materialaufwand', values: materialAnnual },
{ label: 'Rohergebnis', values: rohergebnis },
{ label: 'Löhne und Gehälter', values: personalBruttoAnnual },
{ label: 'Soziale Abgaben', values: personalSozialAnnual },
{ label: 'Summe Personalaufwand', values: personalAnnual },
{ label: 'Abschreibungen', values: afaAnnual },
{ label: 'Sonst. betriebl. Aufwendungen', values: sonstigeAnnual },
]
for (const { label, values } of guvUpdates) {
await pool.query(
'UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3',
[JSON.stringify(values), scenarioId, label]
)
}
// EBIT (Betriebsergebnis)
const ebit: AnnualValues = {}
for (let y = 2026; y <= 2030; y++) {
const k = `y${y}`
ebit[k] = Math.round((umsatzAnnual[k] || 0) - (materialAnnual[k] || 0) - (personalAnnual[k] || 0) - (afaAnnual[k] || 0) - (sonstigeAnnual[k] || 0))
}
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ebit), scenarioId, 'EBIT'])
// Steuerberechnung (nur auf Gewinne, mit Verlustvortrag)
// Stockach 78333: Hebesatz 350%
// Gewerbesteuer = 3,5% × 3,5 = 12,25%
// Körperschaftsteuer = 15% + 5,5% Soli = 15,825%
// Gesamt: ~28,075%
const GEWERBESTEUER_RATE = 0.035 * 3.5 // 12,25%
const KOERPERSCHAFTSTEUER_RATE = 0.15 * 1.055 // 15,825% (inkl. Soli)
const gewerbesteuer: AnnualValues = {}
const koerperschaftsteuer: AnnualValues = {}
const steuernGesamt: AnnualValues = {}
const ergebnisNachSteuern: AnnualValues = {}
let verlustvortrag = 0 // kumulierter Verlustvortrag
for (let y = 2026; y <= 2030; y++) {
const k = `y${y}`
const gewinn = ebit[k] || 0
if (gewinn <= 0) {
// Verlust: keine Steuern, Verlustvortrag aufbauen
verlustvortrag += Math.abs(gewinn)
gewerbesteuer[k] = 0
koerperschaftsteuer[k] = 0
steuernGesamt[k] = 0
ergebnisNachSteuern[k] = Math.round(gewinn)
} else {
// Gewinn: Verlustvortrag verrechnen
// Bis 1 Mio EUR: 100% verrechenbar
// Über 1 Mio EUR: nur 60% verrechenbar (Mindestbesteuerung)
let verrechenbar = 0
if (verlustvortrag > 0) {
if (gewinn <= 1000000) {
verrechenbar = Math.min(verlustvortrag, gewinn)
} else {
verrechenbar = Math.min(verlustvortrag, 1000000 + (gewinn - 1000000) * 0.6)
}
verlustvortrag -= verrechenbar
}
const zuVersteuern = Math.max(0, gewinn - verrechenbar)
const gst = Math.round(zuVersteuern * GEWERBESTEUER_RATE)
const kst = Math.round(zuVersteuern * KOERPERSCHAFTSTEUER_RATE)
gewerbesteuer[k] = gst
koerperschaftsteuer[k] = kst
steuernGesamt[k] = gst + kst
ergebnisNachSteuern[k] = Math.round(gewinn - gst - kst)
}
}
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(gewerbesteuer), scenarioId, 'Gewerbesteuer'])
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(koerperschaftsteuer), scenarioId, 'Körperschaftssteuer'])
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(steuernGesamt), scenarioId, 'Steuern gesamt'])
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ergebnisNachSteuern), scenarioId, 'Ergebnis nach Steuern'])
await pool.query('UPDATE fp_guv SET values = $1 WHERE scenario_id = $2 AND row_label = $3', [JSON.stringify(ergebnisNachSteuern), scenarioId, 'Jahresüberschuss'])
// Steuern auch in Liquidität eintragen (monatlich = 1/12 des Jahresbetrags)
const liqGewSt = findLiq('Gewerbesteuer')
const liqKSt = findLiq('Körperschaftsteuer')
if (liqGewSt) {
const v = emptyMonthly()
for (let y = 2026; y <= 2030; y++) {
const jahresBetrag = gewerbesteuer[`y${y}`] || 0
if (jahresBetrag > 0) {
const monatlich = Math.round(jahresBetrag / 12)
const startM = (y - 2026) * 12 + 1
for (let m = startM; m <= startM + 11 && m <= MONTHS; m++) {
v[`m${m}`] = monatlich
}
}
}
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(v), liqGewSt.id])
liqGewSt.values = v
}
if (liqKSt) {
const v = emptyMonthly()
for (let y = 2026; y <= 2030; y++) {
const jahresBetrag = koerperschaftsteuer[`y${y}`] || 0
if (jahresBetrag > 0) {
const monatlich = Math.round(jahresBetrag / 12)
const startM = (y - 2026) * 12 + 1
for (let m = startM; m <= startM + 11 && m <= MONTHS; m++) {
v[`m${m}`] = monatlich
}
}
}
await pool.query('UPDATE fp_liquiditaet SET values = $1 WHERE id = $2', [JSON.stringify(v), liqKSt.id])
liqKSt.values = v
}
return {
personalkosten: { total_brutto: totalBrutto, total_sozial: totalSozial, total: totalPersonal, positions: personal, headcount },
investitionen: { total_invest: totalInvest, total_afa: totalAfa, items: invest },
umsatzerloese: { total: totalRevenue },
materialaufwand: { total: totalMaterial },
betriebliche: { total_sonstige: totalSonstige, total_gesamt: gesamtBetrieb?.values || emptyMonthly() },
liquiditaet: { rows: liquid, endstand: liquiditaet?.values || emptyMonthly() },
guv: [ebit],
}
return { totalRevenue, totalMaterial }
}
// Import to fix type errors
type FPUmsatzerloese = import('./types').FPUmsatzerloese
type FPMaterialaufwand = import('./types').FPMaterialaufwand