[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:
@@ -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 "Edit as JSON" mode for complex types.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
112
pitch-deck/components/ChatFAB.helpers.ts
Normal file
112
pitch-deck/components/ChatFAB.helpers.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
118
pitch-deck/components/slides/ArchitectureSlide.data.ts
Normal file
118
pitch-deck/components/slides/ArchitectureSlide.data.ts
Normal 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 },
|
||||
]
|
||||
370
pitch-deck/components/slides/ArchitectureSlide.parts.tsx
Normal file
370
pitch-deck/components/slides/ArchitectureSlide.parts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
289
pitch-deck/components/slides/CompetitionSlide.data.ts
Normal file
289
pitch-deck/components/slides/CompetitionSlide.data.ts
Normal 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' },
|
||||
}
|
||||
216
pitch-deck/components/slides/CompetitionSlide.parts.tsx
Normal file
216
pitch-deck/components/slides/CompetitionSlide.parts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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: '$10K–80K/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: '$10K–100K/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: '$6K–25K/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.5K–5.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: '€6K–24K+/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: '€1K–3.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.250–3.333/mo', annual: '€15.000–40.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: '$25K–100K+/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: '$50K–500K+/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: '$40K–300K+/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: '$15K–150K+/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: '$10K–100K+/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: '$50K–250K+/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: '$15K–100K+/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.5K–20K+/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>
|
||||
)
|
||||
}
|
||||
|
||||
221
pitch-deck/components/slides/ExecutiveSummarySlide.pdf.ts
Normal file
221
pitch-deck/components/slides/ExecutiveSummarySlide.pdf.ts
Normal 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>`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
289
pitch-deck/components/slides/FinanzplanSlide.charts.tsx
Normal file
289
pitch-deck/components/slides/FinanzplanSlide.charts.tsx
Normal 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 (2026–2030)</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>
|
||||
)
|
||||
}
|
||||
348
pitch-deck/components/slides/FinanzplanSlide.datagrid.tsx
Normal file
348
pitch-deck/components/slides/FinanzplanSlide.datagrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
pitch-deck/components/slides/FinanzplanSlide.helpers.ts
Normal file
68
pitch-deck/components/slides/FinanzplanSlide.helpers.ts
Normal 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 })
|
||||
}
|
||||
68
pitch-deck/components/slides/FinanzplanSlide.kpis.tsx
Normal file
68
pitch-deck/components/slides/FinanzplanSlide.kpis.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
pitch-deck/components/slides/FinanzplanSlide.skr.tsx
Normal file
139
pitch-deck/components/slides/FinanzplanSlide.skr.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 (2026–2030)</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>
|
||||
)}
|
||||
|
||||
155
pitch-deck/components/slides/MilestonesSlide.data.ts
Normal file
155
pitch-deck/components/slides/MilestonesSlide.data.ts
Normal 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' },
|
||||
]
|
||||
362
pitch-deck/components/slides/MilestonesSlide.parts.tsx
Normal file
362
pitch-deck/components/slides/MilestonesSlide.parts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
pitch-deck/components/slides/MilestonesSlide.themes.ts
Normal file
106
pitch-deck/components/slides/MilestonesSlide.themes.ts
Normal 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
|
||||
@@ -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
|
||||
}) {
|
||||
|
||||
119
pitch-deck/components/slides/USPSlide.data.ts
Normal file
119
pitch-deck/components/slides/USPSlide.data.ts
Normal 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'],
|
||||
},
|
||||
}
|
||||
}
|
||||
370
pitch-deck/components/slides/USPSlide.parts.tsx
Normal file
370
pitch-deck/components/slides/USPSlide.parts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 20–200k 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 20–200k 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()
|
||||
|
||||
259
pitch-deck/components/ui/AnnualPLTable.parts.tsx
Normal file
259
pitch-deck/components/ui/AnnualPLTable.parts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
172
pitch-deck/components/ui/AnnualPLTable.types.ts
Normal file
172
pitch-deck/components/ui/AnnualPLTable.types.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
160
pitch-deck/lib/finanzplan/engine-betrieb.ts
Normal file
160
pitch-deck/lib/finanzplan/engine-betrieb.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
166
pitch-deck/lib/finanzplan/engine-guv.ts
Normal file
166
pitch-deck/lib/finanzplan/engine-guv.ts
Normal 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
|
||||
}
|
||||
154
pitch-deck/lib/finanzplan/engine-liquiditaet.ts
Normal file
154
pitch-deck/lib/finanzplan/engine-liquiditaet.ts
Normal 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() }
|
||||
}
|
||||
106
pitch-deck/lib/finanzplan/engine-sheets.ts
Normal file
106
pitch-deck/lib/finanzplan/engine-sheets.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user